#!/usr/bin/env python3
"""
iScout Web - UI local sobre el backend Frida.
Adjunta a Evony en el emulador rooteado, barre el mapa automaticamente,
y sirve una interfaz web para filtrar por boss / radio / nivel.

Uso:
  /Applications/Xcode.app/Contents/Developer/usr/bin/python3 iscout_web.py
  Abre http://127.0.0.1:8770 en el navegador.
"""
import os, sys, json, math, threading, time, heapq
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
import frida

HERE  = os.path.dirname(os.path.abspath(__file__))
# Versión del scanner: se AUTODETECTA por la carpeta desde la que corre el backend (V4 = evony-scout-v4 o
# evony-scout-pixel; V3 = evony-scout-v3-research). El MISMO código sirve en todos; el badge del header dice
# cuál corre. 2026-08-07: añadido "evony-scout-pixel" -> antes el scanner de los Pixel (motor V4, agent_v4.ts,
# __request_worldmap directo) se etiquetaba V3 porque la carpeta no contenía "evony-scout-v4".
# 2026-08-13: V5 = fork de hardening de la V4 pixel (fixes de la auditoría). Se detecta por "-v5" en la carpeta
# (evony-scout-pixel-v5) ANTES que el "evony-scout-pixel" genérico, que si no la etiquetaría V4.
SCANNER_VERSION = ("V5" if "-v5" in HERE
                   else "V4" if ("evony-scout-v4" in HERE or "evony-scout-pixel" in HERE)
                   else "V3")
_VER_BADGE_HTML = ('<span id=ver_badge title="Versión del scanner en ejecución (autodetectada por carpeta)" '
                   'style="font-size:12px;font-weight:700;padding:3px 9px;border-radius:5px;margin-right:8px;'
                   'background:%s;color:#e0f2fe">%s</span>' %
                   ({"V5": "#047857", "V4": "#0e7490"}.get(SCANNER_VERSION, "#475569"), SCANNER_VERSION))
AGENT = os.path.join(HERE, "agent.js")
PORT  = 8773               # V4 REEMPLAZA a V3: toma su MISMA URL (Tailscale:8772) para que el equipo
                           # acceda igual que antes. Bind = IP de Tailscale (ISCOUT_BIND lo fija run_v4.sh,
                           # como hacía V3). V3 queda RETIRADA — no debe correr a la vez (comparten 6000/6002).
# Persistencia del cache entre restarts del backend. Guarda OBJS, PLAYERS, etc.
# en JSON cada PERSIST_INTERVAL segundos y al cerrar. Filtra al cargar entradas
# con ts más viejas que PERSIST_TTL (24h) para no acumular basura indefinidamente.
CACHE_FILE = os.path.join(HERE, "cache_persist.json")
PERSIST_INTERVAL = 180     # 2026-08-10: 30s -> 180s. MEDIDO en producción: con OBJS a ~430k el
                           # fichero son 105 MB y `save_cache()` tarda ~7-8 s REALES (el mtime del
                           # fichero se espaciaba 37-38 s = 30 s de sleep + 7-8 s de volcado), de los
                           # cuales ~80 ms son CON EL LOCK retenido (rebuild del dict de OBJS) y el
                           # resto retiene el GIL serializando. A 30 s eso es ~21 % del tiempo con el
                           # intérprete ocupado -> compite con el pump de mensajes de frida y con el
                           # RPC de inyección (candidato a los `create_script timeout` que aparecen
                           # cuando una mitad reattacha mientras la otra escanea a tope). A 180 s baja
                           # a ~4 %. Coste: un crash pierde hasta ~3 min de store, que se re-descubre
                           # escaneando. Si el efecto se confirma, el paso definitivo es NO persistir
                           # OBJS (se rellena solo) y dejar solo el histórico irrecuperable.
PERSIST_TTL = 86400        # 24h: entries más viejas se descartan al cargar
# WorldResourceConfig: tipo enum farm_type (1=food, 2=wood, 3=stone, 4=iron, 5+=gems/pumpkin/rose/etc.)
WCFG_TYPE_LABEL = {1: "Farm", 2: "Sawmill", 3: "Quarry", 4: "Iron Mine"}
# Enum sub_city_quality de Evony empieza en 1 (no 0): 1=common..5=legendary.
# Validado 2026-05-27 con subcity (491,779) que en juego es verde y trae quality_id=2.
SUBCITY_QUALITY = {0: "white", 1: "white", 2: "green", 3: "blue", 4: "purple", 5: "gold", 6: "red"}
# Mapping de Msg.sub_city_summary.__evony (Int32) -> nombre de cultura.
# 1-7 = las 7 culturas clasicas (NPC sub-cities normales).
# 10010+ = Famous Cities (sub-cities especiales con buffs unicos, p.ej. Vietnam).
SUBCITY_CULTURE = {
    1: "European", 2: "American", 3: "Chinese", 4: "Russian",
    5: "Korean",   6: "Arabia",   7: "Japan",
    10010: "Vietnam",   # Famous City (visto en este server)
}
# mapinfo_type enum: solo los que usamos para las pestañas
MAPINFO_TYPE = {
    0: "field", 1: "player", 2: "npc", 3: "farm", 4: "garrison",
    5: "ruins", 6: "king_city", 7: "subcity", 8: "snowberg",
    9: "guild_city", 10: "guild_store", 11: "guild_farm", 12: "boss",
    34: "altar", 36: "barbarian_castle", 57: "pyramid",
}

# ── INSTANCIA PIXEL ── Escáneres sobre MÓVILES FÍSICOS (Pixel 7a). Es una instancia SEPARADA
# de evony-scout-v4/ (la de emuladores 6000/6002), que sigue intacta y en su puerto 8772.
# Motivo del cambio de hardware: el `-gpu host -no-window` de los AVD era la causa raíz de los
# STALLED / pantalla blanca; en hardware real ese problema no existe.
#   W = 33201JEHN00951 (Pixel 7a, cuenta evony1_1939, Oeste X<=650)
#   E = 32241JEHN24574 (Pixel 7a, cuenta evony2_1939, Este  X>=650)
SCANNER_W = os.environ.get("SCANNER_W", "33201JEHN00951")
SCANNER_E = os.environ.get("SCANNER_E", "32241JEHN24574")
SCANNERS = [(SCANNER_W, "W"), (SCANNER_E, "E")]

def _is_emulator(serial):
    """True si el dispositivo es un AVD (emulator-NNNN); False si es un móvil físico.
    Las rutas de recuperación difieren: en un AVD se mata qemu y se relanza el emulador; en un
    móvil eso NO aplica, y relanzar el AVD viejo sería además contraproducente."""
    return str(serial).startswith("emulator-")

LOCK   = threading.Lock()
FRIDA_SETUP_LOCK = threading.Lock()   # serializa attach/create_script/load de los 2 escaneres
USERS_LOCK = threading.RLock()        # ALTO#2: serializa lectura-modificacion-escritura de _AUTH/users.json (varios hilos de request lo tocan a la vez). RLock: save_users() puede llamarse ya dentro del lock.
CFG    = {}                 # id -> {name, level, type, power} (MonsterConfig)
WCFG   = {}                 # id -> {name, level, type} (WorldResourceConfig: farms/mines/etc)
ITEMCFG = {}                # item_id -> name (ItemsConfigure: para resolver loot del Farm)

# --- Send Coords (super-admin): whisper top-20 monstruos a [LAN] Lume ---
SEND_COORDS_TARGET_UID  = 334547675              # [LAN] Lume 🔥
SEND_COORDS_SENDER_HALF = "W"                    # envia desde emulator-6000
SEND_COORDS_NAMES       = {"Junior Cerberus", "Junior Knight Bayard"}
# Event monsters: nombre CFG -> niveles (obj.lv) permitidos. Se PRIORIZAN en el
# top-20 (van primero si existen) y el resto se rellena con SEND_COORDS_NAMES.
SEND_COORDS_EVENTS      = {"Junior Hydra": {1}, "Warlord": {1, 2}, "Ymir": {1, 2}, "Normal Barbary Pirate": {1}, "Elite Barbary Pirate": {2}}
SEND_COORDS_CX          = 458.0                  # centro Farm por defecto
SEND_COORDS_CY          = 568.0
OBJS   = {}                 # (wx,wy) -> {t,id,lv,wx,wy,ts}  (pool fusionado de ambos)
RUINS  = {}                 # (wx,wy) -> ruina/pirámide (get_ruins_list_reply, t=57). SEPARADO de OBJS: pirámide y monstruo COEXISTEN en el mismo tile
_PYR_SEEN = set()           # ruins_ids ya logueados (dedup del [pyramid-diag])
RUINS_ID_LEVEL = {14: 5}    # ruins_id (id-space propio de ruinas, NO CFG) -> nivel de pirámide. Observado: 14=Lv5 (evento Pharaoh). Ampliable al ver otros niveles.
PLAYERS = {}                # uid -> {...}  (fusionado de ambos)
# SUMMON_LOG: monstruos INVOCADOS (owner_id>0) registrados EN EL INSTANTE en que el agente
# los ve (kind=summon). Event-driven y persistente (15 min) porque los summons son efímeros
# (mueren rápido) y no aguantan en OBJS para el poll. Alimenta la alerta + un feed de "recientes".
SUMMON_LOG = []             # [{x,y,id,lv,name,group,owner_uid,owner_label,ts,half}]
SUMMON_TILES = {}           # (x,y) -> ts del último summon visto ahí (para que el tag SUMMON
                            # PERSISTA aunque OBJS pierda owner_id entre sweeps, hasta que el monstruo muera)
# SUMMON_LIKELY_TILES: tag AMARILLO (probable). Heurística por patrón: un boss/event que
# APARECIÓ hace <=25s y es atacado/rallyado por un player -> probablemente invocado. (Verde =
# SUMMON_TILES/owner_id, 100% exacto.)
SUMMON_LIKELY_TILES = {}    # (x,y) -> ts
SUMMON_APPEAR_WINDOW = 25   # s: (heurística amarilla DESACTIVADA) ventana aparición->ataque
SUMMON_TTL = 900            # 15 min
# SUMMON_OWNER_MIN: un MONSTRO con nombre y owner_id >= este umbral fue INVOCADO por ese
# jugador/guild (uids reales ~600k guild / ~300M jugador). Por debajo (10-14) son enums de
# facción NPC (Dark Warrior, Crafty Ranger), NO summons. Subcities (type=7) se excluyen por no
# tener nombre de monstruo en CFG. Esta es la señal FIABLE del tag verde (sirve para enemigos).
SUMMON_OWNER_MIN = 100000
MARCHES = {}                # troop_id (int) -> {mty,ow,sx,sy,tx,ty,ts,te,tg,ts_recv}
MARCH_BY_TGT = {}           # (tx,ty) -> {troop_id: True}  (indice rapido para enrich)
SUBCITIES = {}              # subcity_id -> {wx,wy,owner_uid,name,shield_end,...}
SHIELD_ETA = {}             # uid -> {end_time, src, confidence, activation_ts, ts}
                            # confidence: 'exact' (scout/mail/subcity con uid) |
                            #             'inferred_sku' (0->1, usa el SKU habitual aprendido del jugador) |
                            #             'inferred_default' (0->1 transition, asume DEFAULT) |
                            #             'inferred_history' (0->1 con histórico previo, usa mediana) |
                            #             'newbie_inferred' (0->2, asume 7d max)
SHIELD_HISTORY = {}         # uid -> list[{activation_ts, expired_ts, duration_s, tier, src}]
                            # archivo cuando shield cae 1->0 o 2->0. Sobrevive reinicios via cache_persist.
SHIELD_HISTORY_MAX = 5      # max muestras por uid (las mas recientes ganan)
SHIELD_LAST_TRANSITION = {} # uid -> ts del ultimo cambio de tier (para anti-flicker cooldown)
SHIELD_FLICKER_COOLDOWN_S = 60   # ignorar transitions opuestas <60s del ultimo cambio
SHIELD_MIN_DURATION_S = 300      # 5min: duraciones menores se asumen flicker, no se archivan
SHIELD_DEFAULT_DURATION_S = 8 * 3600       # 8h: default mas comun de item-shields en Evony
SHIELD_NEWBIE_MAX_S = 7 * 86400            # 7d: maximo del newbie shield (tier=2)
# SKUs estandar de Peace Shield (segundos). Las duraciones OBSERVADAS (activacion->expiracion)
# son ruidosas por el lag de deteccion; encajarlas al SKU real que compro el jugador hace que
# la inferencia en renovaciones sea precisa en vez de adivinar 8h planas.
STANDARD_SHIELD_SKUS = [
    1 * 3600, 4 * 3600, 8 * 3600, 12 * 3600,
    1 * 86400, 2 * 86400, 3 * 86400, 5 * 86400,
    7 * 86400, 14 * 86400, 30 * 86400,
]
SHIELD_SKU_TOL = 0.18      # acepta snap solo si observed/sku esta dentro de +-18% (sino no fuerza)
SHIELD_BREAK_PENDING = {}   # uid -> ts de la 1a lectura tier=0 que contradice un exact vivo.
                            # Defiende un exact (scout: ground truth) de broadcasts parciales/stale
                            # que reportan tier=0 falso. La rotura solo se confirma si tier=0 PERSISTE.
EXACT_BREAK_CONFIRM_S = 90  # segundos que un tier=0 debe persistir para confirmar rotura de un exact
SHIELD_RECENT = []          # ultimos N shield captures (mas reciente primero) p/ UI toast
SHIELD_RECENT_MAX = 20
WATCHLIST = set()           # set[int uid] de uids marcados como prioritarios (alertas + scout queue)
WATCHLIST_NOTES = {}        # uid -> {note: str, added_ts: int, source: 'manual'|'auto'}
WATCHLIST_BLACKLIST = set() # uids descartados por el usuario (no se re-auto-anaden)
# Reglas para auto-watchlist. Worker auto_watchlist_thread() lee PLAYERS cada 60s
# y anade uids que cumplan reglas (no estan ya en WATCHLIST ni en BLACKLIST).
WATCHLIST_RULES = {
    "enabled": True,
    "enemy_tags": ["NBB"],          # tags que cuentan como enemigo (auto-add si coincide)
    "exclude_tags": ["MVP"],        # tu alianza + aliados (nunca auto-add)
    "min_castle": 0,                # castle level minimo del player
    "min_power_M": 0,               # power minimo en millones
    "require_shield_seen": True,    # solo si hemos visto tier>=1 alguna vez (SHIELD_HISTORY o SHIELD_ETA)
}
PLAYER_POWER = {}           # uid -> {power, rank, ts} (de power_rank_reply)
# V3 RESEARCH: nuevos rankings
PLAYER_FAME  = {}           # uid -> {fame, rank, ts}
PLAYER_KILLS = {}           # uid -> {kills, rank, ts}
PLAYER_KEEP  = {}           # uid -> {keep_rank, rank, ts}  (keep_rank: nivel del keep)
PLAYER_POP   = {}           # uid -> {popularity, title, rank, ts}
GUILD_MEMBERS = {}          # gid -> {tag, members: [{uid, name, clv, power, shield, auth, honour, lastseen, ts}], updated_ts}
MEMBER_LASTSEEN = {}        # uid -> lastseen (epoch unix; 0/1 = centinela "online", del campo __lastseen de user_summary en la member-list de la alianza). Fuente AUTORITATIVA de actividad (vs inferencia).
MEMBER_INFO = {}            # uid -> {power, shield, ts} de la member-list (autoritativo para enemigos cuando su alianza es polleada; el power_rank solo cubre nuestro server)
SELF = {"W": {"uid": 0, "guild_id": 0}, "E": {"uid": 0, "guild_id": 0}}   # uid+guild_id por scanner
DETECTED_SERVER_ID = {"W": 0, "E": 0}   # server_id auto-detectado por el agente desde broadcasts

# ── SVS / enemigo ────────────────────────────────────────────────────────────
# Clasificacion de enemigo para los paneles SVS. Modelo hibrido:
#   1) AUTO: jugador con server_id != nuestro_server => enemigo (en SVS 1v1 es fiable).
#   2) OVERRIDE: si ENEMY_SERVER_OVERRIDE>0, enemigo == ese server exacto.
#   3) TAGS: alianzas marcadas como enemigas (por tag o guild_id) -> refuerzo.
OUR_SERVER_FALLBACK   = 1939            # nuestro server (fallback si no hay deteccion)
ENEMY_SERVER_OVERRIDE = 0               # 0 = auto; >0 = fijar server enemigo concreto
ENEMY_GUILD_TAGS      = set()           # tags de alianza enemiga (lowercase), p.ej. {"lume"}
ENEMY_GUILD_IDS       = set()           # guild_ids enemigos (int)
ENEMY_CFG_FILE        = os.path.join(HERE, "enemy_config.json")
SVS_SHARE_TARGET_UID  = 334547675       # destino del "Share targets" (Lume, de prueba;
                                        # luego se cambiará al chat de alianza/room)

def our_server():
    """Nuestro server_id: consenso de los dos scanners, o fallback 1939."""
    sw, se = DETECTED_SERVER_ID.get("W", 0), DETECTED_SERVER_ID.get("E", 0)
    if sw > 0 and sw == se: return sw
    return sw or se or OUR_SERVER_FALLBACK

SVS_MIN_ENEMY_PLAYERS = 20    # umbral de jugadores para considerar "SVS activo"
                              # (un server extranjero dominante = rival; por debajo
                              #  son inmigrantes sueltos -> no son enemigos)

def foreign_breakdown(now=None):
    """(el caller debe tener LOCK) -> (our_server, {server: count}) de los
    jugadores extranjeros recientes vistos en el mapa."""
    now = now or time.time()
    ours = our_server()
    foreign = {}
    for p in PLAYERS.values():
        if now - p.get("ts", 0) > TTL_SECONDS: continue
        sv = int(p.get("sv", 0) or 0)
        if sv > 0 and sv != ours:
            foreign[sv] = foreign.get(sv, 0) + 1
    return ours, foreign

def effective_enemy_server(foreign):
    """Server enemigo EFECTIVO: el override manual si está puesto; si no, el
    server extranjero DOMINANTE pero solo si supera el umbral SVS (así fuera de
    SVS, con 1-3 inmigrantes, no se marca a nadie). 0 = ningún enemigo por server."""
    if ENEMY_SERVER_OVERRIDE > 0:
        return ENEMY_SERVER_OVERRIDE
    if not foreign:
        return 0
    srv, cnt = max(foreign.items(), key=lambda kv: kv[1])
    return srv if cnt >= SVS_MIN_ENEMY_PLAYERS else 0

def is_enemy(p, enemy_sv):
    """p: dict de PLAYERS. enemy_sv: server enemigo efectivo (de
    effective_enemy_server). True si es enemigo por server o por tag/guild marcado."""
    if not p: return False
    gid = int(p.get("gid", 0) or 0)
    tag = (p.get("tag") or "").strip().lower()
    # capa tags de alianza enemiga (marca siempre, aunque no haya SVS de server)
    if gid and gid in ENEMY_GUILD_IDS: return True
    if tag and tag in ENEMY_GUILD_TAGS: return True
    if enemy_sv <= 0: return False
    return int(p.get("sv", 0) or 0) == enemy_sv

def load_enemy_cfg():
    global ENEMY_SERVER_OVERRIDE, ENEMY_GUILD_TAGS, ENEMY_GUILD_IDS
    try:
        with open(ENEMY_CFG_FILE) as f:
            d = json.load(f)
        ENEMY_SERVER_OVERRIDE = int(d.get("enemy_server", 0) or 0)
        ENEMY_GUILD_TAGS = set(str(t).strip().lower() for t in d.get("enemy_tags", []) if str(t).strip())
        ENEMY_GUILD_IDS  = set(int(g) for g in d.get("enemy_gids", []) if g)
        print(f"[enemy] cfg cargada: server={ENEMY_SERVER_OVERRIDE or 'auto'} "
              f"tags={sorted(ENEMY_GUILD_TAGS)} gids={sorted(ENEMY_GUILD_IDS)}", flush=True)
    except FileNotFoundError:
        pass
    except Exception as e:
        print(f"[enemy] cfg load err: {e}", flush=True)

def set_enemy_cfg(server, tags, gids):
    """Aplica y persiste la config de enemigo. En función módulo propia para que
    el `global` no choque con lecturas de estas variables en do_POST (svs_share)."""
    global ENEMY_SERVER_OVERRIDE, ENEMY_GUILD_TAGS, ENEMY_GUILD_IDS
    try: ENEMY_SERVER_OVERRIDE = int(server or 0)
    except Exception: ENEMY_SERVER_OVERRIDE = 0
    ENEMY_GUILD_TAGS = set(str(t).strip().lower() for t in (tags or []) if str(t).strip())
    g = set()
    for x in (gids or []):
        try: g.add(int(x))
        except Exception: pass
    ENEMY_GUILD_IDS = g
    save_enemy_cfg()

def save_enemy_cfg():
    try:
        tmp = ENEMY_CFG_FILE + ".tmp"
        with open(tmp, "w") as f:
            json.dump({
                "enemy_server": ENEMY_SERVER_OVERRIDE,
                "enemy_tags": sorted(ENEMY_GUILD_TAGS),
                "enemy_gids": sorted(ENEMY_GUILD_IDS),
            }, f, ensure_ascii=False, indent=2)
        os.replace(tmp, ENEMY_CFG_FILE)
    except Exception as e:
        print(f"[enemy] cfg save err: {e}", flush=True)
# SCAN_STATS: métricas por scanner para comparar estrategias de división (E/W vs N/S vs corte).
#   discoveries: descubrimientos NUEVOS de tile (primera vez visto) atribuidos al half
#   respawns: re-descubrimientos (tile recurrente que reaparece tras gap)
#   monsters_by_group: {Boss, Event, Normal} descubiertos por el half
#   cerberus: contador específico de Cerberus (interés histórico del proyecto)
#   redetects: re-detecciones de tiles ya conocidos (mide solapamiento/eficiencia)
#   passes: vueltas completas del sweep de ese half
def _new_scan_stat():
    return {"discoveries": 0, "respawns": 0, "redetects": 0,
            "monsters_by_group": {"Boss": 0, "Event": 0, "Normal": 0},
            "cerberus": 0, "passes": 0, "last_obj_ts": 0,
            # frescura zona central (±100 tiles): suma de intervalos entre re-detecciones
            # de objetos centrales y su conteo → avg = cada cuántos s se refresca el centro
            "central_refresh_sum": 0.0, "central_refresh_n": 0}
# zona central para la métrica de frescura (±CENTRAL_RADIUS tiles alrededor del centro)
CENTRAL_CX, CENTRAL_CY, CENTRAL_RADIUS = 603, 570, 100

# Focus Zone (opt-in): cuando active, el scanner del half elegido RONDA una zona
# (centro+radio, o el centroide de una alianza por tag) en vez de barrer todo —
# recibe las marchas de esa zona en continuo y capta ataques de hasta ~2s.
# Mientras active, ese half NO barre el resto del mapa (trade-off asumido).
# Pre-config: NUD (alianza rival servidor 1939) centro ~638,600 → half E (x>606=Este).
# GLOBAL (compartido por todos los usuarios y el thread del scanner) y PERSISTENTE
# (se guarda en focus_config.json y se restaura al arrancar -> sobrevive reinicios).
FOCUS = {"active": False, "half": "W", "cx": 0, "cy": 0, "radius": 200, "tag": "", "by": ""}   # default: SIN focus (sweep completo). Antes venía hardcodeado active=True/tag=NUD -> ahogaba el sweep de W (STALLED). El focus se activa opt-in por UI.
FOCUS_CFG_FILE = os.path.join(HERE, "focus_config.json")
FOCUS_MAX_RADIUS = 600   # tope de radio (antes 200). Radios grandes cubren más zona
                         # pero revisitan cada punto más lento (dwell menos apretado).

def load_focus_cfg():
    try:
        with open(FOCUS_CFG_FILE) as f:
            d = json.load(f)
        FOCUS["active"] = bool(d.get("active", FOCUS["active"]))
        h = (d.get("half") or FOCUS["half"]).upper(); FOCUS["half"] = h if h in ("W", "E") else "E"
        FOCUS["cx"] = int(d.get("cx", FOCUS["cx"]) or 0)
        FOCUS["cy"] = int(d.get("cy", FOCUS["cy"]) or 0)
        FOCUS["radius"] = max(0, min(int(d.get("radius", FOCUS["radius"]) or 0), FOCUS_MAX_RADIUS))
        FOCUS["tag"] = str(d.get("tag", FOCUS["tag"]) or "")
        FOCUS["by"] = str(d.get("by", FOCUS.get("by", "")) or "")
        print(f"[focus] cfg cargada: {FOCUS}", flush=True)
    except FileNotFoundError:
        pass
    except Exception as e:
        print(f"[focus] cfg load err: {e}", flush=True)

def save_focus_cfg():
    try:
        tmp = FOCUS_CFG_FILE + ".tmp"
        with open(tmp, "w") as f:
            json.dump(FOCUS, f, ensure_ascii=False, indent=2)
        os.replace(tmp, FOCUS_CFG_FILE)
    except Exception as e:
        print(f"[focus] cfg save err: {e}", flush=True)

# ── Perfiles de escáner (region + server por half) ───────────────────────────
# Permite guardar/restaurar la topología de los escáneres y cambiarla EN CALIENTE
# (sin recompilar) via ctl scan_cfg. region: "W"|"E" (mitad) | "FULL" (mapa entero).
# server: 0 = auto-detect | >0 = forzar (p.ej. escanear el server enemigo en SVS).
#   home      = estado normal: 6000=W/auto, 6002=E/auto (mitades de nuestro server).
#   svs_split = SVS: 6000=FULL/1939 (todo nuestro server), 6002=FULL/1954 (server enemigo).
SCANNER_CFG_FILE = os.path.join(HERE, "scanner_config.json")
SCANNER_CFG = {
    "active": "home",
    "fps": 0,    # 0 = NO tocar el fps (deja el default 30 = ESTABLE, como V3). El throttle a 10 DESESTABILIZÓ el scanner (2026-07-20: el juego se quedaba en pantalla negra FUERA del mapa -> autoWM no recuperaba -> sin escaneo), así que OFF por defecto. Opt-in posible con POST /api/scanner_fps, pero a fps bajo la NAVEGACIÓN (cerrar popups + FocusOnWorldMap) se rompe -> no bajar de ~25 sin vigilar
    "profiles": {
        "home":      {"W": {"region": "W", "server": 0}, "E": {"region": "E", "server": 0}},
        "svs_split": {"W": {"region": "FULL", "server": 1939}, "E": {"region": "FULL", "server": 1954}},
    },
}

def load_scanner_cfg():
    global SCANNER_CFG
    try:
        with open(SCANNER_CFG_FILE) as f:
            d = json.load(f)
        if isinstance(d, dict) and d.get("profiles"):
            SCANNER_CFG = d
            SCANNER_CFG.setdefault("active", "home")
            print(f"[scanner-cfg] cargada: active={SCANNER_CFG['active']} "
                  f"perfiles={list(SCANNER_CFG['profiles'])}", flush=True)
    except FileNotFoundError:
        save_scanner_cfg()   # primera vez: persistir defaults
    except Exception as e:
        print(f"[scanner-cfg] load err: {e}", flush=True)

def save_scanner_cfg():
    try:
        tmp = SCANNER_CFG_FILE + ".tmp"
        with open(tmp, "w") as f:
            json.dump(SCANNER_CFG, f, ensure_ascii=False, indent=2)
        os.replace(tmp, SCANNER_CFG_FILE)
    except Exception as e:
        print(f"[scanner-cfg] save err: {e}", flush=True)

def scan_cfg_for(half):
    """Devuelve {region, server} del half segun el perfil ACTIVO (fallback al half nativo)."""
    prof = SCANNER_CFG.get("profiles", {}).get(SCANNER_CFG.get("active", "home"), {})
    d = prof.get(half) or {"region": half, "server": 0}
    return {"region": str(d.get("region", half) or half), "server": int(d.get("server", 0) or 0)}

def push_scan_cfg(half=None):
    """Envia el scan_cfg del perfil activo a los escaneres vivos (uno o ambos)."""
    halves = [half] if half else list(SCRIPTS.keys())
    for h in halves:
        sc = SCRIPTS.get(h)
        if sc is None:
            continue
        c = scan_cfg_for(h)
        try:
            sc.post({"type": "scan_cfg", "region": c["region"], "server": c["server"]})
            print(f"[scanner-cfg] scan_cfg -> {h}: region={c['region']} server={c['server'] or 'auto'}", flush=True)
        except Exception as e:
            print(f"[scanner-cfg] post {h} err: {e}", flush=True)

# Filtros de Active Attacks: (1) ocultar SIEMPRE el server enemigo del SVS y (2) filtrar
# por el tag de alianza del Focus Zone. Ambos se aplican en /api/attacks (no necesitan
# config propia: la regla 1 es permanente y la 2 deriva del Focus).
SCAN_STATS = {"W": _new_scan_stat(), "E": _new_scan_stat(),
              "session_start": time.time(), "mode": "X@606_core12"}
# ── V3 PvP INTELLIGENCE ──────────────────────────────────────────────────────
# Datos de batalla/scout/PvE extraídos de mails (propios + compartidos por aliados).
# Limitación: solo capturamos lo que pasa por nuestras cuentas o se comparte en chat.
import collections as _collections
BATTLE_LOG   = _collections.deque(maxlen=3000)   # batallas PvP: [{ts, attacker, defender, atk_battle, def_battle, generals...}]
SCOUT_INTEL  = {}                                 # uid -> último scout report {troops, def_general, wall, tactics, ts}
MONSTER_KILLS= _collections.deque(maxlen=3000)    # PvE: [{ts, monster_id, user, general, damage, lost_power}]
_BATTLE_SEEN = set()                              # keys ya procesadas (dedup de reports repetidos)
# ── Player Activity Tracker (Fase 1+2) ───────────────────────────────────────
# Inferimos "estuvo ONLINE" a partir de ACCIONES (no del ts del escáner, que es solo
# cobertura de mapa). Fuentes: march (lanzó tropas), relocate (movió castillo),
# shield (activó burbuja), pvp (atacó en un report). Acumula histórico persistente
# (24h+ vía cache_persist) que madura con los días → última actividad + horas activas.
PLAYER_ACT      = {}        # uid -> {first,last,total,by_type{}, hod[168] (7d×24h, hora local), recent deque}
_ACT_SEEN_MARCH = set()     # trp ya contabilizados (1 march = 1 evento; dedup en memoria)
ACT_RECENT_MAX  = 40        # ring de eventos recientes por jugador
# OPCIÓN B — timing fino: por uid, los timestamps de INICIO REAL (game time) de cada
# marcha lanzada (1 por trp). Permite analizar los INTERVALOS entre lanzamientos: una
# cadencia fija (ej. cada 30.0s repetido) es firma de script imposible de fingir a mano.
ACTION_TIMES    = {}        # uid -> deque de epochs de lanzamiento (game ts), cap 400
ACTION_TIMES_MAX = 400
# Registro de RALLIES a monstruos (para detectar robo/contención de rallies): cada rally
# nuevo a un monstruo con su dueño/alianza/tile/tiempo. Permite ver dónde LAN y un enemigo
# (p.ej. NUD) rallean el MISMO objetivo y quién llegó primero (= quién lo está robando).
RALLY_LOG_MAX = 6000
RALLY_LOG = _collections.deque(maxlen=RALLY_LOG_MAX)   # eventos de rally a monstruos
# Velocidad de marcha: por uid, la velocidad MÁX observada y muestras rápidas. Sirve de
# tripwire de SPEED-HACK: solo es anomalía si supera el máximo FÍSICO posible (cap muy
# alto). Los buffs de marcha rápida normales caen MUY por debajo y NO se marcan.
SPEED_STATS = {}            # uid -> {max, n, samples:[{dist,dur,sp,tx,ty,ts}]}
IMPOSSIBLE_SPEED = 5.0      # tiles/s: tope conservador (observado real ~2.8 con buffs full)
# V3 RESEARCH: contadores de packets UP/DOWN por tipo, alimentados por protocol tracer
PROTO_STATS = {}    # half ("W"/"E") -> {up: {msgname: count}, down: {...}, total_up, total_down, last_update}
MONSTERS_BY_TILE = {}       # (wx,wy) -> {id, lv, name, ts} cache LARGO de ultimo monstruo visto
                            # en cada tile. Sobrevive al prune de OBJS. Permite resolver el
                            # target de attacks aunque el monstruo ya no se broadcasta (combate).
MONSTERS_TTL = 3600         # 1h: si en 1h no se ve nada en el tile, se descarta

def _record_activity(uid, ev_type, ts=None):
    """Registra un evento de actividad => el jugador estuvo ONLINE en ese instante.
    NO usa el ts del escáner (que es solo cobertura de mapa). Acumula last/first,
    total, by_type, histograma 7×24 (hora local) y un ring de eventos recientes.
    DEBE llamarse con LOCK ya tomado por el caller."""
    try:
        uid = int(uid)
    except Exception:
        return
    if uid <= 0:
        return
    if ts is None:
        ts = time.time()
    a = PLAYER_ACT.get(uid)
    if a is None:
        a = {"first": ts, "last": ts, "total": 0, "by_type": {},
             "hod": [0] * 168, "recent": _collections.deque(maxlen=ACT_RECENT_MAX)}
        PLAYER_ACT[uid] = a
    if ts > a["last"]:
        a["last"] = ts
    if ts < a["first"]:
        a["first"] = ts
    a["total"] += 1
    a["by_type"][ev_type] = a["by_type"].get(ev_type, 0) + 1
    lt = time.localtime(ts)
    a["hod"][lt.tm_wday * 24 + lt.tm_hour] += 1
    a["recent"].append({"ts": int(ts), "type": ev_type})

# ── Boss Respawn Predictor ──────────────────────────────────────────────────
# Por cada tile guardamos hasta SPAWN_HISTORY_MAX timestamps de "primera vez visto"
# (un nuevo spawn). El intervalo entre spawns consecutivos = lifetime + respawn_gap;
# para predecir el próximo basta con: last_first_ts + median(intervalos).
SPAWN_HISTORY = {}          # (wx,wy) -> [{ts, id, lv}, ...]  últimas N apariciones
SPAWN_HISTORY_MAX = 8       # suficiente para mediana estable sin inflar memoria

# ── Player Relocation Tracker ───────────────────────────────────────────────
# Por cada uid guardamos hasta RELOCATIONS_MAX eventos de cambio de coords.
# Se detecta comparando wx/wy de PLAYERS[uid] con el batch nuevo.
RELOCATIONS = {}            # uid -> [{ts, from:(x,y), to:(x,y)}, ...]
RELOCATIONS_MAX = 20
SHIELD_ALERT_THRESHOLD = 600  # 10 minutos: ETAs < esto se marcan como "expiring soon" para alertas
SHIELD_ALERTED = set()      # uids ya alertados (para no spamear toasts)
SCRIPTS = {"W": None, "E": None}   # refs a los scripts cargados (para pause/resume via .post)
FRIDA_SESSIONS = {"W": None, "E": None}   # sesiones frida por half -> aplicar el throttle de fps SIN recompilar el agente
_FPS_APPLIED = {"W": False, "E": False}   # el throttle se aplica UNA vez por half, al confirmar escaneo (post-login)

# ── Throttle de fps del juego (ahorro de CPU del emulador) ─────────────────────────────────────
# V4 escanea por PROTOBUF (request_worldmap por timer de frida + parseo por DecodeCallback): NO
# necesita que el juego renderice. Los emuladores del scanner renderizaban a 30fps PARA NADIE
# (~85% CPU c/u en OpenGL). Bajar targetFrameRate corta ese gasto. OJO (medido 2026-07-18): el
# procesado de replies va algo atado al frame de Unity -> a fps muy bajo el ESCANEO se ralentiza
# (la cadence adaptativa sube). Default SCANNER_CFG["fps"]=10 = compromiso (CPU ~mitad, escaneo
# ~tipo V3). vSyncCount DEBE ir a 0 o Unity ignora targetFrameRate. Se aplica TRAS confirmar escaneo
# (primer 'batch', post-login) para NO ralentizar el re-login manual de un cold-boot.
FPS_JS = r"""
const lib = Process.getModuleByName("libil2cpp.so");
function ex(n){const p=lib.findExportByName?lib.findExportByName(n):lib.getExportByName(n);if(!p)throw new Error("falta "+n);return p;}
const dg=new NativeFunction(ex("il2cpp_domain_get"),'pointer',[]);
const ta=new NativeFunction(ex("il2cpp_thread_attach"),'pointer',['pointer']);
const ao=new NativeFunction(ex("il2cpp_domain_assembly_open"),'pointer',['pointer','pointer']);
const ai=new NativeFunction(ex("il2cpp_assembly_get_image"),'pointer',['pointer']);
const cn=new NativeFunction(ex("il2cpp_class_from_name"),'pointer',['pointer','pointer','pointer']);
const mn=new NativeFunction(ex("il2cpp_class_get_method_from_name"),'pointer',['pointer','pointer','int']);
const dom=dg(); ta(dom);
function cls(ns,name){for(const a of["UnityEngine.CoreModule","UnityEngine"]){try{const asm=ao(dom,Memory.allocUtf8String(a));if(asm.isNull())continue;const k=cn(ai(asm),Memory.allocUtf8String(ns),Memory.allocUtf8String(name));if(!k.isNull())return k;}catch(e){}}return null;}
function m(k,n,argc){const x=mn(k,Memory.allocUtf8String(n),argc);return x.isNull()?null:x;}
function si(f,v){new NativeFunction(f.readPointer(),'void',['int','pointer'])(v,f);}
function gi(f){return new NativeFunction(f.readPointer(),'int',['pointer'])(f);}
const App=cls("UnityEngine","Application"), QS=cls("UnityEngine","QualitySettings");
const sVS=QS?m(QS,"set_vSyncCount",1):null, sTF=App?m(App,"set_targetFrameRate",1):null, gTF=App?m(App,"get_targetFrameRate",0):null;
if(sVS)si(sVS,0); if(sTF)si(sTF,__FPS__);
send({kind:"fps_set", fps: gTF?gi(gTF):null});
"""

def _apply_fps(half, fps):
    """Fija el targetFrameRate del juego en el emulador de ese half (best-effort; si falla, el
    scanner funciona igual, solo gasta más CPU)."""
    try:
        fps = int(fps or 0)
        if fps <= 0: return False
        sess = FRIDA_SESSIONS.get(half)
        if not sess: return False
        got = {}
        s = sess.create_script(FPS_JS.replace("__FPS__", str(fps)))
        s.on("message", lambda mm, d: got.update(mm.get("payload") or {}) if mm.get("type") == "send" else None)
        s.load()
        try: s.unload()
        except Exception: pass
        print(f"[{half}] fps throttle: targetFrameRate={got.get('fps')} (menos render = menos CPU del emulador)", flush=True)
        return True
    except Exception as e:
        print(f"[{half}] fps throttle err: {e}", flush=True)
        return False

def _maybe_apply_fps(half):
    """Aplica el throttle UNA vez por half, al confirmar escaneo (post-login)."""
    if _FPS_APPLIED.get(half): return
    _FPS_APPLIED[half] = True
    _apply_fps(half, int(SCANNER_CFG.get("fps", 0) or 0))
SCANNER_PAUSED = False             # estado global pausa/resume
LAST_HEARTBEAT = {"W": 0.0, "E": 0.0}   # ts del último mensaje del agent (cualquier kind)
HEARTBEAT_TIMEOUT = 90              # si no llega nada en 90s, el agent se considera muerto
FIRST_HB_GRACE = 180               # ventana extendida SOLO para el 1er heartbeat tras attach:
                                   # el init del bridge il2cpp sobre un Evony recién arrancado
                                   # puede tardar >90s; sin esta gracia se perpetúa el ciclo HARD.
SCAN_FREEZE_TIMEOUT = 150          # ANTI-FREEZE REAL (bug 2026-07-20): el heartbeat es un setInterval
                                   # del agente INDEPENDIENTE del escaneo -> puede seguir latiendo
                                   # mientras el escaneo está CONGELADO (0 objetos nuevos). El watchdog
                                   # de heartbeat NO lo detecta y el estado se queda "running" eterno.
                                   # Por eso: si sweep=="running"/"pasada" y NO llega ningún objeto en
                                   # SCAN_FREEZE_TIMEOUT s (con heartbeat vivo y sin pausa manual), se
                                   # considera FREEZE y se fuerza reattach. En V4 protobuf NO hay
                                   # sweep_pass, así que se gatea por sweep=="running", no COLD_GRACE_DONE.
WARMUP_PAUSE_S = 25                # ANTI-FREEZE (sobre todo W): tras cada (re)attach exitoso,
                                   # pausamos el sweep N s para que il2cpp-bridge + la cold-load
                                   # de Evony terminen SIN competir por CPU con los jumps del mapa.
                                   # El heartbeat del agente es un setInterval independiente del
                                   # loop de jumps (sigue latiendo durante la pausa) -> no dispara
                                   # el watchdog. La mitad densa (W) es la que más lo necesita.
REATTACH_FAILS = {"W": 0, "E": 0}   # contador de reattaches fallidos consecutivos
# ESCALERA DE RECUPERACIÓN (de barato a caro). El orden importa: HARD debe ser < NUCLEAR
# para que se ejecute PRIMERO. Antes estaba invertido (hard=15 > nuclear=6) -> el HARD-RESET
# nunca corría y TODO problema saltaba directo al reboot de qemu (3min, pesado, y dispara el
# doom-loop 'device not found'). Ahora: reintentos ligeros -> HARD (reinicio app Evony, ~38s,
# arregla Unity/IL2CPP colgado = la causa real del create_script-timeout) -> NUCLEAR (reboot
# qemu) solo como último recurso si ni reiniciar la app lo cura.
HARD_RESET_THRESHOLD = 3            # tras 3 fallos: force-stop + relaunch Evony (ligero)
NUCLEAR_RESET_THRESHOLD = 10**9     # 2026-08-07: DESACTIVADO en Pixel físico. El NUCLEAR aquí = _nuclear_reset_phone = REBOOT
                                    # del móvil, que puede NO volver a adb (perdimos E el 07/08 -> escáner entero caído). La escalera se
                                    # queda en HARD (force-stop+relaunch Evony, reversible); un wedge que el HARD no cure se resuelve a
                                    # mano con recover_pixel.sh — NUNCA rebooteando el hardware solo.
# ESCALERA POR FREEZE (fix 2026-08-05): un FREEZE (heartbeat vivo, sweep=running, 0 objetos nuevos)
# = el JUEGO vive pero su render/sesión está colgado (típico: pantalla blanca de E/W). El reattach
# "tiene éxito" (frida attacha al juego vivo) -> reseteaba REATTACH_FAILS -> HARD/NUCLEAR NUNCA subían
# -> bucle reattach<->FREEZE eterno (nunca reiniciaba el juego, que es lo único que arregla el blanco).
# Ahora los FREEZE repetidos escalan por su cuenta empujando REATTACH_FAILS al umbral correspondiente.
FREEZE_FAILS = {"W": 0, "E": 0}     # freezes consecutivos SIN recuperación real (objetos volviendo a fluir)
LAST_FREEZE_TS = {"W": 0, "E": 0}
FREEZE_HARD_AT = 2                  # 2º freeze en la ventana -> HARD. (2026-08-14: probado =1 (opción B, HARD directo) pero en una cascada de RENDER-freeze (juego no renderiza tras relaunch) el =1 hace loop apretado sin dar tiempo a renderizar -> revertido a 2, que mete un reattach de ~150s de margen entre resets.)
FREEZE_NUCLEAR_AT = 10**9         # 2026-08-07: DESACTIVADO en Pixel físico (ver NUCLEAR_RESET_THRESHOLD: el reboot puede no volver).
                                  # Además el fix de last_obj_ts ya elimina los FREEZE falsos, así que esta vía casi no se dispara.
FREEZE_WINDOW_S = 600              # 10min sin freeze = recuperado -> el contador vuelve a 0
# AVD names + emulator binary para nuclear reset
NUCLEAR_AVD = {"W": ("evony1_1939", 6000), "E": ("evony2_1939", 6002)}
EMULATOR_BIN = "/opt/homebrew/share/android-commandlinetools/emulator/emulator"
# nav-unstick: el agente avisa (kind=nav_stuck) cuando no llega al world map (popup modal o
# subpantalla bloqueando FocusOnWorldMap). El backend responde host-side poniendo Evony en
# foreground. Throttle por mitad para no spamear (el agente reintenta cada 5-30s).
#
# ⚠️ POLÍTICA 2026-08-10 — NO SE PULSA "BACK" NUNCA MÁS:
# antes se enviaban BACK x2 en cada aviso para "pelar" el popup. El comentario del código
# afirmaba que un BACK sobre el diálogo "Are you sure you want to exit the game?" lo CANCELA.
# **Es FALSO**: en E se vieron 38 rachas seguidas y el juego quedó APARCADO en ese diálogo, en
# la ciudad, sin escanear (confirmado con screencap). El pelado creaba el problema que decía
# resolver. Ahora: solo foreground y, si sigue atascado NAV_STUCK_RESTART_S, REINICIO LIMPIO
# del juego (force-stop + relanzar + esperar render), que borra cualquier diálogo.
LAST_NAV_BACK = {"W": 0.0, "E": 0.0}
NAV_BACK_COOLDOWN = 8.0
NAV_TAP_COOLDOWN = 20.0      # no tocar la pantalla más de una vez cada 20s
NAV_TAP_AFTER_S  = 20.0      # ni antes de llevar 20s atascado (evita pisar transiciones normales)
# ── Coordenadas MEDIDAS con capturas reales (Pixel 7a, 1080x2400) el 2026-08-10 ────────
# Tras CADA reinicio el juego queda en la CIUDAD con el modal "Daily Rewards" encima, que
# FocusOnWorldMap NO puede atravesar (el agente lo dice: "sin closer in-process"). Con el
# cliente fuera del mapa el servidor responde CERO a request_worldmap (`replies/req=0.00`,
# medido en 98 de 103 casos) -> 150s sin objetos -> el watchdog lo llama "FREEZE". No es
# un cuelgue: es navegación. Secuencia de rescate verificada en vivo (E pasó de 0 a 3.500
# objetos en 5s y el agente reportó "cliente en world map, scanner activo"):
TAP_CHECKIN = (532, 1775)    # botón "Check-in" del modal Daily Rewards (lo cierra + cobra)
TAP_GLOBE   = (1006, 2316)   # globo abajo-derecha = conmutador al world map
# ⚠️ Son coordenadas FIJAS de 1080x2400. Si Evony cambia su interfaz hay que RE-MEDIRLAS con
#    `adb exec-out screencap -p` y mirar la imagen; no adivinarlas.
NAV_STUCK_SINCE = {"W": 0.0, "E": 0.0}    # inicio del episodio actual fuera del mapa (0 = no atascado)
NAV_STUCK_EPISODES = {"W": 0, "E": 0}     # episodios consecutivos sin escanear (para escalar)
NAV_STUCK_RESTART_S = 90                  # bloqueado 90s -> reinicio LIMPIO de esa mitad

# ── LOG DE INCIDENCIAS (se sirve en /api/incidents y se ve en Settings) ──────────────
# Motivo: cuando una mitad dejaba de escanear, el "por qué" vivía SOLO en /tmp/evony_pixel.log,
# inaccesible desde la web. Aquí queda estructurado, por mitad, con motivo y detalle.
INCIDENTS = _collections.deque(maxlen=400)
INC_LOCK = threading.Lock()

INCIDENTS_FILE = os.path.join(HERE, "incidents.jsonl")   # persistencia: sobrevive a reinicios
INC_MAX_LINES = 2000                                     # tope del fichero (se poda al arrancar)
SCANNER_LOG = os.environ.get("SCANNER_LOG", "/tmp/evony_pixel.log")

def _incident(half, kind, detail="", src="live"):
    """Registra un motivo de parada/recuperación. No imprime (los prints ya existen).
    Además lo APENDA a incidents.jsonl: `INCIDENTS` es memoria y se borraba entera en CADA
    reinicio del backend -> el panel aparecía vacío justo cuando se iba a mirar (reportado
    2026-08-11). Con el fichero, el histórico sobrevive a los despliegues."""
    ev = {"ts": time.time(), "half": (half or "-"),
          "kind": str(kind)[:32], "detail": str(detail)[:300], "src": src}
    try:
        with INC_LOCK:
            INCIDENTS.append(ev)
    except Exception:
        pass
    if src == "live":     # los sembrados del log ya venían de disco: no re-escribirlos
        try:
            with open(INCIDENTS_FILE, "a") as f:
                f.write(json.dumps(ev, ensure_ascii=False) + "\n")
        except Exception:
            pass

def _tail_lines(path, n):
    """Últimas n líneas sin cargar el fichero entero (el log del escáner llega a 25MB)."""
    try:
        with open(path, "rb") as f:
            f.seek(0, os.SEEK_END)
            end = f.tell(); buf = b""
            while end > 0 and buf.count(b"\n") <= n:
                step = min(65536, end); end -= step
                f.seek(end); buf = f.read(step) + buf
            return buf.decode("utf-8", "replace").split("\n")[-n:]
    except Exception:
        return []

# Patrones del log -> incidencia. El log del backend va fechado (HH:MM:SS) desde el 2026-08-10.
_INC_PATTERNS = [
    ("watchdog: FREEZE detectado",  "sin objetos",          "el agente vive pero no llegan objetos nuevos -> reattach"),
    ("el reattach no arregla",      "congelado",            "ni el reattach lo cura (render colgado) -> reinicio del juego"),
    ("watchdog: sin heartbeat",     "sin heartbeat",        "el agente dejó de dar señales -> reattach"),
    ("create_script try",           "inyección falló",      "el proceso del juego no acepta el script a tiempo"),
    ("inyección imposible",         "inyección imposible",  "reintentos agotados -> reinicio del juego"),
    ("create_script timeout 4x",    "inyección imposible",  "reintentos agotados"),
    ("HARD-RESET:",                 "HARD-RESET",           "varios fallos de attach -> force-stop + relanzar Evony"),
    ("REINICIO LIMPIO",             "REINICIO LIMPIO",      "fuera del mapa demasiado tiempo -> force-stop + relanzar"),
    ("nav-unstick",                 "fuera del mapa",       "el agente no llega al world map"),
    ("Check-in + globo",            "rescate al mapa",      "Check-in (cierra Daily Rewards) + globo -> world map"),
    ("RECONECTADO por WiFi",        "adb reconectado",      "el servidor adb había soltado el móvil"),
    ("NO renderiza",                "pantalla en blanco",   "el juego vive pero no pinta -> el escáner no ve nada"),
]

def _seed_incidents_from_log(max_lines=8000):
    """Rellena el registro con el histórico REAL leído del log al arrancar.
    Sin esto, tras cada despliegue el panel salía vacío aunque hubiera pasado de todo: el
    histórico existía en /tmp/evony_pixel.log pero nadie lo leía. Combina fichero + log,
    ordena por hora y se queda con lo más reciente."""
    import re as _re
    found = []
    # 1) lo persistido (tiene el detalle exacto que escribió el backend)
    for ln in _tail_lines(INCIDENTS_FILE, INC_MAX_LINES):
        ln = ln.strip()
        if not ln: continue
        try:
            d = json.loads(ln)
            if isinstance(d, dict) and d.get("ts"): found.append(d)
        except Exception:
            pass
    # 2) lo que se pueda reconstruir del log fechado
    now = time.time()
    today = time.strftime("%Y-%m-%d", time.localtime(now))
    rx = _re.compile(r"^(\d\d):(\d\d):(\d\d) (.*)$")
    for ln in _tail_lines(SCANNER_LOG, max_lines):
        m = rx.match(ln)
        if not m: continue
        hh, mm, ss, rest = m.groups()
        try:
            t = time.mktime(time.strptime(f"{today} {hh}:{mm}:{ss}", "%Y-%m-%d %H:%M:%S"))
        except Exception:
            continue
        if t > now + 60: t -= 86400        # línea de ayer (el log cruza la medianoche)
        half = "-"
        hm = _re.match(r"^\[([WE])\]", rest)
        if hm: half = hm.group(1)
        for pat, kind, det in _INC_PATTERNS:
            if pat in rest:
                extra = ""
                sm = _re.search(r"hace (\d+)s", rest)
                if sm: extra = f" ({sm.group(1)}s sin objetos)"
                found.append({"ts": t, "half": half, "kind": kind,
                              "detail": det + extra, "src": "log"})
                break
    # 3) fusionar, deduplicar y quedarse con lo último
    seen = set(); out = []
    for d in sorted(found, key=lambda x: x.get("ts", 0)):
        k = (int(d.get("ts", 0)), d.get("half"), d.get("kind"))
        if k in seen: continue
        seen.add(k); out.append(d)
    with INC_LOCK:
        INCIDENTS.clear()
        for d in out[-INCIDENTS.maxlen:]:
            INCIDENTS.append(d)
    # 4) podar el fichero para que no crezca sin límite
    try:
        if os.path.exists(INCIDENTS_FILE):
            lines = _tail_lines(INCIDENTS_FILE, INC_MAX_LINES)
            with open(INCIDENTS_FILE, "w") as f:
                for ln in lines:
                    if ln.strip(): f.write(ln.rstrip("\n") + "\n")
    except Exception:
        pass
    print(f"[incidents] registro sembrado: {len(INCIDENTS)} entradas "
          f"(fichero + log fechado)", flush=True)
STATE  = {"cfg_n": 0,
          "W": {"sweep": "?", "num": 0, "last_secs": None},
          "E": {"sweep": "?", "num": 0, "last_secs": None}}
FIRST_PASS_TS = None       # ts en que AMBOS terminaron su 1a vuelta (mapa entero visto)
_FP_DONE = {"W": False, "E": False}
# COLD_GRACE_DONE: True cuando la mitad reporta su 1a vuelta de sweep (cold-load terminado).
# Mientras sea False, el watchdog usa FIRST_HB_GRACE (180s) en vez de HEARTBEAT_TIMEOUT (90s):
# el cold-load de la mitad densa W es lento y puede silenciar el heartbeat un rato SIN estar
# muerto -> no la mates por eso (el reattach prematuro era lo que disparaba el freeze). Se
# resetea a False en cada (re)attach para que cada cold-load tenga su ventana larga.
COLD_GRACE_DONE = {"W": False, "E": False}
LAST_PASS_TS = {"W": None, "E": None}   # ts de la ultima vuelta por escaner
FRESH_WINDOW  = 120        # 2 min: tras esto el "nuevo" deja de estar resaltado
TTL_SECONDS   = 86400      # 24h: persistencia larga para acumular cobertura entre sesiones
                           # (se guarda en disco también via persist_thread)
OBJS_MAX      = 150000     # 2026-08-14 RAM CAP: cota dura del store OBJS en memoria. Con TTL=24h el
                           # store crecía a ~250k+ (RSS ~2.4GB). El prune evicta los de ts MÁS VIEJO
                           # (menos re-vistos = periferia stale, ya casi para purgar) al superar esto,
                           # acotando la RAM sin tocar los frescos/visibles (on-map = ts reciente = sobreviven).
STALE_SECONDS = 90         # una vuelta del barrido es ~24s. Si un objeto no se re-ve
                           # en ~3-4 vueltas se considera muerto -> se quita de la lista
ONMAP_SECONDS = 2700       # raised 900->2700 (45 min) 2026-07-22: scarce/scattered monsters (Golden Goblin, ~40 spread over the map) were expiring before the slow periphery re-visit. Was: 15 min "still on map" window for lists + SEND COORDS of monsters.
                           # Calibrado midiendo el intervalo de re-visita real (2 snapshots): es MUY desigual — núcleo
                           # ~1-2 min, periferia hasta ~30 min (mediana 1.4, p90 ~18min). No hay ventana perfecta; 15 min
                           # captura los bosses tipo Ymir (re-visita <~15min) recortando el "stale" vs los 20 min previos.
                           # Subir si se pierden monstruos de periferia lenta; bajar (10-12) si molesta el "stale" (perderá algún vivo).
# 2026-08-12: FRESCURA POR GRUPO en la vista (a petición del usuario: el escáner mostraba
# monstruos que ya no están). Como el bot pero MÁS SUAVE (bot=120/600). Se aplica vía _onmap_ok
# en la lista, contadores de familia/nivel, sc_monsters y send_coords. ONMAP_SECONDS queda como
# ventana ANCHA de reserva (para llamadas con max_seen explícito). Subir si se pierden escasos.
ONMAP_FARM    = 300        # monstruos normales (farm): se MUEVEN/mueren -> 5 min de no re-verse y fuera
ONMAP_RALLY   = 2700       # 2026-08-12: 900->2700 (45min). Boss/Event ESTÁN QUIETOS hasta que los matan
                           # -> no se vuelven "stale" como el farm; con 15min se perdían los ESCASOS de
                           # periferia (Golden Goblin, Epic Cerberus) que el escáner re-barre lento (p90 ~18min,
                           # a veces >30min) aunque siguen en el mapa. 45min = comportamiento previo, sin perderlos.
RALLY_GROUPS_ONMAP = ("boss", "event")   # ⚠️ minúscula: FAMILIES/_id2fam guardan el grupo en minúscula
PRUNE_EVERY   = 20         # cada N seg el hilo de limpieza purga muertos
RESPAWN_GAP_S = 250        # si un tile no se ve >= esto y reaparece => spawn NUEVO
                           # (el boss murio, el tile estuvo vacio entre pasadas y
                           # ahora hay otro). Igualado a v1/iScout para descartar que
                           # la diferencia de detection count entre v1 y v2 venga de aqui.
                           # 250s = ~8-10 vueltas de v2: mucho margen para no falsear
                           # respawns por gaps puntuales de 1-2 pasadas.

# ---------------- Shield inference helpers ----------------
def _shield_median_duration(uid):
    """Devuelve la mediana de duraciones (s) del historico del uid, o None si <2 muestras."""
    h = SHIELD_HISTORY.get(uid)
    if not h or len(h) < 2: return None
    durs = sorted(int(x.get("duration_s", 0) or 0) for x in h if x.get("duration_s"))
    if not durs: return None
    n = len(durs)
    return durs[n // 2] if n % 2 == 1 else (durs[n // 2 - 1] + durs[n // 2]) // 2

def _snap_to_sku(dur_s):
    """Encaja una duracion (s) al SKU de Peace Shield estandar mas cercano si cae
    dentro de SHIELD_SKU_TOL. Devuelve el SKU (s) o None si no encaja en ninguno
    (no forzamos: una duracion atipica se conserva tal cual donde se use)."""
    if not dur_s or dur_s <= 0:
        return None
    best, best_err = None, None
    for sku in STANDARD_SHIELD_SKUS:
        err = abs(dur_s - sku) / sku
        if best_err is None or err < best_err:
            best, best_err = sku, err
    if best is not None and best_err is not None and best_err <= SHIELD_SKU_TOL:
        return best
    return None

def _shield_typical_sku(uid):
    """Estima el SKU habitual del jugador desde su historico de duraciones:
    encaja cada duracion observada a un SKU estandar y devuelve la MODA (el SKU
    mas frecuente; desempate por el mas reciente). None si no hay senal fiable.
    Esto hace que la inferencia en renovaciones use 'lo que SUELE comprar' este
    jugador en vez de un default ciego."""
    h = SHIELD_HISTORY.get(uid)
    if not h:
        return None
    counts = {}   # sku -> [n, last_idx]
    for i, x in enumerate(h):
        sku = _snap_to_sku(int(x.get("duration_s", 0) or 0))
        if sku is None:
            continue
        c = counts.setdefault(sku, [0, -1])
        c[0] += 1
        c[1] = i
    if not counts:
        return None
    # moda: mas frecuente; desempate por mas reciente (mayor last_idx)
    return max(counts.items(), key=lambda kv: (kv[1][0], kv[1][1]))[0]

def _on_shield_tier_transition(uid, prev_tier, new_tier, now):
    """Logica de inferencia cuando un player cambia su tier de shield en el broadcast.
    LLAMAR DENTRO DEL LOCK.

    REGLAS:
    - El broadcast tier es GROUND TRUTH. Si llega tier=0, SIEMPRE limpiamos SHIELD_ETA.
    - El COOLDOWN solo protege ARCHIVING (no envenenar historico con flickers <5min).
    - Una transition 0->1 dentro del cooldown del ultimo evento NO crea nueva SHIELD_ETA
      (era flicker: el shield real nunca se fue, el undershot_check lo recreara con +50%).
    - No-op si existe SHIELD_ETA[uid] con confidence='exact' y end_time>now (gana lo exacto).
    """
    cur = SHIELD_ETA.get(uid)
    has_exact_live = (cur and cur.get("confidence") == "exact" and int(cur.get("end_time", 0) or 0) > now)
    last_trans = SHIELD_LAST_TRANSITION.get(uid, 0)
    in_cooldown = bool(last_trans and (now - last_trans) < SHIELD_FLICKER_COOLDOWN_S)
    SHIELD_LAST_TRANSITION[uid] = int(now)

    # ---- ACTIVACION: 0 -> 1 (item) o 0 -> 2 (newbie)
    if prev_tier == 0 and new_tier in (1, 2):
        if has_exact_live:
            return   # respeto la ETA exacta vigente
        if in_cooldown:
            # 0->1 dentro del cooldown de un 1->0 reciente: era FLICKER, no nueva activacion.
            # El undershot_check posterior recreara SHIELD_ETA con +50% si tier sigue >0.
            return
        if new_tier == 2:
            # newbie: asume el maximo (7d desde ahora). Es upper-bound.
            SHIELD_ETA[uid] = {
                "end_time": int(now) + SHIELD_NEWBIE_MAX_S,
                "src": "tier_transition",
                "confidence": "newbie_inferred",
                "activation_ts": int(now),
                "ts": now,
            }
        else:
            # item shield (tier=1). Prioridad de inferencia (de mas a menos fiable):
            #   1) SKU habitual del jugador  -> 'inferred_sku'      (lo que SUELE comprar)
            #   2) mediana del historico, encajada a SKU -> 'inferred_history'
            #   3) default 8h                -> 'inferred_default'
            sku = _shield_typical_sku(uid)
            if sku:
                dur, conf = int(sku), "inferred_sku"
            else:
                med = _shield_median_duration(uid)
                # SANIDAD: mediana absurdamente corta (<10min) suele estar envenenada por
                # flicker pasado -> usa default. Auto-recovery sin wipe manual.
                if med and med < 600:
                    med = None
                if med:
                    snapped = _snap_to_sku(med)
                    dur, conf = int(snapped if snapped else med), "inferred_history"
                else:
                    dur, conf = SHIELD_DEFAULT_DURATION_S, "inferred_default"
            SHIELD_ETA[uid] = {
                "end_time": int(now) + int(dur),
                "src": "tier_transition",
                "confidence": conf,
                "activation_ts": int(now),
                "duration_assumed_s": int(dur),
                "ts": now,
            }
        return

    # ---- EXPIRACION/ROTURA: 1 -> 0 o 2 -> 0
    if prev_tier in (1, 2) and new_tier == 0:
        # CASO ESPECIAL: tenemos un EXACT vivo (scout/mail/subcity leyo el end_time DIRECTO
        # del objetivo). Una sola lectura tier=0 del broadcast pasivo es sospechosa: es muy
        # comun recibir broadcasts parciales/stale que reportan tier=0 falso. NO rompemos el
        # exact aqui; marcamos break PENDIENTE y dejamos que _shield_confirm_break lo confirme
        # solo si el tier=0 PERSISTE >= EXACT_BREAK_CONFIRM_S. Si la burbuja reaparece, el
        # players-loop limpia el pending. (Una rotura real por ataque persistira y se confirmara.)
        if has_exact_live:
            if uid not in SHIELD_BREAK_PENDING:
                SHIELD_BREAK_PENDING[uid] = now
                et = int(cur.get("end_time", 0) or 0)
                print(f"[shield-break?] uid={uid} tier=0 contradice exact vivo (end {et}, {et-int(now)}s left). "
                      f"Lectura aislada ignorada; espero confirmacion {EXACT_BREAK_CONFIRM_S}s.", flush=True)
            return   # conservar exact
        # Sin exact vivo: comportamiento normal (rotura de inferencia, broadcast manda).
        # Archivar duracion real en historico (si tenemos activation_ts) fuera del cooldown.
        if cur and not in_cooldown:
            act_ts = int(cur.get("activation_ts", 0) or 0)
            if act_ts > 0:
                duration_real = int(now) - act_ts
                # Solo guardar si parece razonable. Threshold SHIELD_MIN_DURATION_S=300s (5min).
                if SHIELD_MIN_DURATION_S <= duration_real <= 14 * 86400:
                    h = SHIELD_HISTORY.setdefault(uid, [])
                    h.append({
                        "activation_ts": act_ts,
                        "expired_ts": int(now),
                        "duration_s": duration_real,
                        "sku_s": _snap_to_sku(duration_real),   # SKU estandar encajado (o None)
                        "tier": prev_tier,
                        "src": cur.get("src", "?"),
                        "confidence_at_activation": cur.get("confidence", "?"),
                    })
                    if len(h) > SHIELD_HISTORY_MAX:
                        del h[:-SHIELD_HISTORY_MAX]
        SHIELD_ETA.pop(uid, None)
        SHIELD_BREAK_PENDING.pop(uid, None)
        return

    # ---- SWITCH 1<->2: raro, conservar lo que tengamos
    # no hacemos nada especial


def _shield_confirm_break(uid, now):
    """Llamar en CADA broadcast que reporte tier=0 para un uid. Confirma la rotura de un
    exact SOLO si el tier=0 ha PERSISTIDO >= EXACT_BREAK_CONFIRM_S desde la 1a lectura
    contradictoria (registrada en SHIELD_BREAK_PENDING por _on_shield_tier_transition).
    Hasta entonces el exact se conserva: defiende contra broadcasts parciales/stale que
    reportan tier=0 falso. Una rotura real por ataque persiste y se confirma aqui.
    LLAMAR DENTRO DEL LOCK."""
    first = SHIELD_BREAK_PENDING.get(uid)
    if first is None:
        return
    cur = SHIELD_ETA.get(uid)
    live_exact = (cur and cur.get("confidence") == "exact" and int(cur.get("end_time", 0) or 0) > now)
    if not live_exact:
        SHIELD_BREAK_PENDING.pop(uid, None)
        return
    if (now - first) < EXACT_BREAK_CONFIRM_S:
        return   # aun en ventana de gracia, seguimos conservando el exact
    # tier=0 persistio: rotura real confirmada. Archivar + popear.
    end_t  = int(cur.get("end_time", 0) or 0)
    act_ts = int(cur.get("activation_ts", 0) or 0)
    if act_ts > 0:
        duration_real = int(now) - act_ts
        if SHIELD_MIN_DURATION_S <= duration_real <= 14 * 86400:
            h = SHIELD_HISTORY.setdefault(uid, [])
            h.append({
                "activation_ts": act_ts, "expired_ts": int(now),
                "duration_s": duration_real, "sku_s": _snap_to_sku(duration_real),
                "tier": 1, "src": cur.get("src", "?"), "confidence_at_activation": "exact",
            })
            if len(h) > SHIELD_HISTORY_MAX:
                del h[:-SHIELD_HISTORY_MAX]
    SHIELD_ETA.pop(uid, None)
    SHIELD_BREAK_PENDING.pop(uid, None)
    print(f"[shield-break] uid={uid} CONFIRMADO: tier=0 persistio {int(now-first)}s (exact end era {end_t}). Rotura real.", flush=True)


def _shield_check_undershot(uid, current_tier, now):
    """Llamar en cada broadcast players. Si el cliente tiene tier>0 (shield real activo)
    pero NO tenemos SHIELD_ETA para uid, significa que la inferencia previa expiro
    pero el shield real sigue activo (undershot).
    En ese caso, extender SHIELD_ETA con duracion previa * 1.5 + estado 'inferred_undershot'.
    LLAMAR DENTRO DEL LOCK.
    """
    if current_tier not in (1, 2):
        return
    if uid in SHIELD_ETA:
        return   # ya tenemos algo (exact o inferred vivo)
    # NO crear undershot por defecto en el primer broadcast (puede ser primera vez que lo vemos).
    # Solo si tenemos historico previo: significa que ya hicimos ciclos.
    h = SHIELD_HISTORY.get(uid)
    if not h:
        return
    # Base de la extension: si conocemos el SKU habitual del jugador, usarlo (mas exacto y
    # con menos overshoot que el ciego +50%). Sino, fallback a ultima duracion * 1.5.
    sku = _shield_typical_sku(uid)
    if sku:
        extended_dur = int(sku)
    else:
        last_dur = int(h[-1].get("duration_s", SHIELD_DEFAULT_DURATION_S) or SHIELD_DEFAULT_DURATION_S)
        extended_dur = max(int(last_dur * 1.5), 3600)   # min 1h
    SHIELD_ETA[uid] = {
        "end_time": int(now) + extended_dur,
        "src": "tier_undershot",
        "confidence": "inferred_undershot",
        "activation_ts": int(now) - extended_dur,   # asumimos activacion al inicio de este SKU
        "duration_assumed_s": extended_dur,
        "ts": now,
    }
    print(f"[undershot] uid={uid} tier={current_tier} inferencia previa expiro pero shield sigue. "
          f"Extiendo +{extended_dur}s ({'SKU habitual' if sku else 'ultima dur*1.5'})", flush=True)


def _merge_coord_shields_locked(now=None):
    """Funde entradas SHIELD_ETA['coord:wx,wy'] (psr/scout capturado antes de tener
    uid en PLAYERS) en la entrada por uid cuando el jugador ya está en PLAYERS.
    REGLA CLAVE: una entrada 'exact' viva por coord PISA una inferida por uid
    (inferred_undershot/inferred_default/...). Esto corrige el caso en que un scout
    devuelve la ETA exacta pero queda atrapada bajo 'coord:' mientras el uid conserva
    una inferencia peor. DEBE llamarse con LOCK ya adquirido (no re-adquiere)."""
    if now is None:
        now = time.time()
    coord_keys = [k for k in SHIELD_ETA if isinstance(k, str) and k.startswith("coord:")]
    if not coord_keys:
        return
    # índice (wx,wy)->uid y nombre->uid desde PLAYERS, una sola pasada
    pos2uid = {}; name2uid = {}
    for puid, p2 in PLAYERS.items():
        wx = int(p2.get("wx", 0) or 0); wy = int(p2.get("wy", 0) or 0)
        if wx or wy: pos2uid[(wx, wy)] = puid
        nm = (p2.get("name", "") or "").lower()
        if nm: name2uid.setdefault(nm, puid)
    for ck in coord_keys:
        ci = SHIELD_ETA.get(ck)
        if not ci:
            continue
        wx = int(ci.get("wx", 0) or 0); wy = int(ci.get("wy", 0) or 0)
        nm = (ci.get("name", "") or "").lower()
        uid = pos2uid.get((wx, wy)) or (name2uid.get(nm) if nm else 0) or 0
        if not uid:
            continue   # aún no conocemos al jugador: lo dejamos pendiente
        cur = SHIELD_ETA.get(uid)
        coord_exact_live = (ci.get("confidence") == "exact"
                            and int(ci.get("end_time", 0) or 0) > int(now))
        if cur is None or (cur.get("confidence") != "exact" and coord_exact_live):
            if cur is not None and not ci.get("activation_ts"):
                ci["activation_ts"] = int(cur.get("activation_ts", 0) or 0)
            SHIELD_ETA[uid] = ci
            print(f"[shield-merge] coord {ck} -> uid={uid} ({ci.get('confidence')}, "
                  f"left={int(ci.get('end_time',0))-int(now)}s)", flush=True)
        SHIELD_ETA.pop(ck, None)


# ---------------- Frida backend ----------------
def on_message(msg, data, half):
    global FIRST_PASS_TS   # leído en el handler de marchas (gate summon) y asignado en sweep_pass
    if msg.get("type") == "error":
        print(f"[{half} agent error]", msg.get("stack") or msg.get("description"), flush=True)
        return
    if msg.get("type") != "send":
        return
    p = msg["payload"]
    if not isinstance(p, dict):
        return
    LAST_HEARTBEAT[half] = time.time()   # watchdog: cualquier mensaje cuenta como vida
    if p.get("kind") == "heartbeat":
        return   # solo sirve como ping; no necesita processing
    if p.get("kind") == "summon":
        # Monstruo INVOCADO detectado por el agente en el instante del sweep (owner_id>0).
        now = time.time()
        wx = int(p.get("wx", 0) or 0); wy = int(p.get("wy", 0) or 0)
        own = int(p.get("own", 0) or 0); mid = int(p.get("id", 0) or 0); lv = int(p.get("lv", 0) or 0)
        with LOCK:
            nm = (CFG.get(str(mid), {}) or {}).get("name", "") or (f"id{mid}" if mid else "Monster")
            key = (wx, wy, mid)
            for s in SUMMON_LOG:
                if (s["x"], s["y"], s["id"]) == key:
                    s["ts"] = now; break
            else:
                gtag = (GUILD_MEMBERS.get(own, {}) or {}).get("tag", "") or ""
                pl = PLAYERS.get(str(own)) or PLAYERS.get(own) or {}
                owner_label = (f"[{gtag}]" if gtag else (pl.get("name", "") or f"#{own}"))
                SUMMON_LOG.append({"x": wx, "y": wy, "id": mid, "lv": lv, "name": nm,
                                   "group": group_of(nm), "owner_uid": own,
                                   "owner_label": owner_label, "ts": now, "half": half})
                print(f"[{half} summon] {nm} Lv{lv} @{wx},{wy} owner={own} ({owner_label})", flush=True)
            SUMMON_TILES[(wx, wy)] = now    # marca el tile como "summon" para el tag persistente
            cut = now - SUMMON_TTL
            SUMMON_LOG[:] = [s for s in SUMMON_LOG if s["ts"] >= cut][-200:]
            for tk in [k for k, ts in SUMMON_TILES.items() if ts < cut]:
                del SUMMON_TILES[tk]
        return
    if p.get("kind") == "nav_stuck":
        # Agente fuera del mapa (ciudad / popup modal). Solo foreground; a los
        # NAV_STUCK_RESTART_S (90s) sin salir -> REINICIO LIMPIO de esa mitad.
        # Sin BACKs: ver la nota de POLÍTICA junto a NAV_STUCK_RESTART_S.
        now = time.time()
        serial = next((s for s, h in SCANNERS if h == half), None)
        if NAV_STUCK_SINCE.get(half, 0.0) <= 0:
            NAV_STUCK_SINCE[half] = now
            _incident(half, "fuera del mapa",
                      f"el agente no llega al world map (streak={p.get('streak',0)}); "
                      f"reinicio limpio si sigue así {NAV_STUCK_RESTART_S}s")
        stuck_for = now - NAV_STUCK_SINCE.get(half, now)
        if serial and stuck_for >= NAV_STUCK_RESTART_S:
            NAV_STUCK_SINCE[half] = now                                  # abre nueva ventana
            NAV_STUCK_EPISODES[half] = NAV_STUCK_EPISODES.get(half, 0) + 1
            _incident(half, "REINICIO LIMPIO",
                      f"{int(stuck_for)}s fuera del mapa (episodio {NAV_STUCK_EPISODES[half]}) "
                      f"-> force-stop + relanzar Evony")
            threading.Thread(target=_clean_game_restart, args=(serial, half),
                             daemon=True).start()
        elif serial:
            # Tocar la pantalla (Check-in + globo) solo si el atasco ya lleva
            # NAV_TAP_AFTER_S y no hemos tocado en NAV_TAP_COOLDOWN: así no pisamos
            # transiciones normales del juego ni martilleamos la UI.
            taps = (stuck_for >= NAV_TAP_AFTER_S and
                    now - LAST_NAV_BACK.get(half, 0.0) >= NAV_TAP_COOLDOWN)
            if taps or now - LAST_NAV_BACK.get(half, 0.0) >= NAV_BACK_COOLDOWN:
                if taps:
                    LAST_NAV_BACK[half] = now
                threading.Thread(target=_nav_unstick,
                                 args=(serial, half, p.get("streak", 0), p.get("secs", 0), taps),
                                 daemon=True).start()
        return
    if p.get("kind") == "config":
        with LOCK:
            for k, v in p["cfg"].items():
                CFG[str(k)] = v
            apply_cfg_manual()   # rellena ids que el cliente aún no tiene (p.ej. Pyrat)
            STATE["cfg_n"] = len(CFG)
        print(f"[{half} cfg] {p['n']} configs", flush=True)
    elif p.get("kind") == "wcfg":
        with LOCK:
            for k, v in p["wcfg"].items():
                WCFG[str(k)] = v
        print(f"[{half} wcfg] {p['n']} world resource configs", flush=True)
    elif p.get("kind") == "itemcfg":
        with LOCK:
            for k, v in p["itemcfg"].items():
                ITEMCFG[str(k)] = v
        print(f"[{half} itemcfg] {p['n']} item names", flush=True)
    elif p.get("kind") == "batch":
        # Llegan objetos = esta mitad está VIVA y en el mapa: cierra el episodio de atasco
        # (si no se resetea, el contador de 90s arrastraría y reiniciaría sin motivo).
        if NAV_STUCK_SINCE.get(half, 0.0) > 0 and p.get("items"):
            _incident(half, "recuperado",
                      f"vuelven los objetos ({len(p['items'])} en el lote) tras "
                      f"{int(time.time() - NAV_STUCK_SINCE[half])}s fuera del mapa")
            NAV_STUCK_SINCE[half] = 0.0
            NAV_STUCK_EPISODES[half] = 0
        _maybe_apply_fps(half)   # escaneo confirmado (post-login) -> aplica el throttle de fps UNA vez por half
        now = time.time()
        # server cuyo mapa ve ESTE escáner (perfil activo). En SVS el escáner E está en
        # el server enemigo (1954): sus monstruos NO deben mezclarse con los nuestros.
        src_srv = scan_cfg_for(half)["server"] or our_server()
        with LOCK:
            for it in p["items"]:
                if "wx" not in it or "wy" not in it: continue   # M6: item sin coords -> saltar (no abortar el lote entero con KeyError)
                k = (it["wx"], it["wy"])
                it["half"] = half   # de qué scanner (W o E) llegó este item
                it["srv"] = src_srv   # server donde se vio (para filtrar el server enemigo)
                if int(it.get("t", 0)) == 57:   # RUINS/pirámides: van a un store SEPARADO (coexisten con monstruos en el mismo tile) -> NO a OBJS
                    it["ts"] = now
                    RUINS[k] = it
                    _rid = int(it.get("id", 0) or 0)
                    if _rid not in _PYR_SEEN:
                        _PYR_SEEN.add(_rid)
                        _rc = CFG.get(str(_rid)) or {}
                        print(f"[pyramid-diag] RUIN @{k} ruins_id={_rid} -> name={(_rc.get('name','') or '?')!r} cfg_lv={_rc.get('level')} type={_rc.get('type')}", flush=True)
                    continue
                # cache LARGO de monstruos por tile: si es ttype=2 (monstruo NPC) y
                # tiene id, lo guardamos para que active attacks pueda resolverlo
                # incluso si OBJS lo purga durante combate.
                if int(it.get("t", 0)) == 2 and int(it.get("id", 0)) > 0:
                    cfg = CFG.get(str(it["id"])) or {}
                    MONSTERS_BY_TILE[k] = {
                        "id": it["id"], "lv": it.get("lv", 0),
                        "name": cfg.get("name", "") or f"id{it['id']}",
                        "ts": now,
                    }
                # --- SCAN_STATS: atribución al half que aportó este objeto ---
                st = SCAN_STATS.get(half)
                def _stat_monster(item):
                    if st is None: return
                    st["last_obj_ts"] = now
                    if int(item.get("t", 0)) == 2 and int(item.get("id", 0)) > 0:
                        cfg2 = CFG.get(str(item["id"])) or {}
                        grp = group_of(cfg2.get("name", "") or "")
                        st["monsters_by_group"][grp] = st["monsters_by_group"].get(grp, 0) + 1
                        if "cerberus" in (cfg2.get("name", "") or "").lower():
                            st["cerberus"] += 1
                old = OBJS.get(k)
                if old is None:
                    # tile nunca visto -> descubrimiento nuevo
                    it["ts"] = now
                    it["first_ts"] = now
                    OBJS[k] = it
                    if st is not None:
                        st["discoveries"] += 1
                        _stat_monster(it)
                    # [respawns-disabled] respawn predictor desactivado temporalmente
                    # (info poco fiable). No acumulamos SPAWN_HISTORY para no consumir memoria.
                    # if int(it.get("t", 0)) == 2 and int(it.get("id", 0)) > 0:
                    #     h = SPAWN_HISTORY.setdefault(k, [])
                    #     h.append({"ts": now, "id": it["id"], "lv": it.get("lv", 0)})
                    #     if len(h) > SPAWN_HISTORY_MAX: del h[:-SPAWN_HISTORY_MAX]
                else:
                    # spawn NUEVO en un tile recurrente si: cambio el tipo/nivel,
                    # o el tile estuvo sin verse >= RESPAWN_GAP_S (vacio entre
                    # pasadas = el boss anterior murio y aparecio otro).
                    respawn = (old.get("id") != it["id"]
                               or old.get("lv") != it["lv"]
                               or (now - old.get("ts", now)) >= RESPAWN_GAP_S)
                    if respawn:
                        it["ts"] = now
                        it["first_ts"] = now          # re-marca como recien descubierto
                        OBJS[k] = it
                        if st is not None:
                            st["respawns"] += 1
                            _stat_monster(it)
                        # [respawns-disabled] respawn predictor desactivado temporalmente
                        # (info poco fiable). No acumulamos SPAWN_HISTORY para no consumir memoria.
                        # if int(it.get("t", 0)) == 2 and int(it.get("id", 0)) > 0:
                        #     h = SPAWN_HISTORY.setdefault(k, [])
                        #     h.append({"ts": now, "id": it["id"], "lv": it.get("lv", 0)})
                        #     if len(h) > SPAWN_HISTORY_MAX: del h[:-SPAWN_HISTORY_MAX]
                    else:
                        prev_ts = old.get("ts", now)   # antes de actualizar → gap de refresco
                        old.update(it)
                        old["ts"] = now
                        old["half"] = half  # actualiza scanner que lo re-detectó
                        # FIX 2026-08-07 (CAUSA RAÍZ de los "freezes"): refrescar el reloj anti-freeze TAMBIÉN con
                        # objetos REPETIDOS. Antes last_obj_ts solo se refrescaba en descubrimientos nuevos (L982) y
                        # respawns (L1002) -> con el mapa ya mapeado, el barrido re-ve conocidos >150s (SCAN_FREEZE_TIMEOUT)
                        # -> FALSO FREEZE -> reattach -> create_script en bucle. El agente está VIVO y recibiendo replies
                        # (reply-parse objs sube, latido NetworkManager del juego sin gaps): esto lo refleja. Un freeze REAL
                        # (agente colgado) sigue detectándose: entonces NO llega ningún objeto (ni repetido) y el reloj sí se estanca.
                        if st is not None:
                            st["last_obj_ts"] = now
                        old.setdefault("first_ts", old.get("ts", now))
                        if st is not None:
                            st["redetects"] += 1
                            # frescura del centro: gap desde la última detección (cualquier half)
                            if abs(it["wx"]-CENTRAL_CX) <= CENTRAL_RADIUS and abs(it["wy"]-CENTRAL_CY) <= CENTRAL_RADIUS:
                                st["central_refresh_sum"] += (now - prev_ts)
                                st["central_refresh_n"] += 1
    elif p.get("kind") == "players":
        now = time.time()
        # server cuyo mapa está viendo ESTE escáner (perfil activo). Etiqueta dónde
        # vimos al jugador por última vez: su propio server (enemigo) o el NUESTRO
        # (si cruzó a atacarnos). Clave en SVS.
        src_srv = scan_cfg_for(half)["server"] or our_server()
        with LOCK:
            for it in p["items"]:
                uid = it["uid"]
                it["seen_srv"] = src_srv
                old_p = PLAYERS.get(uid)
                # Player Relocation Tracker: detectar cambio de coords del castillo
                new_wx, new_wy = int(it.get("wx", 0)), int(it.get("wy", 0))
                if (new_wx != 0 or new_wy != 0) and old_p is not None:
                    old_wx, old_wy = int(old_p.get("wx", 0)), int(old_p.get("wy", 0))
                    old_srv = int(old_p.get("seen_srv", 0) or 0)
                    cross_server = (old_srv > 0 and src_srv > 0 and old_srv != src_srv)
                    if (old_wx != 0 or old_wy != 0) and (old_wx != new_wx or old_wy != new_wy) and not cross_server:
                        # ¡Relocalización REAL (mismo server)! Los "saltos" entre 1939 y 1954
                        # (un escáner en cada server viendo al mismo jugador en SVS) NO son
                        # teleports: se ignoran para no inflar el contador.
                        r = RELOCATIONS.setdefault(uid, [])
                        r.append({
                            "ts": now,
                            "from_x": old_wx, "from_y": old_wy,
                            "to_x": new_wx, "to_y": new_wy,
                            "name": it.get("name", old_p.get("name", "")),
                            "tag": it.get("tag", old_p.get("tag", "")),
                            "gid": int(it.get("gid", old_p.get("gid", 0)) or 0),
                            "srv": src_srv,                                  # server donde se ve ahora (destino)
                            "from_srv": int(old_p.get("seen_srv", 0) or 0),  # server donde estaba (origen)
                        })
                        if len(r) > RELOCATIONS_MAX: del r[:-RELOCATIONS_MAX]
                        _record_activity(uid, "relocate", now)   # mover castillo = online
                # Shield tier transition detection (inferencia de activacion/expiracion)
                new_tier = int(it.get("shield", 0) or 0)
                prev_tier = int((old_p or {}).get("shield", 0) or 0)
                if old_p is not None and new_tier != prev_tier:
                    _on_shield_tier_transition(uid, prev_tier, new_tier, now)
                    if prev_tier == 0 and new_tier in (1, 2):
                        _record_activity(uid, "shield", now)   # activar burbuja = online
                if new_tier in (1, 2):
                    # burbuja presente -> cancela cualquier break pendiente (era lectura stale)
                    SHIELD_BREAK_PENDING.pop(uid, None)
                    # Anti-undershot: si tier sigue 1/2 pero no tenemos SHIELD_ETA, extender.
                    _shield_check_undershot(uid, new_tier, now)
                elif new_tier == 0:
                    # confirma rotura de un exact solo si tier=0 persiste (defiende de stale 0)
                    _shield_confirm_break(uid, now)
                it["ts"] = now
                PLAYERS[uid] = it
    elif p.get("kind") == "marches":
        now = time.time()
        # server que está viendo ESTE escáner (perfil activo: p.ej. E->1954 en svs_split).
        # Etiqueta cada marcha con su server de origen para distinguir, en Active Attacks,
        # la actividad en el server enemigo del SVS de la de nuestro server.
        src_srv = scan_cfg_for(half)["server"] or our_server()
        with LOCK:
            for it in p["items"]:
                trp = int(it.get("trp", 0))
                if trp == 0:
                    continue
                it["ts_recv"] = now
                it["srv"] = src_srv
                old = MARCHES.get(trp)
                if old is not None:
                    okey = (int(old.get("tx", 0)), int(old.get("ty", 0)))
                    sub = MARCH_BY_TGT.get(okey)
                    if sub is not None:
                        sub.pop(trp, None)
                        if not sub: MARCH_BY_TGT.pop(okey, None)
                MARCHES[trp] = it
                key = (int(it.get("tx", 0) or 0), int(it.get("ty", 0) or 0))   # M6: sin tx/ty -> (0,0) en vez de KeyError que aborta el lote
                MARCH_BY_TGT.setdefault(key, {})[trp] = True
                # SUMMON PROBABLE (amarillo) -> DESACTIVADO. La heurística "Boss/Event aparecido
                # <=25s + atacado" es indistinguible del farmeo NORMAL de event monsters (Barbary
                # Pirate y cía. respawnean y se farmean constantemente) -> inundaba de falsos
                # positivos (12/12 ataques marcados en pruebas). No hay señal fiable de summon de
                # OTROS jugadores: los mensajes de summon son _reply solo al invocador (sin
                # broadcast) y owner_id es ambiguo (enum NPC 10/12/13 / dueño de subcity, no "quién
                # invocó"). Se deja sin marcar para no dar info incorrecta. summon_likely=False.
                # Activity tracker: dueño lanzó una marcha = estuvo online. 1 trp = 1 evento.
                ow = int(it.get("ow", 0) or 0)
                if ow > 0 and trp not in _ACT_SEEN_MARCH:
                    _ACT_SEEN_MARCH.add(trp)
                    _record_activity(ow, "march", now)
                    # OPCIÓN B: registrar el timestamp de INICIO REAL (game ts) del lanzamiento
                    # — 1 por trp (primera vez que lo vemos) — para el análisis de cadencia.
                    launch = int(it.get("ts", 0) or 0)
                    if launch > 1_000_000_000:   # epoch plausible (no relativo/0)
                        dq = ACTION_TIMES.get(ow)
                        if dq is None:
                            dq = _collections.deque(maxlen=ACTION_TIMES_MAX); ACTION_TIMES[ow] = dq
                        dq.append(launch)
                    # ROBO DE RALLIES: si es un rally (mty 19/20/21) a un MONSTRUO, registrar
                    # dueño+alianza+tile+tiempo para detectar contención (LAN vs enemigo en el
                    # mismo objetivo) y quién llegó primero.
                    mty_v = int(it.get("mty", 0) or 0)
                    # TRIPWIRE SPEED-HACK: velocidad implícita de marchas directas (mty=10)
                    # o fase de viaje (20). dist/dur = tiles/s. Solo es anomalía si supera el
                    # tope físico; los buffs normales caen muy por debajo.
                    if mty_v in (10, 20):
                        sxa = int(it.get("sx", 0) or 0); sya = int(it.get("sy", 0) or 0)
                        txa = int(it.get("tx", 0) or 0); tya = int(it.get("ty", 0) or 0)
                        tsa = int(it.get("ts", 0) or 0); tea = int(it.get("te", 0) or 0)
                        if sxa and txa and tsa > 1_000_000_000 and tea > tsa:
                            dist = math.hypot(sxa - txa, sya - tya); dur = tea - tsa
                            if dist >= 20 and dur >= 3:
                                sp = dist / dur
                                st = SPEED_STATS.get(ow)
                                if st is None:
                                    st = {"max": 0.0, "n": 0, "samples": []}; SPEED_STATS[ow] = st
                                st["n"] += 1
                                if sp > st["max"]: st["max"] = round(sp, 2)
                                if sp >= IMPOSSIBLE_SPEED and len(st["samples"]) < 12:
                                    st["samples"].append({"dist": round(dist), "dur": dur,
                                        "sp": round(sp, 2), "tx": txa, "ty": tya, "ts": tsa})
                    if mty_v in (19, 20, 21):
                        txc = int(it.get("tx", 0) or 0); tyc = int(it.get("ty", 0) or 0)
                        mh = MONSTERS_BY_TILE.get((txc, tyc))
                        if mh:   # objetivo es (o fue) un monstruo en ese tile
                            owp = PLAYERS.get(ow) or {}
                            RALLY_LOG.append({
                                "ts": launch if launch > 1_000_000_000 else int(now),
                                "tx": txc, "ty": tyc, "uid": ow,
                                "name": owp.get("name", "") or "",
                                "tag": (owp.get("tag", "") or ""),
                                "gid": int(owp.get("gid", 0) or 0),
                                "sv": int(owp.get("sv", 0) or src_srv),
                                "tname": mh.get("name", ""), "lv": int(mh.get("lv", 0) or 0),
                            })
                            # (VÍA 3 rally->summon RETIRADA: marcaba como summon todo rally a un
                            #  boss/event salvaje = falsos positivos masivos. El tag summon depende
                            #  SOLO de owner_id (Vía 1), que es exacto.)
                if len(_ACT_SEEN_MARCH) > 60000:
                    _ACT_SEEN_MARCH.clear()
    elif p.get("kind") == "subcities":
        # Sub-cities con peace_shield_end_time + power_calc (GetPower del cliente).
        # NPCs: power_calc > 0 (config-based, exacto). Player-owned: power_calc = 0
        # porque el broadcast pasivo no incluye troops/buildings; sólo un scout report
        # del sub-city (CLV 19+) traería el dato real.
        now = time.time()
        with LOCK:
            for it in p["items"]:
                sid = int(it.get("id", 0))
                if sid == 0:
                    continue
                it["ts"] = now
                pc = int(it.get("power_calc", 0) or 0)
                ouid = int(it.get("owner_uid", 0) or 0)
                # preserva power_calc previo si llega 0 ahora
                old = SUBCITIES.get(sid)
                if old:
                    old_pc = int(old.get("power_calc", 0) or 0)
                    if pc == 0 and old_pc > 0:
                        it["power_calc"] = old_pc
                SUBCITIES[sid] = it
                end_t = int(it.get("shield_end", 0) or 0)
                if end_t > 0 and ouid > 0:
                    SHIELD_ETA[ouid] = {"end_time": end_t, "src": "subcity",
                                        "confidence": "exact",
                                        "activation_ts": int(SHIELD_ETA.get(ouid, {}).get("activation_ts", 0) or 0),
                                        "ts": now}
    elif p.get("kind") == "shields":
        # Ground truth de shield ETA. Fuentes:
        #  - addSubCity (owner_uid + end_time) -> ya viene con uid
        #  - UIMailScoutReport.ShowScoutReport -> ya viene con uid (cuando user abre mail)
        #  - sys_mail.set__peace_shield_report  -> viene con wx/wy/name, NO con uid: hay que
        #    matchear contra PLAYERS por (wx,wy) o por nombre para obtener el uid.
        now = time.time()
        with LOCK:
            for it in p["items"]:
                et  = int(it.get("end_time", 0) or 0)
                if et <= 0:
                    continue
                src = it.get("src", "?")
                uid = int(it.get("uid", 0) or 0)
                wx = int(it.get("wx", 0) or 0); wy = int(it.get("wy", 0) or 0)
                nm = (it.get("name", "") or "").strip()
                matched_uid = 0
                # caso A: el agent ya nos da uid directamente (scout / subcity / self / sys_mail)
                if uid > 0:
                    SHIELD_ETA[uid] = {"end_time": et, "src": src,
                                       "confidence": "exact",
                                       "activation_ts": int(SHIELD_ETA.get(uid, {}).get("activation_ts", 0) or 0),
                                       "ts": now}
                    matched_uid = uid
                else:
                    # caso B: viene por sys_mail (wx, wy, name). Match a un uid del cache PLAYERS.
                    # 1) match exacto por coordenadas (lo mas fiable)
                    if wx and wy:
                        for puid, p2 in PLAYERS.items():
                            if int(p2.get("wx", 0) or 0) == wx and int(p2.get("wy", 0) or 0) == wy:
                                matched_uid = puid; break
                    # 2) fallback: match por nombre (case-insensitive)
                    if matched_uid == 0 and nm:
                        lo = nm.lower()
                        for puid, p2 in PLAYERS.items():
                            if (p2.get("name", "") or "").lower() == lo:
                                matched_uid = puid; break
                    if matched_uid > 0:
                        SHIELD_ETA[matched_uid] = {"end_time": et, "src": src,
                                                   "confidence": "exact",
                                                   "activation_ts": int(SHIELD_ETA.get(matched_uid, {}).get("activation_ts", 0) or 0),
                                                   "ts": now}
                        print(f"[psr->uid] matched {nm or '?'} @({wx},{wy}) -> uid={matched_uid} end_time={et} left={et-int(now)}s", flush=True)
                    else:
                        # no encontrado en PLAYERS: guarda por coords para futuro match cuando
                        # el sweep capture al jugador. query_players hara la migracion.
                        SHIELD_ETA[f"coord:{wx},{wy}"] = {"end_time": et, "src": src,
                                                         "confidence": "exact",
                                                         "ts": now,
                                                         "wx": wx, "wy": wy, "name": nm}
                        print(f"[psr] not in PLAYERS yet: {nm or '?'} @({wx},{wy}) end_time={et} (cached by coord)", flush=True)
                # registra en SHIELD_RECENT para el toast de la UI (independientemente del match)
                SHIELD_RECENT.insert(0, {
                    "ts": now, "name": nm, "wx": wx, "wy": wy,
                    "end_time": et, "uid": matched_uid, "src": src,
                })
                del SHIELD_RECENT[SHIELD_RECENT_MAX:]
    elif p.get("kind") == "self":
        SELF[half] = {"uid": int(p.get("uid", 0) or 0),
                      "guild_id": int(p.get("guild_id", 0) or 0)}
        print(f"[{half} self] uid={SELF[half]['uid']} guild_id={SELF[half]['guild_id']}", flush=True)
    elif p.get("kind") == "server_id":
        # V3: server_id auto-detectado por el agente desde broadcasts (mapinfo/subcity)
        sid = int(p.get("server_id", 0) or 0)
        if sid > 0:
            DETECTED_SERVER_ID[half] = sid
            print(f"[{half} server_id] {sid} (auto-detectado)", flush=True)
    elif p.get("kind") == "proto_stats":
        # V3 RESEARCH: protocol tracer manda cada N seg un dump de counters UP/DOWN
        # por tipo de message. Acumulamos en PROTO_STATS[half] para exponer via /api/protocol_stats.
        with LOCK:
            ps = PROTO_STATS.setdefault(half, {"up": {}, "down": {}, "last_update": 0, "total_up": 0, "total_down": 0})
            for k, v in (p.get("up") or {}).items():
                ps["up"][k] = ps["up"].get(k, 0) + int(v)
                ps["total_up"] += int(v)
            for k, v in (p.get("down") or {}).items():
                ps["down"][k] = ps["down"].get(k, 0) + int(v)
                ps["total_down"] += int(v)
            ps["last_update"] = time.time()
    elif p.get("kind") == "powers":
        now = time.time()
        with LOCK:
            for it in p["items"]:
                uid = int(it.get("uid", 0) or 0)
                pw  = int(it.get("power", 0) or 0)
                if uid > 0 and pw > 0:
                    PLAYER_POWER[uid] = {"power": pw, "rank": int(it.get("rank", 0) or 0), "ts": now}
    elif p.get("kind") == "fames":
        # V3: fame ranking. Item: {uid, fame, rank, name}
        now = time.time()
        with LOCK:
            for it in p["items"]:
                uid = int(it.get("uid", 0) or 0)
                if uid > 0:
                    PLAYER_FAME[uid] = {"fame": int(it.get("fame", 0) or 0), "rank": int(it.get("rank", 0) or 0), "name": it.get("name", ""), "ts": now}
        print(f"[fames] {p.get('n')} captured", flush=True)
    elif p.get("kind") == "kills":
        # V3: kill ranking. Item: {uid, kills (Int64-as-Number), rank, name}
        now = time.time()
        with LOCK:
            for it in p["items"]:
                uid = int(it.get("uid", 0) or 0)
                if uid > 0:
                    PLAYER_KILLS[uid] = {"kills": int(it.get("kills", 0) or 0), "rank": int(it.get("rank", 0) or 0), "name": it.get("name", ""), "ts": now}
        print(f"[kills] {p.get('n')} captured", flush=True)
    elif p.get("kind") == "keeps":
        # V3: keep ranking. Item: {uid, keep_rank (nivel keep), rank, name}
        now = time.time()
        with LOCK:
            for it in p["items"]:
                uid = int(it.get("uid", 0) or 0)
                if uid > 0:
                    PLAYER_KEEP[uid] = {"keep_rank": int(it.get("keep_rank", 0) or 0), "rank": int(it.get("rank", 0) or 0), "name": it.get("name", ""), "ts": now}
        print(f"[keeps] {p.get('n')} captured", flush=True)
    elif p.get("kind") == "popularities":
        # V3: popularity ranking. Item: {uid, popularity, title, rank, name}
        now = time.time()
        with LOCK:
            for it in p["items"]:
                uid = int(it.get("uid", 0) or 0)
                if uid > 0:
                    PLAYER_POP[uid] = {"popularity": int(it.get("popularity", 0) or 0), "title": it.get("title", ""), "rank": int(it.get("rank", 0) or 0), "name": it.get("name", ""), "ts": now}
        print(f"[popularities] {p.get('n')} captured", flush=True)
    elif p.get("kind") == "members":
        # V3: guild member list (GetMemberList reply). Item: {uid, name, gid, tag, clv, wx, wy, power, shield, auth, honour}
        now = time.time()
        by_gid = {}
        with LOCK:
            for it in p["items"]:
                uid = int(it.get("uid", 0) or 0)
                gid = int(it.get("gid", 0) or 0)
                if uid <= 0 or gid <= 0:
                    continue
                lastseen = int(it.get("lastseen", 0) or 0)
                row = {
                    "uid": uid, "name": it.get("name", ""), "clv": int(it.get("clv", 0) or 0),
                    "wx": int(it.get("wx", 0) or 0), "wy": int(it.get("wy", 0) or 0),
                    "power": int(it.get("power", 0) or 0), "shield": int(it.get("shield", 0) or 0),
                    "auth": int(it.get("auth", 0) or 0), "honour": int(it.get("honour", 0) or 0),
                    "lastseen": lastseen, "ts": now,
                }
                # MEMBER_LASTSEEN: cada poll refleja el estado actual del miembro, asi que
                # el ultimo valor observado gana (sobrescribe). Fuente autoritativa de actividad.
                MEMBER_LASTSEEN[uid] = lastseen
                MEMBER_INFO[uid] = {"power": row["power"], "shield": row["shield"], "ts": now}
                by_gid.setdefault(gid, {"tag": it.get("tag", ""), "members": []})
                by_gid[gid]["members"].append(row)
                if it.get("tag") and not by_gid[gid]["tag"]:
                    by_gid[gid]["tag"] = it.get("tag")
            for gid, info in by_gid.items():
                GUILD_MEMBERS[gid] = {"tag": info["tag"], "members": info["members"], "updated_ts": now}
        print(f"[members] {p.get('n')} captured across {len(by_gid)} guilds", flush=True)
    elif p.get("kind") == "battle_reports":
        # V3 PvP: attack/defend reports con battle_info + generales. Dedup por key.
        added = 0
        now = time.time()
        with LOCK:
            for it in p["items"]:
                k = it.get("key", "")
                if not k or k in _BATTLE_SEEN:
                    continue
                _BATTLE_SEEN.add(k)
                BATTLE_LOG.append(it)
                added += 1
                # Activity tracker: el atacante de un report estuvo online al atacar.
                _atk = it.get("attacker") or {}
                _auid = int(_atk.get("uid", 0) or 0)
                _bts = float(it.get("ts", 0) or 0) or now
                if _auid > 0:
                    _record_activity(_auid, "pvp", _bts)
            if len(_BATTLE_SEEN) > 8000:  # bound memory del set de dedup
                _BATTLE_SEEN.clear()
        if added: print(f"[pvp] +{added} battle reports (total {len(BATTLE_LOG)})", flush=True)
    elif p.get("kind") == "scout_intel":
        # V3 PvP: scout reports (formación defensiva del enemigo). Último gana por uid.
        with LOCK:
            for it in p["items"]:
                uid = int(it.get("uid", 0) or 0)
                if uid > 0:
                    SCOUT_INTEL[uid] = it
        print(f"[pvp] {p.get('n')} scout intel updated (total {len(SCOUT_INTEL)})", flush=True)
    elif p.get("kind") == "monster_kills":
        # V3 PvE: monster_report con daño + general + loot.
        with LOCK:
            for it in p["items"]:
                MONSTER_KILLS.append(it)
        print(f"[pvp] {p.get('n')} monster kills (total {len(MONSTER_KILLS)})", flush=True)
    elif p.get("kind") == "sweep_done":
        STATE[half]["sweep"] = "done"
    elif p.get("kind") == "sweep_pass":
        now = time.time()   # FIRST_PASS_TS ya declarado global al inicio de on_message
        _FP_DONE[half] = True
        COLD_GRACE_DONE[half] = True   # cold-load terminado: watchdog pasa a timeout normal
        if FIRST_PASS_TS is None and all(_FP_DONE.values()):
            FIRST_PASS_TS = now           # AMBOS dieron su 1a vuelta: mapa entero visto
            print("[sweep] 1a vuelta completa de AMBOS; nuevos a partir de ahora se resaltan", flush=True)
        if LAST_PASS_TS[half] is not None:
            STATE[half]["last_secs"] = round(now - LAST_PASS_TS[half], 1)
        LAST_PASS_TS[half] = now
        STATE[half]["num"] = p["pass"]
        STATE[half]["sweep"] = f"pasada {p['pass']}"
        if half in SCAN_STATS: SCAN_STATS[half]["passes"] = int(p.get("pass", 0) or 0)

class _NeedsEscalation(Exception):
    """Señal interna: forzar caída al bloque de threshold checks (HARD/NUCLEAR).
    NO incrementa REATTACH_FAILS automáticamente — el que la lanza debe hacerlo."""
    pass

# Reset coordinado UI <-> frida_thread:
# - RESET_LOCK por half: evita 2 resets simultáneos en la misma mitad.
# - EXTERNAL_RESET[half]: cuando la UI dispara un reset, lo pone en True ANTES
#   de empezar y a False al terminar. El frida_thread, al ver True, se abstiene
#   de hacer su propia escalación y solo reintenta el attach (la UI ya está
#   haciendo el trabajo pesado de force-stop/relanzar).
RESET_LOCK = {"W": threading.Lock(), "E": threading.Lock()}
EXTERNAL_RESET = {"W": False, "E": False}

FRIDA_BIN_NAME = "frida-server-17.9.10-android-arm64"
# estados de STATE[half]["sweep"] que indican que frida_thread ya está recuperando esa
# mitad (para que emu-watchdog NO interfiera y se peleen reiniciando emuladores a la vez).
RECOVERING_STATES = ("nuclear-reset", "hard-reset", "reattach", "reconectando", "emu-watchdog-reset")

def _adb(serial, *args, timeout=10):
    """adb -s <serial> <args> con captura. Nunca lanza por returncode."""
    import subprocess
    try:
        return subprocess.run(["adb", "-s", serial, *args],
                              capture_output=True, text=True, timeout=timeout)
    except Exception as e:
        class _R: stdout = ""; stderr = str(e); returncode = -1
        return _R()

def _revive_adb_device(serial, max_wait=60):
    """Revive el daemon adb y espera a que `serial` aparezca ONLINE como 'device'.
    CAUSA RAÍZ del doom-loop nuclear: al matar qemu con kill -9 (o bajo presión), el
    daemon adb se cae; entonces frida.get_device(serial) lanza 'device not found' y el
    bucle lo cuenta como fallo -> escala a NUCLEAR -> mata qemu otra vez -> mata adb otra
    vez -> ... infinito, y el emulador NUNCA vuelve al mapa. La cura no es nuclear: es
    revivir adb y ESPERAR a que el device reaparezca, sin escalar. Devuelve True si el
    device está online; NO lanza."""
    import subprocess
    def _start_server():
        try: subprocess.run(["adb", "start-server"], capture_output=True, timeout=15)
        except Exception: pass
    _start_server()
    deadline = time.time() + max_wait
    while time.time() < deadline:
        try:
            r = subprocess.run(["adb", "devices"], capture_output=True, text=True, timeout=10)
            for line in (r.stdout or "").splitlines():
                parts = line.split()
                if len(parts) >= 2 and parts[0] == serial and parts[1] == "device":
                    # adb ya ve el device, PERO el backend droidy de frida mantiene su
                    # propia conexión al daemon adb; tras kill-server queda stale y
                    # frida.get_device() sigue dando 'device not found' aunque adb lo vea.
                    # Forzar re-enumeración hace que frida reconecte su backend adb.
                    try:
                        import frida
                        frida.get_device_manager().enumerate_devices()
                    except Exception:
                        pass
                    return True
        except Exception:
            _start_server()
        time.sleep(3)
    return False

def _frida_listening(serial):
    """True si frida-server escucha en :27042 (lo único que importa para attach)."""
    r = _adb(serial, "shell", "su", "-c",
             "netstat -anl 2>/dev/null | grep ':27042 ' | grep LISTEN", timeout=8)
    return "LISTEN" in (r.stdout or "")

def _ensure_frida_server(serial, half, max_wait_online=40):
    """CHOKEPOINT de fiabilidad: garantiza frida-server LISTEN en :27042 antes de attach.
    Réplica fiel de la secuencia probada de restart_v3.sh (la que SÍ levanta frida-server
    siempre): device online -> Magisk root (+sqlite policy fallback) -> binario presente ->
    setsid + verify LISTEN con reintentos. Devuelve True/False; NO lanza. Es idempotente y
    barato si ya está LISTEN. Llamarlo antes de cada attach hace que CUALQUIER reinicio
    (watchdog, nuclear, crash de frida-server) acabe con el agente re-atachado y autoWM activo."""
    import os
    try:
        # 0) device debe estar online (puede estar rebootando tras un reset)
        if (_adb(serial, "get-state", timeout=5).stdout or "").strip() != "device":
            # ⭐ 2026-08-10: si el device es WiFi (IP:puerto), REconectarlo antes de rendirse.
            # Visto en vivo: el servidor adb del Mac soltó las entradas de LOS DOS Pixel a la
            # vez; los móviles estaban perfectos (ping OK, :5555 abierto) pero no salían en
            # `adb devices` -> "OFFLINE tras 40s" en ambas mitades y el watchdog dando vueltas
            # en HARD-RESETs cuyos comandos adb fallaban en silencio. El escáner quedó tirado
            # 3 min hasta que se hizo `adb connect` A MANO. Con USB esto no pasaba: es el
            # precio de operar por WiFi, así que aquí se auto-cura.
            import subprocess as _sp   # NO hay import global de subprocess en este módulo
            es_wifi = (":" in serial and serial.split(":")[0].replace(".", "").isdigit())
            waited = 0
            while waited < max_wait_online:
                if es_wifi:
                    # 2026-08-14 FIX watchdog "no reinicia": cada ~14s sin éxito -> adb kill-server
                    # para limpiar el estado ATASCADO del servidor adb del Mac. Era la causa de
                    # "OFFLINE (ni con adb connect)" que tumbó la V5: el daemon adb queda wedged
                    # (a menudo por rutas Tailscale) y `adb connect` falla en silencio; kill-server +
                    # start-server + connect SÍ recupera (verificado a mano). Sin esto, el HARD-RESET
                    # del watchdog no podía re-attachar -> el juego congelado nunca se reiniciaba.
                    if waited and waited % 14 == 0:
                        try:
                            _sp.run(["adb", "kill-server"], capture_output=True, timeout=8)
                            time.sleep(1)
                            _sp.run(["adb", "start-server"], capture_output=True, timeout=8)
                            print(f"[{half}] ensure-frida: adb kill-server (servidor adb atascado, reintentando connect)", flush=True)
                        except Exception:
                            pass
                    try:
                        _sp.run(["adb", "connect", serial], capture_output=True, timeout=8)
                    except Exception:
                        pass
                time.sleep(2); waited += 2
                if (_adb(serial, "get-state", timeout=5).stdout or "").strip() == "device":
                    if es_wifi:
                        print(f"[{half}] ensure-frida: {serial} RECONECTADO por WiFi "
                              f"(el servidor adb lo había soltado)", flush=True)
                        _incident(half, "adb reconectado",
                                  f"{serial} se había caído del servidor adb; recuperado con adb connect")
                    break
            else:
                print(f"[{half}] ensure-frida: {serial} OFFLINE tras {max_wait_online}s"
                      f"{' (ni con adb connect)' if es_wifi else ''}", flush=True)
                return False
        # 1) ya escuchando -> nada que hacer
        if _frida_listening(serial):
            return True
        # 2) Magisk root (con fallback sqlite para grant persistente del boot)
        if "root" not in (_adb(serial, "shell", "su", "-c", "whoami", timeout=8).stdout or ""):
            _adb(serial, "shell", "monkey", "-p", "com.topjohnwu.magisk",
                 "-c", "android.intent.category.LAUNCHER", "1", timeout=10)
            time.sleep(8)
            if "root" in (_adb(serial, "shell", "su", "-c", "whoami", timeout=8).stdout or ""):
                _adb(serial, "shell", "su", "-c",
                     'magisk --sqlite "INSERT OR REPLACE INTO policies '
                     '(uid, policy, until, logging, notification) VALUES (2000, 2, 0, 0, 0)"',
                     timeout=8)
            else:
                print(f"[{half}] ensure-frida: sin root Magisk en {serial}", flush=True)
                return False
        # 3) binario presente (re-push si se perdió)
        ls = _adb(serial, "shell", "ls", "/data/local/tmp/frida-server", timeout=5)
        if "No such" in ((ls.stdout or "") + (ls.stderr or "")):
            print(f"[{half}] ensure-frida: re-push frida-server", flush=True)
            _adb(serial, "push", os.path.join(HERE, FRIDA_BIN_NAME),
                 "/data/local/tmp/frida-server", timeout=60)
            _adb(serial, "shell", "su", "-c", "chmod 755 /data/local/tmp/frida-server", timeout=5)
        # 4) (re)arrancar via setsid (NO nohup: Android mata hijos del shell) + verify LISTEN
        for attempt in range(2):
            _adb(serial, "shell", "su", "-c",
                 "pkill -9 -f /data/local/tmp/frida-server 2>/dev/null", timeout=5)
            time.sleep(2 if attempt == 0 else 3)  # dejar salir :27042 de TIME_WAIT
            _adb(serial, "shell", "su", "-c",
                 "setsid /data/local/tmp/frida-server </dev/null >/dev/null 2>&1 &", timeout=8)
            for i in range(8):
                time.sleep(2)
                if _frida_listening(serial):
                    print(f"[{half}] ensure-frida: LISTEN OK ({serial}, intento {attempt+1}, {i*2+2}s)", flush=True)
                    return True
        print(f"[{half}] ensure-frida: frida-server NO LISTEN tras 2 intentos ({serial})", flush=True)
        return False
    except Exception as e:
        print(f"[{half}] ensure-frida: error {e}", flush=True)
        return False

EVONY_PKG = "com.topgamesinc.evony"
EVONY_ACT = "com.topgamesinc.evony/com.topgamesinc.androidplugin.UnityActivity"

def _ensure_evony_foreground(serial, half):
    """CHOKEPOINT: garantiza que Evony esté en FOREGROUND antes del attach.
    CAUSA RAÍZ descubierta del 'el agente atacha pero no hay heartbeat ni self-uid, y al
    rato watchdog→reattach en bucle': tras un reset/relaunch, Evony queda en BACKGROUND
    (se ve el launcher de Android) o tapada por un popup que pausa el motor. Unity NO
    ejecuta lógica ni Update mientras está en background -> WorldMapManager.Update no corre
    -> autoWM no puede navegar y no hay heartbeat. Esto NO lo puede arreglar el agente
    (corre dentro de un proceso pausado); debe forzarse host-side con `am start`.
    Idempotente, barato (~no-op si ya está al frente). NO lanza."""
    try:
        r = _adb(serial, "shell", "dumpsys", "activity", "activities", timeout=8)
        top = ""
        for line in (r.stdout or "").splitlines():
            if "topResumedActivity" in line or "ResumedActivity" in line:
                top = line.strip()
                break
        if EVONY_PKG in top:
            return True   # ya al frente, nada que hacer
        print(f"[{half}] Evony NO en foreground (top='{top[:70]}') -> am start", flush=True)
        _adb(serial, "shell", "am", "start", "-n", EVONY_ACT, timeout=10)
        time.sleep(4)
        return True
    except Exception as e:
        print(f"[{half}] ensure-foreground: error {e} (no bloqueo attach)", flush=True)
        return True

def _nav_unstick(serial, half, streak=0, secs=0, do_taps=False):
    """El agente reporta que NO llega al world map (pop-up de sugerencias / login diario /
    evento, o Evony arrancó en otra pantalla). Como haría un humano: forzar foreground y
    pulsar ATRÁS (Android BACK) un par de veces para pelar el popup/subpantalla y volver a
    la pantalla principal, desde donde el FocusOnWorldMap del agente SÍ carga el mapa.
    Host-side (adb) porque el agente, dentro del proceso, no puede cerrar UI de forma fiable
    sin conocer las clases del cliente. Auto-reparable: si sigue atascado, el agente vuelve a
    avisar y repetimos (con cooldown). NO lanza. Corre en su propio thread (no bloquea el
    pump de mensajes de frida)."""
    try:
        _ensure_evony_foreground(serial, half)
        if not do_taps:
            print(f"[{half}] nav-unstick: fuera del mapa (streak={streak}, t+{secs}s) -> solo foreground", flush=True)
            return
        # RESCATE AL MAPA (2026-08-10). Nada de BACK: repetidos aparcaban el juego en el
        # diálogo "Are you sure you want to exit the game?" y bloqueaban el mapa para siempre.
        # Esta secuencia está MEDIDA con capturas (ver TAP_CHECKIN/TAP_GLOBE):
        #   1) Check-in -> cierra el modal Daily Rewards que tapa el mapa (y cobra la recompensa)
        #   2) globo    -> conmuta al world map
        # Si aun así no vuelve, el escalón final es el REINICIO LIMPIO a los
        # NAV_STUCK_RESTART_S, que lo dispara el handler de nav_stuck.
        print(f"[{half}] nav-unstick: fuera del mapa (streak={streak}, t+{secs}s) "
              f"-> Check-in + globo (sin BACK)", flush=True)
        _adb(serial, "shell", "input", "tap", str(TAP_CHECKIN[0]), str(TAP_CHECKIN[1]), timeout=8)
        time.sleep(2)
        _adb(serial, "shell", "input", "tap", str(TAP_GLOBE[0]), str(TAP_GLOBE[1]), timeout=8)
        time.sleep(2)
        _ensure_evony_foreground(serial, half)
        _incident(half, "rescate al mapa",
                  "Check-in (cierra el modal Daily Rewards) + globo -> world map")
    except Exception as e:
        print(f"[{half}] nav-unstick err: {e}", flush=True)

def _screen_renders(serial, tries=1, need=1, gap=10):
    """True si la pantalla RENDERIZA. Criterio calibrado: captura ~15KB = blanca/negra,
    >1MB = está pintando. Con tries/need>1 exige lecturas buenas SEGUIDAS."""
    import subprocess
    ok = 0
    for i in range(max(1, tries)):
        try:
            r = subprocess.run(["adb", "-s", serial, "exec-out", "screencap", "-p"],
                               capture_output=True, timeout=25)
            ok = ok + 1 if len(r.stdout) > 1_000_000 else 0
        except Exception:
            ok = 0
        if ok >= need:
            return True
        if i < tries - 1:
            time.sleep(gap)
    return False

def _clean_game_restart(serial, half):
    """REINICIO LIMPIO de Evony en UNA mitad: suelta la sesión frida, force-stop, despierta la
    pantalla, relanza y ESPERA a que renderice. Sin BACKs -> no deja diálogos abiertos.
    Al terminar fuerza el reattach del watchdog (LAST_HEARTBEAT=0). ~1,5 min. No lanza."""
    try:
        print(f"[{half}] REINICIO LIMPIO: force-stop + relanzar Evony (sin BACK)", flush=True)
        STATE[half]["sweep"] = "reinicio-limpio"
        # Soltar la sesión ANTES de matar el proceso: si no, frida-core puede abortar y
        # llevarse el backend entero (misma lección que en la detección de desconexión).
        sess = FRIDA_SESSIONS.get(half)
        if sess is not None:
            try: sess.detach()
            except Exception: pass
            FRIDA_SESSIONS[half] = None
            time.sleep(1)
        _adb(serial, "shell", "am", "force-stop", "com.topgamesinc.evony", timeout=12)
        time.sleep(3)
        _adb(serial, "shell", "input", "keyevent", "KEYCODE_WAKEUP", timeout=6)
        _adb(serial, "shell", "am", "start", "-n",
             "com.topgamesinc.evony/com.topgamesinc.androidplugin.UnityActivity", timeout=12)
        time.sleep(35)                                   # carga en frío (login + escena)
        ok = _screen_renders(serial, tries=7, need=2)    # hasta ~60s más
        if not ok:
            _incident(half, "pantalla en blanco",
                      "tras el reinicio la pantalla NO renderiza (el juego vive pero no pinta)")
        # 2º episodio consecutivo o más: UN solo BACK para pelar un modal de login que
        # bloquee el mapa. UNO, y solo aquí — nunca en bucle (eso creaba el diálogo de salida).
        if NAV_STUCK_EPISODES.get(half, 0) >= 2:
            _adb(serial, "shell", "input", "keyevent", "4", timeout=6)
            time.sleep(1.5)
            _ensure_evony_foreground(serial, half)
            _incident(half, "peel 1xBACK",
                      f"modal persistente ({NAV_STUCK_EPISODES.get(half,0)} episodios): un único BACK tras el reinicio")
        _incident(half, "reinicio hecho",
                  f"Evony relanzada; render={'OK' if ok else 'EN BLANCO'}; esperando reattach")
        LAST_HEARTBEAT[half] = 0.0     # reattach inmediato del watchdog
        print(f"[{half}] REINICIO LIMPIO completado (render={'OK' if ok else 'BLANCO'})", flush=True)
    except Exception as e:
        print(f"[{half}] REINICIO LIMPIO err: {e}", flush=True)
        _incident(half, "error reinicio", str(e))

def _peel_popups_after_launch(serial, half):
    """Tras (RE)LANZAR Evony, el cliente abre popups MODALES (Daily Rewards / regalos de login /
    eventos) que impiden llegar al world map. Si no se pelan ANTES de attachar, el agente ni
    siquiera llega a reportar `nav_stuck`: la inyección agota el tiempo -> HARD-RESET -> el
    reinicio vuelve a abrir el popup -> BUCLE. Visto en vivo 2026-08-10 en la mitad E, que se
    pasó ~30 min en ese ciclo mientras W (sin popup) escaneaba perfecto; se diagnosticó con un
    simple `screencap`, no con logs.
    Hace lo mismo que _nav_unstick (foreground + BACK espaciados) pero SIN esperar a que el
    agente avise, porque aquí sabemos con certeza que el juego ACABA de arrancar y por tanto NO
    está en el mapa.
    ⚠️ Un BACK de MÁS abre el diálogo "Are you sure you want to exit the game?". Un BACK sobre
    ESE diálogo lo CANCELA (no confirma la salida), así que la secuencia es segura; lo único que
    JAMÁS hay que hacer es pulsar el botón Quit. Si quedara residuo, el `nav_stuck` del agente
    lo pela después con su cooldown."""
    try:
        # 2026-08-10: los BACK x2 se ELIMINARON. Aparcaban el juego en el diálogo
        # "Are you sure you want to exit the game?" (visto en E) y ahí sí que no hay mapa
        # posible. Ahora: foreground + ESPERAR a que renderice, que además evita que el
        # backend attache sobre un juego a medio cargar (eso deja la pantalla en blanco).
        print(f"[{half}] post-launch: foreground + esperando render (sin BACK)", flush=True)
        _ensure_evony_foreground(serial, half)
        if not _screen_renders(serial, tries=7, need=2):
            _incident(half, "pantalla en blanco",
                      "tras (re)lanzar el juego la pantalla no renderiza; el escáner no verá nada")
            print(f"[{half}] post-launch: ⚠ la pantalla NO renderiza", flush=True)
        _ensure_evony_foreground(serial, half)
    except Exception as e:
        print(f"[{half}] post-launch err: {e}", flush=True)


def _hard_reset_impl(serial, half):
    """Force-stop Evony + relanzar frida-server. ~40s. Idempotente."""
    import subprocess
    print(f"[{half}] HARD-RESET (UI): force-stop Evony + frida-server", flush=True)
    _incident(half, "reinicio del juego", "force-stop Evony + relanzar + reattach (~1,5 min); frida-server NO se toca")
    STATE[half]["sweep"] = "hard-reset"
    try:
        # DETACH ANTES de matar frida-server: si se mata con la sesión aún viva, frida-core
        # ABORTA y se lleva por delante el backend entero (misma lección que en la detección
        # de desconexión). Con la sesión soltada, el kill es inocuo y el watchdog reattacha.
        sess = FRIDA_SESSIONS.get(half)
        if sess is not None:
            try: sess.detach()
            except Exception: pass
            FRIDA_SESSIONS[half] = None
            time.sleep(1)
        # ⛔ 2026-08-10: NO se mata frida-server. Historia corta: el `kill` de aquí fallaba en
        # silencio (faltaba `su -c`; frida-server corre como root). Al arreglarlo empezó a matarlo
        # DE VERDAD y los `create_script` fallidos se TRIPLICARON (13/h -> 38/h), porque cortar
        # frida-server de golpe deja el agente HUÉRFANO dentro del juego, que es justo lo que
        # impide inyectar. Y no aporta nada: se comprobó en vivo que con frida-server nuevo
        # (PID 15495->16741) la mitad seguía sin poder inyectar. Lo que cura es reiniciar el
        # JUEGO, que es lo que hace la línea siguiente. `_ensure_frida_server` ya lo levanta si
        # de verdad se ha caído.
        subprocess.run(["adb", "-s", serial, "shell", "am", "force-stop", "com.topgamesinc.evony"], timeout=10)
        time.sleep(3)
        # frida-server: en Pixel con Magisk `adb root` NO funciona (production build) y sin root el
        # binario no arranca. Hay que lanzarlo con `su -c setsid`, igual que supervise_pixel.sh.
        # (FIX 2026-08-10: el `adb root` + nohup de antes fallaba en SILENCIO -> la mitad se quedaba
        #  sin frida-server tras el reset y solo revivía por el camino del supervisor.)
        subprocess.run(["adb", "-s", serial, "shell",
                        "su -c 'setsid /data/local/tmp/frida-server </dev/null >/dev/null 2>&1 &'"],
                       timeout=10)
        time.sleep(3)
        # pantalla ON antes de relanzar: con la pantalla apagada Unity PAUSA la app y el juego
        # pierde la conexión con el servidor (mismo motivo que en el nuclear reset).
        subprocess.run(["adb", "-s", serial, "shell", "input", "keyevent", "KEYCODE_WAKEUP"], timeout=6)
        subprocess.run(["adb", "-s", serial, "shell", "am", "start", "-n",
                       "com.topgamesinc.evony/com.topgamesinc.androidplugin.UnityActivity"],
                      timeout=10)
        # OPCIÓN 2 — RECUPERACIÓN ROBUSTA (2026-08-18): esperar al RENDER real en vez de un fijo de
        # 50s. Los `create_script timeout` (que convertían 1 freeze en CASCADA de hard-resets: el
        # reattach fallaba -> REATTACH_FAILS>=3 -> más hard-resets) venían de reattachar mientras el
        # juego aún cargaba (pantalla blanca). Ahora: margen mínimo + poll de screencap hasta que
        # PINTE (2 lecturas buenas seguidas, criterio de _screen_renders/supervise) antes de dar el
        # reattach por listo. Un reattach a juego YA RENDERIZADO entra a la 1ª (medido).
        print(f"[{half}] HARD-RESET (UI) aplicado, esperando a que Evony RENDERICE (hasta ~90s)…", flush=True)
        time.sleep(35)                                          # margen mínimo (login + carga inicial)
        rendered = _screen_renders(serial, tries=6, need=2, gap=9)   # hasta ~54s más -> ~90s total
        _peel_popups_after_launch(serial, half)   # el juego recién arrancado abre modales que tapan el mapa
        print(f"[{half}] HARD-RESET (UI) completado (render={'OK' if rendered else 'EN BLANCO tras ~90s'}), esperando reattach del watchdog", flush=True)
    except Exception as e:
        print(f"[{half}] HARD-RESET (UI) fail: {e}", flush=True)

def _nuclear_reset_phone(serial, half):
    """NUCLEAR en MÓVIL FÍSICO. No hay qemu que matar ni AVD que relanzar, así que el escalón por
    encima de 'reiniciar el juego' es reiniciar el DISPOSITIVO y rearmar frida-server y Evony.
    Tras el reboot hay que despertar la pantalla y quitar el keyguard (sin PIN basta el swipe):
    Unity PAUSA la app con la pantalla apagada y el juego perdería su conexión con el servidor."""
    import subprocess
    print(f"[{half}] NUCLEAR-RESET (movil {serial}): reboot del dispositivo", flush=True)
    STATE[half]["sweep"] = "nuclear-reset"
    try:
        subprocess.run(["adb", "-s", serial, "reboot"], timeout=20)
        time.sleep(45)
        booted = False
        for _ in range(40):
            try:
                r = subprocess.run(["adb", "-s", serial, "shell", "getprop", "sys.boot_completed"],
                                   capture_output=True, text=True, timeout=5)
                if "1" in (r.stdout or ""): booted = True; break
            except Exception: pass
            time.sleep(5)
        if not booted:
            print(f"[{half}] NUCLEAR (movil): no arranco a tiempo", flush=True); return
        subprocess.run(["adb", "-s", serial, "shell", "input", "keyevent", "KEYCODE_WAKEUP"], timeout=10)
        time.sleep(1)
        subprocess.run(["adb", "-s", serial, "shell", "input", "swipe", "540", "1900", "540", "600", "200"], timeout=10)
        subprocess.run(["adb", "-s", serial, "shell", "su", "-c",
                        "setsid /data/local/tmp/frida-server </dev/null >/dev/null 2>&1 &"], timeout=15)
        time.sleep(4)
        subprocess.run(["adb", "-s", serial, "shell", "am", "start", "-n",
                        "com.topgamesinc.evony/com.topgamesinc.androidplugin.UnityActivity"], timeout=20)
        print(f"[{half}] movil reiniciado, frida-server y Evony relanzados", flush=True)
        time.sleep(30)
        _peel_popups_after_launch(serial, half)   # tras el reboot, Evony vuelve a abrir modales
    except Exception as e:
        print(f"[{half}] NUCLEAR (movil) error: {e}", flush=True)


def _nuclear_reset_impl(serial, half):
    """Kill qemu + relanzar AVD entero. ~3min. Idempotente."""
    import subprocess
    if not _is_emulator(serial):
        return _nuclear_reset_phone(serial, half)
    avd, port = NUCLEAR_AVD[half]
    print(f"[{half}] ☠ NUCLEAR-RESET (UI): kill qemu pid + relanzar AVD {avd}", flush=True)
    STATE[half]["sweep"] = "nuclear-reset"
    try:
        r = subprocess.run(["sh", "-c",
            f"ps -A -o pid,command | grep 'qemu-system' | grep -- '-port {port}' | grep -v grep | awk '{{print $1}}'"],
            capture_output=True, text=True, timeout=5)
        qpid = (r.stdout or "").strip().split("\n")[0].strip()
        if qpid:
            subprocess.run(["kill", "-9", qpid], timeout=5)
            print(f"[{half}] qemu pid {qpid} killed", flush=True)
        time.sleep(5)
        # NO hacemos adb kill-server aquí: afectaría a la OTRA mitad. El emulador
        # nuevo se re-registrará solo cuando arranque.
        subprocess.Popen([EMULATOR_BIN, "-avd", avd, "-port", str(port),
                          "-no-snapshot", "-writable-system", "-no-boot-anim",
                          "-gpu", "host", "-no-window", "-memory", "4096",
                          "-partition-size", "8192", "-no-audio"],
                         stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
        print(f"[{half}] esperando 180s a boot del emulador...", flush=True)
        time.sleep(180)
        subprocess.run(["adb", "-s", serial, "root"], timeout=15)
        time.sleep(5)
        for _ in range(10):
            try:
                r2 = subprocess.run(["adb", "-s", serial, "shell", "echo", "ready"],
                                    capture_output=True, text=True, timeout=5)
                if "ready" in (r2.stdout or ""): break
            except Exception: pass
            time.sleep(2)
        subprocess.run(["adb", "-s", serial, "shell",
                       "nohup", "/data/local/tmp/frida-server", ">/dev/null", "2>&1", "&"],
                      timeout=5)
        time.sleep(4)
        subprocess.run(["adb", "-s", serial, "shell", "am", "start", "-n",
                       "com.topgamesinc.evony/com.topgamesinc.androidplugin.UnityActivity"],
                      timeout=10)
        print(f"[{half}] esperando 35s a Evony tras nuclear...", flush=True)
        time.sleep(35)
        print(f"[{half}] ☠ NUCLEAR-RESET (UI) completado", flush=True)
    except Exception as e:
        print(f"[{half}] ☠ NUCLEAR-RESET (UI) fail: {e}", flush=True)

def _run_reset(half, kind):
    """Ejecuta hard o nuclear reset en un thread separado, coordinado con frida_thread.
    Si ya hay un reset corriendo en esta mitad, no hace nada (no se acumulan)."""
    if not RESET_LOCK[half].acquire(blocking=False):
        print(f"[{half}] reset ya en curso, ignorando duplicado ({kind})", flush=True)
        return False
    def worker():
        try:
            EXTERNAL_RESET[half] = True
            REATTACH_FAILS[half] = 0   # limpia contador para que frida_thread no escale
            serial = next(s for s, h in SCANNERS if h == half)
            if kind == "nuclear":
                _nuclear_reset_impl(serial, half)
            else:
                _hard_reset_impl(serial, half)
            # tras el reset, fuerza al watchdog del frida_thread a reattachar inmediato
            LAST_HEARTBEAT[half] = 0.0
        finally:
            EXTERNAL_RESET[half] = False
            RESET_LOCK[half].release()
    threading.Thread(target=worker, daemon=True, name=f"reset-{half}-{kind}").start()
    return True

def _detach_all_sessions(timeout_s=3.0):
    """Suelta TODAS las sesiones frida ANTES de que el backend muera.

    ⭐ Arregla la CAUSA RAÍZ medida el 2026-08-10: si el backend se mata sin soltar la sesión,
    su agente se queda **HUÉRFANO dentro del proceso del juego** y el siguiente attach ya no
    puede inyectar (`create_script timeout` en bucle) hasta reiniciar el juego. Se comprobó
    contando mapeos: `grep -c frida-agent /proc/<pid juego>/maps` → W=3 (una sesión, sana) vs
    E=6 (dos sesiones, wedged). frida-server fresco NO lo cura (el huérfano vive en el juego).
    Como cada `kill` del backend sembraba un huérfano, el "deploy elegante" (respawn) era la
    fuente de los wedges.

    Cada detach corre en su PROPIO hilo con tope de `timeout_s` (3s por mitad, en paralelo →
    ~3s en total): si una sesión está colgada, el cierre del backend NO se queda pillado."""
    ths = []
    for h in ("W", "E"):
        s = FRIDA_SESSIONS.get(h)
        if s is None:
            continue
        def _d(half=h, sess=s):
            try:
                sess.detach()
                print(f"[{half}] sesión frida soltada al salir (sin huérfano)", flush=True)
            except Exception as e:
                print(f"[{half}] detach al salir falló: {e}", flush=True)
        t = threading.Thread(target=_d, daemon=True, name=f"detach-{h}")
        t.start()
        ths.append((h, t))
    for h, t in ths:
        t.join(timeout_s)
        if t.is_alive():
            print(f"[{h}] el detach no terminó en {timeout_s}s -> se sale igualmente "
                  f"(puede quedar huérfano: si esa mitad no attacha, reinicia su JUEGO)", flush=True)
        FRIDA_SESSIONS[h] = None

def _install_exit_hooks():
    """SIGTERM/SIGINT + atexit -> soltar las sesiones frida. El supervisor mata el backend con
    SIGTERM, así que este camino es el normal en cada despliegue/respawn. ⚠️ `kill -9` (SIGKILL)
    NO se puede interceptar: los shortcuts mandan primero SIGTERM y solo usan -9 si sigue vivo."""
    import atexit, signal
    atexit.register(_detach_all_sessions)
    def _on_term(signum, frame):
        print(f"[web] señal {signum} recibida -> soltando sesiones frida + guardando OBJS y saliendo", flush=True)
        _detach_all_sessions()
        # OPCIÓN B: volcar la lista de monstruos SOLO aquí (al apagar), con frida YA soltado ->
        # sin escaneo activo -> cero riesgo de freeze. load_cache la recupera al re-arrancar.
        try: save_cache(include_objs=True)
        except Exception as e: print(f"[web] save OBJS al salir falló: {e}", flush=True)
        os._exit(0)
    for _s in (signal.SIGTERM, signal.SIGINT):
        try:
            signal.signal(_s, _on_term)
        except Exception as e:
            print(f"[web] no se pudo instalar el handler de {_s}: {e}", flush=True)

def _queued_reset(half, wait_s=150):
    """Reinicia una mitad que en este momento tenía OTRO reset en curso (`RESET_LOCK` cogido,
    normalmente por el watchdog). Reintenta cada 5s hasta que el cerrojo se libera.

    Por qué existe: el botón "Restart Scanners" llamaba a `_run_reset` una vez y, si la mitad
    estaba ocupada, la **ignoraba en silencio** -> se reiniciaba solo media (2026-08-10, con E
    congelada: `hard reset en ['W']`, y en la 1ª pulsación `en []`, es decir nada). Ahora una
    pulsación acaba cubriendo AMBAS mitades. No lanza."""
    t0 = time.time()
    while time.time() - t0 < wait_s:
        time.sleep(5)
        if _run_reset(half, "hard"):
            waited = int(time.time() - t0)
            print(f"[{half}] reinicio EN COLA ejecutado (esperó {waited}s al reset previo)", flush=True)
            _incident(half, "reinicio en cola",
                      f"reiniciada al liberarse el reset que ya estaba en curso ({waited}s de espera)")
            return
    print(f"[{half}] reinicio EN COLA descartado: el reset previo seguía tras {wait_s}s", flush=True)
    _incident(half, "reinicio NO hecho",
              f"el reset previo seguía en curso tras {wait_s}s; vuelve a pulsar Restart Scanners")

def frida_thread(serial, half):
    # agentes PREcompilados por mitad (no se puede editar el bundle de
    # frida-compile en runtime: tiene manifiesto de offsets -> "malformed package").
    agent_path = os.path.join(HERE, f"agent_{half}.js")
    with open(agent_path) as f:
        src = f.read()
    attempt = 0
    while True:
        attempt += 1
        try:
            # CHOKEPOINT de fiabilidad: antes de cualquier attach, garantizar que
            # frida-server esté LISTEN (lo revive si murió tras un reset/crash, que es
            # la causa #1 de "el emulador se reinicia pero no vuelve al mapa": sin
            # frida-server no hay agente y autoWM no corre). Fuera del FRIDA_SETUP_LOCK
            # porque es puro adb (no toca el core de frida) y puede tardar.
            if not _ensure_frida_server(serial, half):
                REATTACH_FAILS[half] += 1
                print(f"[{half}] frida-server no disponible; fails={REATTACH_FAILS[half]}", flush=True)
                raise _NeedsEscalation()
            # CHOKEPOINT #2: Evony debe estar en FOREGROUND o Unity está pausado y el agente
            # cargará pero sin heartbeat/self-uid (causa raíz del bucle reattach tras reset).
            _ensure_evony_foreground(serial, half)
            # setup serializado: dos hilos haciendo attach/load a la vez en el
            # core de frida corrompe el transporte ("malformed package").
            with FRIDA_SETUP_LOCK:
                dev = frida.get_device(serial, timeout=30)
                # attach by name evita el enumerate_processes() que estaba timeando.
                try:
                    session = dev.attach("Evony")
                except frida.ProcessNotFoundError:
                    # Evony no corre: contar como fallo para que la escalación HARD/NUCLEAR
                    # (que sí lanza Evony) se dispare en lugar de quedar en bucle eterno.
                    REATTACH_FAILS[half] += 1
                    print(f"[{half}] Evony no corre en {serial}; fails={REATTACH_FAILS[half]}", flush=True)
                    time.sleep(5)
                    # FALL THROUGH al bloque de thresholds al final del while
                    raise _NeedsEscalation()
                # create_script falla a veces con TransportError porque transferir 167KB
                # via adb forward es lento, sobre todo si Evony está en cold-load (mitad
                # densa W): frida-server compite por CPU y el transporte se atasca. Retry
                # 4 veces con backoff largo (5/8/11/14s = 38s totales) para darle tiempo a
                # caer en un valle de CPU ANTES de escalar al HARD-reset (que es el freeze).
                # 2026-08-10 (B): 4 reintentos -> 2. MEDIDO: cuando la inyección no entra es
                # porque hay un AGENTE HUÉRFANO en el proceso del juego (mapeos frida-agent=6),
                # y eso NO se cura esperando: los 4 reintentos con backoff (5+8+11+14=38s) eran
                # 38s de escáner parado antes de aplicar el único remedio que funciona (reiniciar
                # el juego). Con 2 se pierde ~13s y se escala ya.
                script = None
                CS_TRIES = 2
                for cs_try in range(CS_TRIES):
                    try:
                        script = session.create_script(src)
                        break
                    except frida.TransportError as e:
                        wait = 5 + cs_try * 3
                        print(f"[{half}] create_script try {cs_try+1} fail ({e}); retry {wait}s", flush=True)
                        _incident(half, "inyección falló",
                                  f"create_script intento {cs_try+1}/{CS_TRIES}: {e} — el proceso del juego "
                                  f"no acepta el script a tiempo (normalmente un agente huérfano dentro "
                                  f"del juego, o carga en frío)")
                        time.sleep(wait)
                if script is None:
                    # ⭐ 2026-08-10 (A+B) — REESCRITO CON DATOS. Antes esto hacía "frida-server
                    # fresco + reattach", y eso era DOBLEMENTE malo:
                    #  (A) matar frida-server NO cura el wedge — PROBADO en vivo: se mató y
                    #      relanzó (PID 15495->16741) y E seguía sin poder inyectar. El huérfano
                    #      vive en el proceso DEL JUEGO, no en frida-server. Peor aún: cortar
                    #      frida-server de golpe SIEMBRA huérfanos. Cuando el `kill` empezó a
                    #      funcionar de verdad (fix del `su -c`), los create_script fallidos
                    #      pasaron de 13/h a 38/h. Por eso ya NO se toca frida-server aquí.
                    #  (B) reattachar sin más solo repetía el fallo hasta gastar 3 intentos y
                    #      llegar al HARD-RESET: minutos de escáner parado por ciclo.
                    # AHORA: directo al ÚNICO remedio probado -> REINICIAR EL JUEGO de esa mitad
                    # (borra el huérfano; verificado: proceso nuevo con frida-agent=0 -> attach OK).
                    # _run_reset corre en su propio hilo con RESET_LOCK, así que no bloquea aquí.
                    print(f"[{half}] inyección imposible ({CS_TRIES}x) -> REINICIO DEL JUEGO "
                          f"(lo único que borra un agente huérfano; NO se toca frida-server)", flush=True)
                    _incident(half, "inyección imposible",
                              f"{CS_TRIES} intentos de create_script agotados -> reinicio del juego "
                              f"(agente huérfano dentro del proceso). frida-server NO se toca.")
                    try: session.detach()
                    except Exception: pass
                    FRIDA_SESSIONS[half] = None
                    _run_reset(half, "hard")     # force-stop + relanzar + esperar render + reattach
                    REATTACH_FAILS[half] = 0     # el reinicio ES la escalación: no acumular más
                    time.sleep(3)
                    raise _NeedsEscalation()
                script.on("message", lambda m, d, h=half: on_message(m, d, h))
                try:
                    script.load(timeout=120)
                except TypeError:
                    script.load()
                SCRIPTS[half] = script                # ref para pause/resume via .post
                FRIDA_SESSIONS[half] = session         # ref de la sesión -> aplicar el throttle de fps
                _FPS_APPLIED[half] = False             # re-aplicar el throttle tras cada (re)attach
                # aplicar el perfil de escáner ACTIVO (region+server) en cuanto carga el
                # agente -> sobrevive reinicios/reattach sin recompilar.
                try: push_scan_cfg(half)
                except Exception as e: print(f"[scanner-cfg] push inicial {half} err: {e}", flush=True)
                # si la UI ya pidio pausa antes de que arrancara este escaner, propaga
                if SCANNER_PAUSED:
                    try: script.post({"type": "ctl", "cmd": "pause"})
                    except Exception: pass
                LAST_PASS_TS[half] = time.time()      # base para medir la 1a vuelta
                STATE[half]["sweep"] = "running"
                STATE[half]["num"] = 0
                STATE[half]["last_secs"] = None
                # GRACIA DE COLD-LOAD: hasta que esta mitad reporte su 1a vuelta de sweep
                # (COLD_GRACE_DONE -> True), el watchdog usa FIRST_HB_GRACE (180s) en vez de
                # 90s. El cold-load de la mitad densa W es legitimamente lento y puede silenciar
                # el heartbeat un rato; NO la mates por eso (el reattach prematuro disparaba el
                # freeze). Tras la 1a vuelta -> timeout normal. Reset aqui = cada cold-load
                # (incluido tras reattach) estrena su ventana larga.
                COLD_GRACE_DONE[half] = False
                LAST_HEARTBEAT[half] = time.time()
                try: SCAN_STATS[half]["last_obj_ts"] = time.time()   # anti-freeze: reinicia el reloj "sin objetos" en cada attach (si no, un ts viejo daría falso freeze)
                except Exception: pass
                REATTACH_FAILS[half] = 0   # éxito: resetea contador de fallos
                print(f"[{half}] frida activo en {serial} (intento {attempt})", flush=True)
                if attempt > 1:
                    _incident(half, "attach OK", f"agente inyectado al intento {attempt} ({serial})")
                # WARM-UP anti-freeze: el bridge il2cpp acaba de cargar y Evony puede
                # seguir en cold-load (mitad densa W = pico de CPU). Pausamos el sweep
                # WARMUP_PAUSE_S para no competir por CPU con los jumps del mapa y dejar
                # que todo asiente; luego resume automático en un thread aparte. El
                # heartbeat sigue (setInterval del agente) -> el watchdog no interfiere.
                # Se omite si el usuario pausó manualmente (no auto-reanudamos su pausa).
                if not SCANNER_PAUSED and WARMUP_PAUSE_S > 0:
                    try:
                        script.post({"type": "ctl", "cmd": "pause"})
                        STATE[half]["sweep"] = "warmup"
                        print(f"[{half}] warm-up: sweep en pausa {WARMUP_PAUSE_S}s (bridge+Evony settling; heartbeat sigue)", flush=True)
                        def _warmup_resume(h=half, sc=script):
                            time.sleep(WARMUP_PAUSE_S)
                            # solo reanuda si este sigue siendo el script vivo y el
                            # usuario no pausó manualmente entretanto
                            if SCRIPTS.get(h) is sc and not SCANNER_PAUSED:
                                try: sc.post({"type": "ctl", "cmd": "resume"})
                                except Exception: pass
                                if STATE[h].get("sweep") == "warmup":
                                    STATE[h]["sweep"] = "running"
                                try: SCAN_STATS[h]["last_obj_ts"] = time.time()   # anti-freeze: el sweep resume AQUÍ -> baseline fresco
                                except Exception: pass
                                print(f"[{h}] warm-up completado -> sweep activo", flush=True)
                        threading.Thread(target=_warmup_resume, daemon=True).start()
                    except Exception as e:
                        print(f"[{half}] warm-up skip ({e})", flush=True)
            # lock liberado: el otro escaner puede hacer su setup; los mensajes
            # llegan por el hilo interno de frida, no por este.
            while True:
                time.sleep(5)
                hb = LAST_HEARTBEAT.get(half, 0)
                age = time.time() - hb
                # timeout DINAMICO: gracia larga (180s) durante el cold-load, normal (90s) tras
                # la 1a vuelta de sweep. Evita matar la mitad densa W mientras carga.
                to = HEARTBEAT_TIMEOUT if COLD_GRACE_DONE.get(half) else FIRST_HB_GRACE
                if age > to:
                    # hb=0 es señal de "reset forzado externamente": no mostramos
                    # el delta absurdo (~1.7e9s = epoch). Solo decimos "forzado".
                    age_str = "(forzado)" if hb == 0 else f"hace {int(age)}s"
                    print(f"[{half}] watchdog: sin heartbeat {age_str}, forzando reattach", flush=True)
                    _incident(half, "sin heartbeat",
                              ("reattach forzado a mano o tras un reinicio" if hb == 0
                               else f"el agente no dice nada desde hace {int(age)}s -> reattach"))
                    # NO llamar unload/detach: pueden colgarse si la sesión está corrupta.
                    # Frida limpiará la sesión zombi cuando hagamos attach nuevo.
                    SCRIPTS[half] = None
                    STATE[half]["sweep"] = "reattach"
                    REATTACH_FAILS[half] += 1
                    break
                # ANTI-FREEZE (bug 2026-07-20): heartbeat VIVO pero escaneo CONGELADO.
                # El heartbeat es un setInterval del agente INDEPENDIENTE del loop de escaneo:
                # puede seguir latiendo mientras el escaneo se atasca y no llega NINGÚN objeto
                # nuevo. Lo detectamos por la frescura de last_obj_ts (se refresca con CADA objeto
                # parseado, nuevo o repetido). Solo cuando el sweep DEBERÍA producir (running/pasada)
                # y sin pausa manual. Un escáner sano recorre todo el mapa y siempre ve objetos, así
                # que 0 objetos en SCAN_FREEZE_TIMEOUT s = congelado -> reattach (misma vía que el hb).
                sw = STATE[half].get("sweep", "")
                if (not SCANNER_PAUSED) and (sw == "running" or sw.startswith("pasada")):
                    lot = (SCAN_STATS.get(half) or {}).get("last_obj_ts", 0) or 0
                    if lot:
                        obj_age = time.time() - lot
                        if obj_age > SCAN_FREEZE_TIMEOUT:
                            _nowf = time.time()
                            if _nowf - LAST_FREEZE_TS[half] > FREEZE_WINDOW_S:
                                FREEZE_FAILS[half] = 0   # freeze aislado (>10min desde el último) -> de cero
                            FREEZE_FAILS[half] += 1; LAST_FREEZE_TS[half] = _nowf
                            SCRIPTS[half] = None
                            STATE[half]["sweep"] = "frozen"
                            # El reattach NO arregla un render/sesión colgado (pantalla blanca): el juego está vivo.
                            # 1er freeze -> reattach (por si es un hipo de frida); freezes REPETIDOS -> empujar
                            # REATTACH_FAILS al umbral para que la escalera existente reinicie el JUEGO, y si
                            # persiste, el EMULADOR (los resets exitosos siguen reseteando REATTACH_FAILS; FREEZE_FAILS
                            # persiste por ventana -> si vuelve a congelar, re-escala).
                            if FREEZE_FAILS[half] >= FREEZE_NUCLEAR_AT:
                                REATTACH_FAILS[half] = max(REATTACH_FAILS[half], NUCLEAR_RESET_THRESHOLD)
                                print(f"[{half}] watchdog: FREEZE x{FREEZE_FAILS[half]} — ni reattach ni restart de juego lo arreglan -> escalando a NUCLEAR (relanzar emulador)", flush=True)
                            elif FREEZE_FAILS[half] >= FREEZE_HARD_AT:
                                REATTACH_FAILS[half] = max(REATTACH_FAILS[half], HARD_RESET_THRESHOLD)
                                print(f"[{half}] watchdog: FREEZE x{FREEZE_FAILS[half]} — el reattach no arregla el render colgado (¿pantalla blanca?) -> escalando a RESTART del JUEGO", flush=True)
                                _incident(half, "congelado (x%d)" % FREEZE_FAILS[half],
                                          f"{int(obj_age)}s sin objetos y el reattach no lo cura "
                                          f"(render colgado / pantalla en blanco) -> reinicio del juego")
                            else:
                                REATTACH_FAILS[half] += 1
                                print(f"[{half}] watchdog: FREEZE detectado (heartbeat vivo, sweep={sw}, 0 objetos nuevos hace {int(obj_age)}s, freeze #{FREEZE_FAILS[half]}) -> forzando reattach", flush=True)
                                _incident(half, "sin objetos",
                                          f"{int(obj_age)}s sin un solo objeto nuevo (agente vivo, sweep={sw}) "
                                          f"-> reattach forzado")
                            break
        except _NeedsEscalation:
            # Señal explícita desde dentro del bloque para caer al threshold check
            # (REATTACH_FAILS ya fue incrementado por quien lanzó la excepción).
            STATE[half]["sweep"] = "reconectando"
        except frida.TransportError as e:
            REATTACH_FAILS[half] += 1
            print(f"[{half}] TransportError ({e}); fails={REATTACH_FAILS[half]}", flush=True)
            STATE[half]["sweep"] = "reconectando"
            time.sleep(4)
        except Exception as e:
            msg = str(e).lower()
            if ("device not found" in msg or "device offline" in msg
                    or "unable to find device" in msg or "no device" in msg):
                # adb/frida perdió el device (típico tras kill de qemu o muerte del daemon
                # adb). NO escalar a nuclear: eso mataría qemu otra vez y reiniciaría el
                # bucle. Revivir adb y ESPERAR a que el device vuelva; reintentar sin sumar
                # fallo. Solo si tras esperar 60s sigue ausente cuenta como fallo real.
                STATE[half]["sweep"] = "esperando-adb"
                print(f"[{half}] device perdido ({e}); reviviendo adb + esperando device (NO escalo)", flush=True)
                if _revive_adb_device(serial):
                    print(f"[{half}] device {serial} de vuelta ONLINE; reintento attach sin sumar fallo", flush=True)
                    STATE[half]["sweep"] = "reconectando"
                    time.sleep(3)
                    continue
                REATTACH_FAILS[half] += 1
                print(f"[{half}] device {serial} sigue ausente tras 60s; fails={REATTACH_FAILS[half]}", flush=True)
                STATE[half]["sweep"] = "reconectando"
                time.sleep(5)
            else:
                REATTACH_FAILS[half] += 1
                print(f"[{half}] error: {e}; fails={REATTACH_FAILS[half]}", flush=True)
                STATE[half]["sweep"] = "reconectando"
                time.sleep(5)

        # Si la UI disparó un reset externo, NO hagas escalación propia:
        # _run_reset() ya está haciendo el trabajo desde otro thread.
        if EXTERNAL_RESET[half]:
            print(f"[{half}] reset externo en curso, frida_thread espera...", flush=True)
            while EXTERNAL_RESET[half]:
                time.sleep(2)
            time.sleep(3)   # un margen extra para que Evony termine de arrancar
            continue

        # NUCLEAR RESET AUTOMÁTICO: si N≥NUCLEAR_RESET_THRESHOLD fallos seguidos,
        # ni los hard-resets funcionan (qemu colgado a nivel proceso). Solución:
        # kill -9 al proceso qemu + relanzar el emulador entero. ~3 min total.
        if REATTACH_FAILS[half] >= NUCLEAR_RESET_THRESHOLD:
            if not _is_emulator(serial):      # movil fisico: reboot del aparato, NO relanzar un AVD
                _nuclear_reset_phone(serial, half)
                REATTACH_FAILS[half] = 0
                continue
            avd, port = NUCLEAR_AVD[half]
            print(f"[{half}] ☠ NUCLEAR-RESET: {REATTACH_FAILS[half]} fallos -> kill qemu pid + relanzar AVD {avd}", flush=True)
            STATE[half]["sweep"] = "nuclear-reset"
            try:
                import subprocess
                # 1) encontrar el qemu del puerto
                r = subprocess.run(["sh", "-c",
                    f"ps -A -o pid,command | grep 'qemu-system' | grep -- '-port {port}' | grep -v grep | awk '{{print $1}}'"],
                    capture_output=True, text=True, timeout=5)
                qpid = (r.stdout or "").strip().split("\n")[0].strip()
                if qpid:
                    subprocess.run(["kill", "-9", qpid], timeout=5)
                    print(f"[{half}] qemu pid {qpid} killed", flush=True)
                time.sleep(5)
                # 2) limpiar adb (registrará device offline)
                subprocess.run(["adb", "kill-server"], timeout=10)
                time.sleep(3)
                subprocess.run(["adb", "start-server"], timeout=10)
                time.sleep(3)
                # 3) relanzar emulador
                # 3) relanzar emulador V3 (flags ajustados: -no-snapshot-load preserva el save,
                # sin -writable-system ni -partition-size para no romper userdata persistente)
                subprocess.Popen([EMULATOR_BIN, "-avd", avd, "-port", str(port),
                                  "-no-snapshot-load", "-no-boot-anim", "-no-audio",
                                  "-gpu", "host", "-no-window"],
                                 stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
                print(f"[{half}] esperando boot del emulador (hasta 120s)...", flush=True)
                # esperar boot completado (en lugar de sleep ciego)
                booted = False
                for _ in range(60):
                    try:
                        r2 = subprocess.run(["adb", "-s", serial, "shell", "getprop", "sys.boot_completed"],
                                            capture_output=True, text=True, timeout=5)
                        if "1" in (r2.stdout or ""): booted = True; break
                    except Exception: pass
                    time.sleep(2)
                if not booted:
                    print(f"[{half}] ☠ NUCLEAR-RESET fail: emulador no booted en 120s", flush=True)
                    time.sleep(20); continue
                time.sleep(6)  # buffer extra para SystemUI

                # 4) ensure Magisk root (playstore+Magisk requiere lanzar app si grant perdido)
                r_who = subprocess.run(["adb", "-s", serial, "shell", "su", "-c", "whoami"],
                                       capture_output=True, text=True, timeout=8)
                if "root" not in (r_who.stdout or ""):
                    print(f"[{half}] Magisk grant denied, lanzando app...", flush=True)
                    subprocess.run(["adb", "-s", serial, "shell", "monkey", "-p",
                                    "com.topjohnwu.magisk", "-c", "android.intent.category.LAUNCHER", "1"],
                                   capture_output=True, timeout=10)
                    time.sleep(8)

                # 5) ensure frida-server binary (puede haberse perdido)
                r_ls = subprocess.run(["adb", "-s", serial, "shell", "ls", "/data/local/tmp/frida-server"],
                                      capture_output=True, text=True, timeout=5)
                if "No such" in (r_ls.stderr or "") or "No such" in (r_ls.stdout or ""):
                    print(f"[{half}] re-pushing frida-server...", flush=True)
                    subprocess.run(["adb", "-s", serial, "push",
                                    os.path.join(os.path.dirname(os.path.abspath(__file__)),
                                                 "frida-server-17.9.10-android-arm64"),
                                    "/data/local/tmp/frida-server"], capture_output=True, timeout=30)
                    subprocess.run(["adb", "-s", serial, "shell", "su", "-c",
                                    "chmod 755 /data/local/tmp/frida-server"], capture_output=True, timeout=5)

                # 6) arrancar frida-server via su -c + setsid (NO nohup — Android lo mata al cierre adb shell)
                subprocess.run(["adb", "-s", serial, "shell", "su", "-c",
                                "pkill -9 -f /data/local/tmp/frida-server 2>/dev/null; "
                                "setsid /data/local/tmp/frida-server </dev/null >/dev/null 2>&1 &"],
                              timeout=8)
                time.sleep(4)

                # 7) lanzar Evony (settle 25s para Unity init antes de que frida intente attach)
                subprocess.run(["adb", "-s", serial, "shell", "am", "start", "-n",
                               "com.topgamesinc.evony/com.topgamesinc.androidplugin.UnityActivity"],
                              timeout=10)
                print(f"[{half}] esperando 25s settle Evony...", flush=True)
                time.sleep(25)
                REATTACH_FAILS[half] = 0
            except Exception as e:
                print(f"[{half}] ☠ NUCLEAR-RESET fail: {e}", flush=True)
                time.sleep(30)
            continue   # vuelve al while True externo para reintentar attach

        # HARD RESET AUTOMÁTICO: si ya hemos fallado N veces seguidas, algo más
        # profundo está mal. Force-stop Evony + relaunch + kill frida-server.
        # Esto soluciona el caso "Evony en estado degradado tras horas".
        if REATTACH_FAILS[half] >= HARD_RESET_THRESHOLD:
            print(f"[{half}] HARD-RESET: {REATTACH_FAILS[half]} fallos seguidos -> force-stop Evony (frida NO se toca)", flush=True)
            _incident(half, "HARD-RESET",
                      f"{REATTACH_FAILS[half]} fallos de attach seguidos -> reiniciar el JUEGO (frida-server NO se mata)")
            STATE[half]["sweep"] = "hard-reset"
            try:
                import subprocess
                # 2026-08-18: ALINEADO con _hard_reset_impl (lección 2026-08-10): la escalada YA NO
                # mata frida-server. Matarlo con la sesión aún viva puede ABORTAR frida-core (tumba el
                # backend entero) y deja el agente HUÉRFANO dentro del juego -> triplica los
                # create_script (13->38/h). Se auto-alimentaba: escalada mata frida -> huérfano ->
                # siguiente reattach falla -> vuelve a escalar -> mata frida... Lo que cura el wedge es
                # reiniciar el JUEGO (force-stop + relanzar), NO matar frida. (adb root: no-op en Pixel.)
                # DETACH defensivo de cualquier sesión que quede, ANTES de reiniciar el juego.
                sess = FRIDA_SESSIONS.get(half)
                if sess is not None:
                    try: sess.detach()
                    except Exception: pass
                    FRIDA_SESSIONS[half] = None
                    time.sleep(1)
                subprocess.run(["adb", "-s", serial, "shell", "am", "force-stop", "com.topgamesinc.evony"], timeout=10)
                time.sleep(3)
                # ensure frida-server ARRIBA (sin matarlo). setsid = sobrevive al cierre del shell adb;
                # si ya corre, la 2a instancia no puede bindear :27042 y sale sola (no-op inocuo).
                subprocess.run(["adb", "-s", serial, "shell", "su", "-c",
                                "setsid /data/local/tmp/frida-server </dev/null >/dev/null 2>&1 &"],
                              timeout=8)
                time.sleep(3)
                # pantalla ON antes de relanzar (con pantalla apagada Unity PAUSA y se pierde la sesión de red)
                subprocess.run(["adb", "-s", serial, "shell", "input", "keyevent", "KEYCODE_WAKEUP"], timeout=6)
                subprocess.run(["adb", "-s", serial, "shell", "am", "start", "-n",
                               "com.topgamesinc.evony/com.topgamesinc.androidplugin.UnityActivity"],
                              timeout=10)
                # OPCIÓN 2 (recuperación robusta): esperar al RENDER real en vez del fijo 50s
                # (mismo motivo que _hard_reset_impl: reattachar a juego cargando -> create_script timeout).
                print(f"[{half}] HARD-RESET aplicado, esperando a que Evony RENDERICE (hasta ~90s)…", flush=True)
                time.sleep(35)
                rendered = _screen_renders(serial, tries=6, need=2, gap=9)
                _peel_popups_after_launch(serial, half)   # ídem: pelar modales antes de reintentar el attach
                print(f"[{half}] HARD-RESET (escalada) completado (render={'OK' if rendered else 'EN BLANCO'})", flush=True)
                # NO reset el contador aquí: si el reattach posterior falla, el
                # contador sigue subiendo y eventualmente escala a NUCLEAR (>=6).
                # Solo se resetea al lograr `frida activo` exitosamente.
            except Exception as e:
                print(f"[{half}] HARD-RESET fail: {e}", flush=True)
                time.sleep(10)

# ---------------- API helpers ----------------
def enrich(o):
    c = CFG.get(str(o["id"])) or {}
    now = time.time()
    ft = o.get("first_ts", o.get("ts", now))
    # resaltado SOLO si: ya termino la 1a vuelta, el objeto se descubrio DESPUES
    # de esa 1a vuelta, y lleva <=5 min desde su primera deteccion.
    fresh = (FIRST_PASS_TS is not None
             and ft > FIRST_PASS_TS
             and (now - ft) <= FRESH_WINDOW)
    # estado de ataque/rally REAL: viene del hook ServerMapLayer.addTargetObj que
    # captura MsgDown.map_target_info (march en curso). Si existe march activa
    # hacia (wx,wy) con end_time > now, hay ataque/rally de verdad.
    # ataque/rally REAL via hook ServerMapLayer.addTargetObj. Solo cuentan
    # marches cuyo `mty` (Msg.march_type) sea de ATAQUE a monstruo:
    #   2  = monster              (solo de monarca)
    #   19 = boss_war_wait        (alianza: miembros uniendose)
    #   20 = boss_war_way         (alianza: rally marchando)
    #   21 = boss_war             (alianza: en combate)
    #   43 = scout_monster        (exploracion del monstruo)
    # Otros mty (reap_resource=7, cargo=49, etc.) son ruido y se filtran.
    atk = ""; atk_uid = 0; atk_guild = 0; atk_eta = 0; atk_mty = 0
    atk_name = ""; atk_tag = ""; atk_count = 0
    ATTACK_MTYS = {2, 10, 19, 20, 21, 43}   # 2=monster, 10=player-attack directo, 19/20/21=boss-rally fases, 43=scout
    ALLY_MTYS   = {19, 20, 21}
    key = (int(o["wx"]), int(o["wy"]))
    subs = MARCH_BY_TGT.get(key)
    if subs:
        rel = []
        for trp in list(subs.keys()):
            m = MARCHES.get(trp)
            if not m: continue
            if int(m.get("mty", 0)) not in ATTACK_MTYS: continue
            if int(m.get("te", 0)) <= now: continue
            rel.append(m)
        if rel:
            ally = [m for m in rel if int(m.get("mty", 0)) in ALLY_MTYS]
            if ally:
                # rally de alianza: elegimos representante priorizando mty=21
                # (en combate) > 20 (marchando) > 19 (formandose).
                by = {}
                for m in ally: by.setdefault(int(m["mty"]), []).append(m)
                if   21 in by: rep = min(by[21], key=lambda m: int(m["te"]))
                elif 20 in by: rep = min(by[20], key=lambda m: int(m["te"]))
                else:          rep = min(by[19], key=lambda m: int(m["te"]))
                atk = "rally"
                atk_count = len(ally)
            else:
                rep = max(rel, key=lambda m: int(m["te"]))
                atk = "rally"
                atk_count = 1
            atk_uid = int(rep.get("ow", 0))
            atk_mty = int(rep.get("mty", 0))
            atk_eta = max(0, int(rep.get("te", 0) - now))
            pl = PLAYERS.get(str(atk_uid)) or PLAYERS.get(atk_uid)
            if pl:
                atk_guild = int(pl.get("gid", 0) or 0)
                atk_name  = pl.get("name", "") or ""
                atk_tag   = pl.get("tag", "") or ""
    # legado: campos antiguos del agente (st/hp/oid) — los seguimos exponiendo
    # para que el tooltip los muestre, pero NO se usan para derivar `atk` ya.
    oid = int(o.get("oid", 0) or 0)
    ogd = int(o.get("ogd", 0) or 0)
    st  = int(o.get("st",  0) or 0)
    hp  = int(o.get("hp",  0) or 0)
    hpt = int(o.get("hpt", 0) or 0)
    if not atk_uid and oid > 0 and o["t"] in (3, 4, 5, 7):
        # mapinfo_type: farm/garrison/ruins/subcity -> ocupacion de territorio
        atk = "owned"; atk_uid = oid; atk_guild = ogd
        pl = PLAYERS.get(str(oid)) or PLAYERS.get(oid)
        if pl:
            atk_name = pl.get("name", "") or ""
            atk_tag  = pl.get("tag", "") or ""
    return {
        "name":  c.get("name", f"id{o['id']}"),
        "level": (o["lv"] if int(o.get("lv", 0) or 0) > 0 else int(c.get("level", 0) or 0)),   # lv 0 sin poblar (p.ej. Ymir Lv3/Lv5) -> usar nivel del CFG (como sc_monsters)

        "type":  o["t"],
        "power": c.get("power", 0),
        "x": o["wx"], "y": o["wy"], "id": o["id"],
        "group": group_of(c.get("name", "")),
        # SUMMON (verde, FIABLE): un EVENT MONSTER cuyo owner_id es un uid real de jugador/guild
        # (>= SUMMON_OWNER_MIN) fue INVOCADO. Solo grupo Event (los summons que importan, p.ej.
        # Elite Barbary Pirate); excluye monstruos Normal con dueño (Duke/Nell…), enums NPC y
        # subcities. Funciona para cualquier jugador, incl. enemigos.
        "summon": bool(group_of(c.get("name", "")) == "Event"
                       and int(o.get("own", 0) or 0) >= SUMMON_OWNER_MIN),
        # summon_likely (amarillo): DESACTIVADO (la heurística "fresh+atacado" inundaba de falsos
        # positivos, indistinguible del farmeo normal de event monsters).
        "summon_likely": False,
        "owner_uid": int(o.get("own", 0) or 0),
        "age": int(now - ft),
        "seen_age": int(now - o.get("ts", now)),
        "fresh": fresh,
        "half": o.get("half", ""),   # scanner que lo capturó (W o E)
        "atk": atk, "atk_uid": atk_uid, "atk_guild": atk_guild,
        "atk_eta": atk_eta, "atk_mty": atk_mty,
        "atk_name": atk_name, "atk_tag": atk_tag, "atk_count": atk_count,
        "atk_hp": hp, "atk_hpt": hpt, "atk_st": st,
    }

# Familias de rally-boss de Evony (match por substring, case-insensitive)
BOSS_KEYWORDS = [
    "cerberus", "sphinx", "bayard", "bayar knight", "lava turtle", "hydra",
    "ymir", "pan ", "harpy", "warlord", "witch", "golem", "mire squid",
    "pumpkin", "ghidorah", "nine-tails", "nine tails", "centaur", "minotaur",
    "peryton", "redcap", "skeleton dragon", "fafnir", "kamaitachi", "ifrit",
    "griffin", "behemoth", "manticore", "werewolf", "yasha", "typhon",
    "jormungandr", "phoenix", "gluttonous", "viking", "fire spirit",
    "golden goblin", "arctic barbarian",
]

_ALL_FAM_KW = None
def _all_fam_kw():
    """Todas las keywords de FAMILIES (fuente única). BOSS_KEYWORDS quedaba
    desactualizada respecto a FAMILIES (faltaban Ammit, Zombie, Kraken, Azazel,
    Leviathan, Garmr, Ghidorah, Nine-tails…), por lo que esos monstruos no
    entraban al catálogo ni mostraban pill. Derivar de FAMILIES lo evita."""
    global _ALL_FAM_KW
    if _ALL_FAM_KW is None:
        kws = []
        for _disp, _kws, _grp in FAMILIES:
            kws += _kws
        _ALL_FAM_KW = kws
    return _ALL_FAM_KW

# Override manual de CFG: monstruos que el cliente aún no tiene en su config
# (p.ej. event monster nuevo recién lanzado). SOBREESCRIBE estos ids (son nuestra
# fuente de verdad hasta que el cliente actualice su config; quitar de aquí cuando
# el dump del agente traiga el nombre real). 2026-06-05: "Normal Barbary Pirate"
# (event nuevo) — ids 1894 (lv1) / 1895 (lv2) detectados en el mapa sin nombre.
CFG_MANUAL = {
    # Son DOS monstruos distintos (no Lv1/Lv2 del mismo): Normal (1894) y Elite (1895).
    "1894": {"name": "Normal Barbary Pirate", "level": 1, "type": 0, "power": 24600000},
    "1895": {"name": "Elite Barbary Pirate",  "level": 2, "type": 0, "power": 130000000},
}
def apply_cfg_manual():
    """Aplica CFG_MANUAL sobreescribiendo (corrige incluso si la caché trae un
    nombre viejo de un override anterior)."""
    for k, v in CFG_MANUAL.items():
        CFG[k] = dict(v)

def is_boss(name):
    n = (name or "").lower()
    if "(boss)" in n:
        return True
    return any(k in n for k in _all_fam_kw())

# Familias de monstruos de Evony (taxonomía canónica validada con el usuario 2026-05-27).
# (display, keywords, grupo)  grupo: "boss" | "event" | "shadow" | "other"
# group_of() devuelve el grupo del PRIMER match → el ORDEN IMPORTA:
#   1º Shadow of Dawn (captura "(Shadow of Dawn) X" antes que el keyword base de criatura)
#   2º Flame Cerberus (event) antes que Cerberus (boss)
#   3º Bosses permanentes del mapa
#   4º Events temporales
#   5º Others (festivos/decorativos)
#   6º wildcard "(boss)"
# Boss   = monstruos PERMANENTES del mapa (Cerberus, Bayard, Harpy, Peryton, Kraken...)
# Event  = monstruos de EVENTOS temporales (Hydra, Warlord, Witch, Pan, Sphinx, Nian...)
# Shadow = evento Shadow of Dawn (sus propios bosses + estructuras)
# Other  = festivos/decorativos (Easter Bunny, Santa, Ferris Wheel, Ares Statue...)
FAMILIES = [
    # ── 1) SHADOW OF DAWN (primero: captura "(Shadow of Dawn) X" antes que la criatura base) ──
    ("Shadow of Dawn",  ["shadow of dawn"],      "shadow"),
    ("Dawn Sanctuary",  ["dawn sanctuary"],      "shadow"),
    ("Dawn Temple",     ["dawn temple"],         "shadow"),
    ("Eclipse Stronghold", ["eclipse stronghold"], "shadow"),
    # ── 2) Excepciones de event que contienen keyword de boss (antes que el boss) ──
    ("Flame Cerberus",  ["flame cerberus"],      "event"),   # event, NO boss (pese a "cerberus")
    # ── 3) BOSS — monstruos permanentes del mapa ──
    ("Cerberus",        ["cerberus"],            "boss"),
    ("Knight Bayard",   ["bayard", "bayar knight"], "boss"),
    ("Harpy",           ["harpy"],               "boss"),
    ("Peryton",         ["peryton"],             "boss"),
    ("Minotaur",        ["minotaur"],            "boss"),
    ("Griffin",         ["griffin"],             "boss"),
    ("Ifrit",           ["ifrit"],               "boss"),
    ("Kamaitachi",      ["kamaitachi"],          "boss"),
    ("Fafnir",          ["fafnir"],              "boss"),
    ("Behemoth",        ["behemoth"],            "boss"),
    ("Phoenix",         ["phoenix"],             "boss"),
    ("Jormungandr",     ["jormungandr"],         "boss"),
    ("Typhon",          ["typhon"],              "boss"),
    ("Ammit",           ["ammit"],               "boss"),
    ("Centaur",         ["centaur"],             "boss"),
    ("Werewolf",        ["werewolf"],            "boss"),
    ("Yasha",           ["yasha"],               "boss"),
    ("Redcap",          ["redcap"],              "boss"),
    ("Skeleton Dragon", ["skeleton dragon"],     "boss"),
    ("Manticore",       ["manticore"],           "boss"),
    ("Zombie",          ["zombie"],              "boss"),
    ("Airavata",        ["airavata"],            "boss"),
    ("Ghidorah",        ["ghidorah"],            "boss"),
    ("Nine-tails",      ["nine-tails", "nine tails"], "boss"),
    ("Stymphalian Bird",["stymphalian"],         "boss"),
    ("Kraken",          ["kraken"],              "boss"),
    ("Azazel",          ["azazel"],              "boss"),
    ("Leviathan",       ["leviathan"],           "boss"),
    ("Garmr",           ["garmr"],               "boss"),
    # ── 4) EVENT — monstruos de eventos temporales ──
    # Barbary Pirate: dos monstruos distintos -> dos pills (Normal 24.6M / Elite 130M)
    ("Normal Barbary Pirate", ["normal barbary pirate"], "event"),
    ("Elite Barbary Pirate",  ["elite barbary pirate"],  "event"),
    ("Hydra",           ["hydra"],               "event"),
    ("Warlord",         ["warlord"],             "event"),
    ("Ymir",            ["ymir"],                "event"),
    ("Pan",             ["pan "],                "event"),
    ("Lava Turtle",     ["lava turtle"],         "event"),
    ("Sphinx",          ["sphinx"],              "event"),   # cubre Sphinx y Sphinx Castle
    ("Witch",           ["witch"],               "event"),   # ojo: Mysterious Witch va a Other (antes)
    ("Golem",           ["golem"],               "event"),
    ("Mire Squid",      ["mire squid"],          "event"),
    ("Pumpkin",         ["pumpkin"],             "event"),
    ("Viking",          ["viking"],              "event"),
    ("Golden Goblin",   ["golden goblin"],       "event"),
    ("Arctic Barbarian",["arctic barbarian"],    "event"),
    ("Aglaope",         ["aglaope"],             "event"),
    ("Nasu",            ["nasu"],                "event"),
    ("Gugler Knight",   ["gugler"],              "event"),
    ("Carcinus",        ["carcinus"],            "event"),
    ("Serpopard",       ["serpopard"],           "event"),
    ("Savage Tiger Gladiator", ["savage tiger"], "event"),
    ("Taotie",          ["taotie"],              "event"),
    ("Nian",            ["nian"],                "event"),
    ("Cranky Snowman",  ["cranky snowman", "snowman"], "event"),
    ("Surtr",           ["surtr"],               "event"),
    ("Angry Turkey",    ["angry turkey", "turkey"], "event"),
    ("Fortress Pyramid",["fortress pyramid"],    "event"),
    # ── 5) OTHER — festivos / decorativos ──
    ("Easter Bunny",    ["easter bunny", "bunny"], "other"),
    ("Santa",           ["santa"],               "other"),
    ("Mysterious Witch",["mysterious witch"],    "other"),  # antes que Witch→event (más específico)
    ("Ferris Wheel",    ["ferris wheel"],        "other"),
    ("Celebration Squad",["celebration squad"],  "other"),
    ("Ares Statue",     ["ares statue"],         "other"),
    ("Fire Spirit",     ["fire spirit"],         "other"),
    # ── 6) wildcard genérico de bosses prefijados "(Boss)" (último) ──
    ("Bosses (Boss)",   ["(boss)"],              "boss"),
]
# Orden de precedencia: "Mysterious Witch" debe ir ANTES de "Witch" (event) para no
# clasificarse como event. Lo reordenamos: las entradas Other con substring que colisiona
# con event se evalúan por su posición — movemos Mysterious Witch arriba programáticamente.
_FAM_OTHER_FIRST = [f for f in FAMILIES if f[2] == "other" and "witch" in f[1][0]]
FAMILIES = _FAM_OTHER_FIRST + [f for f in FAMILIES if f not in _FAM_OTHER_FIRST]

def fam_match(name, kws):
    n = (name or "").lower()
    return any(k in n for k in kws)

_GROUP_LABEL = {"boss": "Boss", "event": "Event", "shadow": "Shadow of Dawn", "other": "Other"}
_GROUP_CACHE = {}   # name -> label (memo: los nombres se repiten miles de veces)
def group_of(name):
    """Clasifica un monstruo: Boss | Event | Shadow of Dawn | Other | Normal."""
    cached = _GROUP_CACHE.get(name)
    if cached is not None:
        return cached
    n = (name or "").lower()
    res = "Normal"
    for disp, kws, grp in FAMILIES:
        if any(k in n for k in kws):
            res = _GROUP_LABEL.get(grp, "Normal"); break
    else:
        if "(boss)" in n:
            res = "Boss"
    _GROUP_CACHE[name] = res
    return res

def reward_score(rw):
    """Composite per-kill (repetible) a partir de la tabla estatica MonsterConfig.
    exp domina (es el valor real de farmeo: subir generales); pop+cred suman; los items
    se valoran por valor esperado items_ev = sum(amount * priority/100), con priority ~=
    % de drop (el item de priority=100 es el drop garantizado). NO incluye first-kill
    (es one-time). Devuelve (score, items_ev)."""
    if not isinstance(rw, dict):
        return 0.0, 0.0
    exp = float(rw.get("exp", 0) or 0)
    pop = float(rw.get("pop", 0) or 0)
    cred = float(rw.get("cred", 0) or 0)
    items_ev = 0.0
    for it in (rw.get("items") or []):
        try:
            amt, pri = it[1], it[2]
            items_ev += float(amt or 0) * (float(pri or 0) / 100.0)
        except Exception:
            pass
    score = exp + pop + cred + items_ev * 100.0
    return round(score, 1), round(items_ev, 2)

def observed_kill_stats():
    """Agrega MONSTER_KILLS por monster_id -> {n, exp_avg, cost_avg, items:{id:total}}.
    Calibracion REAL: 'cost_avg' = lost_power medio (poder de tropa perdido por kill),
    'items' = loot observado acumulado. Solo cubre monstruos que W/E mataron de verdad.
    Llamar bajo LOCK (lee MONSTER_KILLS)."""
    agg = {}
    for it in list(MONSTER_KILLS):
        mid = int(it.get("monster_id", 0) or 0)
        if mid <= 0:
            continue
        a = agg.get(mid)
        if a is None:
            a = agg[mid] = {"n": 0, "exp_sum": 0.0, "cost_sum": 0.0, "items": {}}
        a["n"] += 1
        a["exp_sum"] += float(it.get("experience", 0) or 0)
        a["cost_sum"] += float(it.get("lost_power", 0) or 0)
        for pair in (it.get("loot") or []):
            try:
                iid, amt = int(pair[0]), int(pair[1])
                a["items"][iid] = a["items"].get(iid, 0) + amt
            except Exception:
                pass
    out = {}
    for mid, a in agg.items():
        n = a["n"] or 1
        out[mid] = {"n": a["n"], "exp_avg": round(a["exp_sum"] / n),
                    "cost_avg": round(a["cost_sum"] / n), "items": a["items"]}
    return out

def respawn_predictions(min_samples=2, only_empty=True,
                        ids=None, fams=None, allow_normal=True):
    """Devuelve [{x, y, id, name, lv, last_spawn_ts, median_cycle_s,
    samples, predicted_in_s, confidence, group}] ordenado por predicted_in_s asc.

    Filtros:
    - min_samples: ≥2 intervalos (≥3 spawns) para tener mediana fiable.
    - only_empty: solo tiles actualmente vacíos (los vivos aún no han muerto).
    - ids: set de monster_ids; si no vacío, solo esos.
    - fams: set de nombres de familia (display name de FAMILIES); si no vacío,
      solo monstruos cuyo nombre matchee alguna keyword de esas familias.
    - allow_normal: si False, descarta el grupo "Normal" (monstruos no-Boss no-Event).

    ids y fams se combinan en OR (igual que en query()).
    """
    ids = set(ids) if ids else set()
    fams = set(fams) if fams else set()
    # expand fams -> ids via fam_ids (reusa la función ya existente)
    if fams:
        ids |= fam_ids(fams)
    filter_by_id = bool(ids) or bool(fams)
    now = time.time()
    out = []
    with LOCK:
        for (wx, wy), hist in SPAWN_HISTORY.items():
            if len(hist) < 2:
                continue
            # spawn más reciente
            last = hist[-1]
            id_, lv = int(last.get("id", 0)), int(last.get("lv", 0))
            if id_ <= 0:
                continue
            # intervalos consecutivos
            deltas = [hist[i+1]["ts"] - hist[i]["ts"] for i in range(len(hist)-1)]
            deltas = [d for d in deltas if 60 <= d <= 86400]  # filtra outliers absurdos
            if not deltas:
                continue
            deltas_sorted = sorted(deltas)
            median = deltas_sorted[len(deltas_sorted)//2]
            # reliability: 'high' >=3 intervals & std<30%; 'medium' >=3; 'low' rest
            mean = sum(deltas)/len(deltas)
            var = sum((d-mean)**2 for d in deltas)/len(deltas)
            std = var**0.5
            cv = std/mean if mean > 0 else 1.0
            if len(deltas) >= 3 and cv < 0.30: conf = "high"
            elif len(deltas) >= 3: conf = "medium"
            else: conf = "low"

            # tile alive?
            o = OBJS.get((wx, wy))
            alive = o is not None and (now - o.get("ts", 0)) <= STALE_SECONDS \
                    and int(o.get("id", 0)) == id_ and int(o.get("lv", 0)) == lv
            if only_empty and alive:
                continue

            next_spawn_ts = last["ts"] + median
            predicted_in_s = next_spawn_ts - now
            cfg = CFG.get(str(id_)) or {}
            name = cfg.get("name", "") or f"id{id_}"
            grp = group_of(name)
            # filtros server-side
            if filter_by_id and id_ not in ids:
                continue
            if not allow_normal and grp == "Normal":
                continue
            out.append({
                "x": wx, "y": wy, "id": id_, "lv": lv, "name": name,
                "group": grp,             # "Boss" | "Event" | "Normal" — para badge UI
                "last_spawn_ts": int(last["ts"]),
                "last_spawn_age_s": int(now - last["ts"]),
                "median_cycle_s": int(median),
                "samples": len(deltas),
                "predicted_in_s": int(predicted_in_s),
                "predicted_ts": int(next_spawn_ts),
                "confidence": conf,
                "alive": alive,
            })
    # ordenar: los que ya deberían haber respawneado primero, luego los más cercanos
    out.sort(key=lambda r: r["predicted_in_s"])
    return out

def relocations_recent(max_age_h=24, near=None):
    """Devuelve [{uid, name, tag, gid, ts, age_s, from:[x,y], to:[x,y],
    distance_km, hops_total}] ordenado por ts desc.

    - max_age_h: descarta eventos más viejos que esto.
    - near: tupla (cx, cy, radius_tiles) opcional; si se da, solo eventos
      cuyo destino (to) caiga dentro del radio.
    """
    import math
    now = time.time()
    cutoff = now - max_age_h * 3600
    out = []
    with LOCK:
        for uid, hist in RELOCATIONS.items():
            for ev in hist:
                if ev.get("ts", 0) < cutoff:
                    continue
                tx, ty = int(ev.get("to_x", 0)), int(ev.get("to_y", 0))
                if near is not None:
                    cx, cy, rad = near
                    if math.hypot(tx - cx, ty - cy) > rad:
                        continue
                # distancia del salto (en km como en el resto de la UI)
                fx, fy = int(ev.get("from_x", 0)), int(ev.get("from_y", 0))
                hop_dist = int(math.hypot(tx - fx, ty - fy))
                out.append({
                    "uid": uid,
                    "name": ev.get("name", ""),
                    "tag":  ev.get("tag", ""),
                    "gid":  int(ev.get("gid", 0) or 0),
                    "ts":   int(ev.get("ts", 0)),
                    "age_s": int(now - ev.get("ts", 0)),
                    "from": [fx, fy], "to": [tx, ty],
                    "hop_dist": hop_dist,
                    "hops_total": len(hist),
                })
    out.sort(key=lambda r: r["ts"], reverse=True)
    return out

def timing_analysis(uid):
    """OPCIÓN B — análisis de CADENCIA de lanzamientos (game timestamps reales).
    Un humano lanza con intervalos irregulares; un script dispara a intervalo fijo.
    Devuelve None si no hay muestras suficientes. (caller debe tener LOCK)"""
    raw = ACTION_TIMES.get(uid)
    if not raw:
        return None
    ts = sorted(set(int(t) for t in raw if int(t) > 0))
    if len(ts) < 25:
        return None
    # intervalos INTRA-SESIÓN (descarta huecos largos = offline). 3s..30min.
    ivs = [b - a for a, b in zip(ts, ts[1:]) if 3 <= (b - a) <= 1800]
    n = len(ivs)
    if n < 20:
        return None
    mean = sum(ivs) / n
    var = sum((x - mean) ** 2 for x in ivs) / n
    cv = (var ** 0.5 / mean) if mean > 0 else 0.0
    # intervalo dominante: para cada valor, cuenta los que caen a ±2s (tolerancia de
    # red/segundo). Si una sola cadencia agrupa una fracción alta -> robótico.
    best_v, best_c = 0, 0
    for v in set(ivs):
        c = sum(1 for x in ivs if abs(x - v) <= 2)
        if c > best_c:
            best_c, best_v = c, v
    dom_frac = best_c / n
    # firma de script: una cadencia concreta agrupa >=35% de TODOS los intervalos, con
    # muestra amplia. (El azar humano no concentra así.)
    periodic = (n >= 25 and dom_frac >= 0.35 and best_v >= 4)
    return {"samples": n, "dominant_interval": best_v, "dominant_frac": round(dom_frac, 2),
            "cv": round(cv, 2), "periodic": periodic}

def attack_window(uid, now=None):
    """Ventana de ATAQUE óptima: la franja horaria (hora local del server) en la que el
    jugador suele estar OFFLINE — menor actividad observada — = mejor momento para pegarle
    (sin reacción/recarga de burbuja). Del histograma hod (7×24) de PLAYER_ACT.
    Devuelve {start,end,len,now_in,confidence} o None si no hay datos suficientes."""
    a = PLAYER_ACT.get(uid)
    if not a:
        return None
    hod = a.get("hod") or []
    total = int(a.get("total", 0) or 0)
    if total < 40 or len(hod) < 168:
        return None   # poca muestra -> no fiable
    hod24 = [0] * 24
    for d in range(7):
        for h in range(24):
            hod24[h] += hod[d * 24 + h]
    peak = max(hod24) or 1
    thr = peak * 0.12   # "tranquilo" = <12% de su hora pico
    quiet = [1 if hod24[h] <= thr else 0 for h in range(24)]
    if sum(quiet) >= 24 or sum(quiet) == 0:
        return None
    # franja tranquila consecutiva MÁS LARGA (circular sobre 24h)
    best_len = 0; best_start = 0; run = 0; run_start = 0
    for i in range(48):
        h = i % 24
        if quiet[h]:
            if run == 0: run_start = h
            run += 1
            if run > best_len and run <= 24:
                best_len = run; best_start = run_start
        else:
            run = 0
    if best_len < 3:
        return None
    start = best_start % 24
    end = (best_start + best_len) % 24
    now = now or time.time()
    cur_h = time.localtime(now).tm_hour
    # ¿estamos AHORA dentro de la ventana? (manejo circular)
    if start <= end:
        now_in = start <= cur_h < end if end != start else False
    else:
        now_in = cur_h >= start or cur_h < end
    # confianza por cobertura de muestra (días con datos)
    days_obs = sum(1 for d in range(7) if sum(hod[d * 24:(d + 1) * 24]) > 0)
    conf = "high" if days_obs >= 5 else ("medium" if days_obs >= 3 else "low")
    return {"start": start, "end": end, "len": best_len, "now_in": now_in, "confidence": conf}

def svs_shields_data(now=None):
    """Datos lean de burbujas enemigas (para alertas). Extraído de /api/svs_shields para
    poder agregarlo en /api/pulse y no duplicar lógica."""
    now = now or time.time()
    fresh = STALE_SECONDS * 4
    out = []
    with LOCK:
        _ours, _foreign = foreign_breakdown(now)
        enemy_sv = effective_enemy_server(_foreign)
        for p in PLAYERS.values():
            if now - p.get("ts", 0) > fresh: continue
            if not is_enemy(p, enemy_sv): continue
            uid = int(p.get("uid", 0) or 0)
            tier = int(p.get("shield", 0) or 0)
            ei = SHIELD_ETA.get(uid)
            eta = int(ei["end_time"] - now) if (ei and ei.get("end_time", 0) > now) else 0
            state = "open" if (tier == 0 and eta == 0) else ("drops_soon" if 0 < eta <= 1800 else "shielded")
            pw = int(p.get("power", 0) or 0); ppi = PLAYER_POWER.get(uid)
            if ppi and ppi.get("power", 0) > 0: pw = int(ppi["power"])
            elif MEMBER_INFO.get(uid, {}).get("power", 0) > 0: pw = int(MEMBER_INFO[uid]["power"])
            out.append({"uid": uid, "name": p.get("name", ""), "tag": p.get("tag", ""),
                        "x": int(p.get("wx", 0) or 0), "y": int(p.get("wy", 0) or 0),
                        "tier": tier, "shield_eta": eta, "state": state,
                        "powerM": round(pw / 1e6, 1), "age": int(now - p.get("ts", now))})
    out.sort(key=lambda r: (r["shield_eta"] if r["shield_eta"] > 0 else 1e12, -r["powerM"]))
    return {"rows": out, "total": len(out), "svs_active": enemy_sv > 0, "effective_enemy": enemy_sv}

def svs_relocations_data(now=None, max_age_m=120):
    """Relocations de enemigos SVS (para feed + alertas). Extraído de /api/svs_relocations."""
    now = now or time.time(); cutoff = now - max_age_m * 60
    out = []
    with LOCK:
        _ours, _foreign = foreign_breakdown(now)
        enemy_sv = effective_enemy_server(_foreign)
        pl_by_uid = {int(p.get("uid", 0) or 0): p for p in PLAYERS.values()}
        for uid, hist in RELOCATIONS.items():
            p = pl_by_uid.get(int(uid))
            if not (p and is_enemy(p, enemy_sv)):
                last = hist[-1] if hist else {}
                etag = (last.get("tag") or "").strip().lower()
                if not (etag and etag in ENEMY_GUILD_TAGS):
                    continue
            pw = 0
            if p:
                pw = int(p.get("power", 0) or 0); ppi = PLAYER_POWER.get(int(uid))
                if ppi and ppi.get("power", 0) > 0: pw = int(ppi["power"])
                elif MEMBER_INFO.get(int(uid), {}).get("power", 0) > 0: pw = int(MEMBER_INFO[int(uid)]["power"])
            for ev in hist:
                if ev.get("ts", 0) < cutoff: continue
                fx, fy = int(ev.get("from_x", 0)), int(ev.get("from_y", 0))
                tx, ty = int(ev.get("to_x", 0)), int(ev.get("to_y", 0))
                fsrv = int(ev.get("from_srv", 0) or 0); tsrv = int(ev.get("srv", 0) or 0)
                out.append({
                    "uid": int(uid), "name": ev.get("name", ""), "tag": ev.get("tag", ""),
                    "ts": int(ev.get("ts", 0)), "age": int(now - ev.get("ts", 0)),
                    "from": [fx, fy], "to": [tx, ty], "from_srv": fsrv, "to_srv": tsrv,
                    "crossed_to_us": bool(tsrv and _ours and tsrv == _ours and fsrv and fsrv != _ours),
                    "hop": int(math.hypot(tx - fx, ty - fy)) if (fsrv == tsrv or not fsrv) else 0,
                    "powerM": round(pw / 1e6, 1),
                })
    out.sort(key=lambda r: -r["ts"])
    return {"rows": out[:200], "total": len(out), "now": int(now),
            "svs_active": enemy_sv > 0, "our_server": _ours, "effective_enemy": enemy_sv}

def spawn_sightings_data(now=None, max_seen=180):
    """Summons recientes del SUMMON_LOG (event-driven: el agente los registra en el instante
    del sweep). Devuelve los frescos (<= max_seen) para la alerta; el log completo dura 15 min."""
    now = now or time.time()
    with LOCK:
        rows = [{"x": s["x"], "y": s["y"], "name": s["name"], "level": s["lv"],
                 "group": s["group"], "owner_uid": s["owner_uid"], "owner_label": s["owner_label"],
                 "age": int(now - s["ts"]), "half": s.get("half", "")}
                for s in SUMMON_LOG if (now - s["ts"]) <= max_seen]
    return {"rows": rows, "total": len(rows)}

def ares_sightings_data(now=None, max_seen=180):
    """Ares Statue (id 194) frescos en NUESTRO server (para alerta de aparición)."""
    now = now or time.time(); our_sv = our_server(); out = []
    with LOCK:
        for o in OBJS.values():
            if int(o.get("id", 0) or 0) != 194: continue
            if o.get("srv") and int(o["srv"]) != our_sv: continue
            if (now - o.get("ts", 0)) > max_seen: continue
            out.append({"x": int(o.get("wx", 0) or 0), "y": int(o.get("wy", 0) or 0),
                        "name": "Ares Statue", "level": int(o.get("lv", 0) or 0)})
    return {"rows": out, "total": len(out)}

def _gen_lbl(g):
    """Etiqueta legible de un general (de battle/scout reports)."""
    if not g: return None
    nk = (g.get("name_key", "") or "").strip()
    fid = g.get("famous_id", 0) or 0
    lbl = nk if (nk and not nk.startswith("general") and len(nk) < 40) else (f"General#{fid}" if fid else "?")
    return {"label": lbl, "star": g.get("star", 0), "power": int(g.get("power", 0) or 0)}

def player_pvp_intel(uid):
    """Ficha de combate de UN jugador: último scout (tropas/general/muro/tácticas) +
    historial de batallas que le involucran (rol, rival, power perdido). Solo hay datos
    de a quien hayamos scouteado o peleado (reports que pasan por nuestras cuentas).
    (caller debe tener LOCK)."""
    sc = SCOUT_INTEL.get(uid) or SCOUT_INTEL.get(str(uid))
    scout = None
    if sc:
        scout = {
            "ts": int(sc.get("ts", 0) or 0),
            "total_army": int(sc.get("total_army", 0) or 0),
            "total_wall": int(sc.get("total_wall", 0) or 0),
            "archertower": int(sc.get("archertower", 0) or 0),
            "def_general": _gen_lbl(sc.get("def_general")),
            "def_assistant": _gen_lbl(sc.get("def_assistant")),
            "tactics": sc.get("tactics"),
        }
    bl = []
    for b in BATTLE_LOG:
        atk = b.get("attacker") or {}; dfn = b.get("defender") or {}
        au = int(atk.get("uid", 0) or 0); du = int(dfn.get("uid", 0) or 0)
        if uid != au and uid != du: continue
        abi = b.get("atk_battle") or {}; dbi = b.get("def_battle") or {}
        role = "attacker" if au == uid else "defender"
        other = dfn if role == "attacker" else atk
        mine = abi if role == "attacker" else dbi
        theirs = dbi if role == "attacker" else abi
        bl.append({
            "ts": int(b.get("ts", 0) or 0), "role": role,
            "vs": other.get("name", "") or "", "vs_tag": other.get("tag", "") or "",
            "my_lost": int(mine.get("lost_power", 0) or 0),
            "their_lost": int(theirs.get("lost_power", 0) or 0),
            "gen": _gen_lbl(b.get("atk_general")),
        })
    bl.sort(key=lambda r: -r["ts"]); bl = bl[:10]
    if not scout and not bl:
        return None
    return {"scout": scout, "battles": bl}

def player_rally_contention(uid, win=1800, max_age_h=72):
    """Contención de rallies de ESTE jugador contra NUESTRA alianza: veces que rallea el
    mismo tile de monstruo dentro de `win` de un rally nuestro, y cuántas a la vez/después
    (= disputa/snipea). (caller debe tener LOCK)."""
    now = time.time(); cutoff = now - max_age_h * 3600
    my_guilds = {g for g in (SELF["W"]["guild_id"], SELF["E"]["guild_id"]) if g > 0}
    if not my_guilds:
        return None
    mine = [e for e in RALLY_LOG if e.get("uid") == uid and e.get("ts", 0) >= cutoff]
    if not mine:
        return None
    lan_by_tile = {}
    for e in RALLY_LOG:
        if e.get("ts", 0) >= cutoff and e.get("gid") in my_guilds:
            lan_by_tile.setdefault((e["tx"], e["ty"]), []).append(e["ts"])
    count = 0; steals = 0; ex = []; seen = set()
    for e in mine:
        tile = (e["tx"], e["ty"]); lts = lan_by_tile.get(tile)
        if not lts:
            continue
        near = [t for t in lts if abs(t - e["ts"]) <= win]
        if not near:
            continue
        o = min(near, key=lambda t: abs(t - e["ts"]))
        ck = (tile, e["ts"] // win)
        if ck in seen:
            continue
        seen.add(ck)
        steal = e["ts"] >= o
        count += 1; steals += 1 if steal else 0
        if len(ex) < 12:
            ex.append({"x": tile[0], "y": tile[1], "tname": e.get("tname", ""),
                       "lv": e.get("lv", 0), "gap": int(e["ts"] - o), "ts": int(e["ts"]), "steal": steal})
    if count == 0:
        return None
    return {"count": count, "steals": steals, "window_m": win // 60, "examples": ex}

def cheat_analysis(uid, now=None):
    """Análisis de ANOMALÍAS CONDUCTUALES de un jugador, a partir de datos OBSERVADOS
    pasivamente por el escáner (PLAYER_ACT: marchas/relocations/shields a lo largo del
    tiempo). Es evidencia circunstancial de AUTOMATIZACIÓN (bot/script/cuenta 24-7),
    NO una prueba definitiva — eso lo tiene Evony en sus logs. Devuelve métricas + flags
    factuales + un score, para generar un report escalable. (caller debe tener LOCK)"""
    now = now or time.time()
    a = PLAYER_ACT.get(uid)
    if not a or not int(a.get("total", 0) or 0):
        return None
    hod = list(a.get("hod") or [0] * 168)
    total = int(a.get("total", 0) or 0)
    by_type = dict(a.get("by_type", {}) or {})
    first = float(a.get("first", 0) or 0); last = float(a.get("last", 0) or 0)
    span_days = max((last - first) / 86400.0, 0.04)
    per_day = total / span_days
    hod24 = [0] * 24
    for d in range(7):
        for h in range(24):
            hod24[h] += hod[d * 24 + h]
    hours_covered = sum(1 for h in range(24) if hod24[h] > 0)
    week_hours = sum(1 for s in hod if s > 0)
    def _gap(row):
        # mayor ventana de silencio consecutiva (circular 24h) de una fila de 24h
        if not any(row): return 24
        z = [1 if row[h] == 0 else 0 for h in range(24)] * 2
        g = 0; run = 0
        for v in z:
            run = run + 1 if v else 0
            g = max(g, run)
        return min(g, 24)
    longest_quiet = _gap(hod24)   # plegado (solo display)
    # ANÁLISIS POR DÍA (riguroso): un humano tiene una ventana de sueño >=4h CASI todos
    # los días; un bot 24/7 no la tiene NINGÚN día. Solo días realmente observados.
    day_gaps = [_gap(hod[d*24:(d+1)*24]) for d in range(7) if sum(hod[d*24:(d+1)*24]) > 0]
    days_observed = len(day_gaps)
    days_no_sleep = sum(1 for g in day_gaps if g < 4)   # días SIN pausa real de 4h
    relocate = int(by_type.get("relocate", 0) or 0)   # contador legacy (cumulativo)
    march = int(by_type.get("march", 0) or 0)
    # TELEPORT LIMPIO: desde la lista server-taggeada, SOLO movimientos del MISMO server
    # (excluye los saltos artificiales 1939<->1954 del SVS). Rate sobre su propio lapso.
    rl = RELOCATIONS.get(uid, []) or []
    # SOLO eventos con server VERIFICADO y del MISMO server (ambos tags >0 e iguales).
    # Excluye legacy sin tag (srv=0) y los flips cross-server del SVS.
    clean_rel = [ev for ev in rl
                 if int(ev.get("from_srv", 0) or 0) > 0 and int(ev.get("srv", 0) or 0) > 0
                 and int(ev.get("from_srv", 0)) == int(ev.get("srv", 0))]
    clean_n = len(clean_rel)
    # Tel/day = relocations (mismo server) por DÍA DE OBSERVACIÓN del jugador (span_days, su
    # ventana real first..last, ya >=0.04). Antes se dividía por el lapso ENTRE relocations y se
    # exigía >=1 día -> como casi siempre ocurren en <1 día, salía 0 (la columna "no funcionaba").
    # Guard: >=2 eventos y >=0.5 días observados, para no inflar por ráfagas en ventanas cortas.
    clean_rate = round(clean_n / span_days, 1) if (clean_n >= 2 and span_days >= 0.5) else 0.0
    # UNIFORMIDAD HORARIA: un humano concentra su juego en horas pico; un script lo
    # reparte plano por casi todas las horas. share = % de acciones en la hora más activa.
    busiest = max(hod24) if hod24 else 0
    busiest_share = (busiest / total) if total else 1.0
    flags = []; score = 0
    # 1) Sin ventana de sueño la GRAN MAYORÍA de días (señal fuerte, por-día)
    if total >= 150 and days_observed >= 3 and days_no_sleep >= max(3, round(0.8 * days_observed)):
        flags.append({"key": "no_sleep", "text":
            f"No daily offline/sleep window on {days_no_sleep} of {days_observed} observed days "
            f"(no 4h+ continuous gap). Human players have a 6-8h daily offline window almost every day."})
        score += 55
    # 2) Teleport-script (rate LIMPIO, mismo server)
    if clean_n >= 8 and clean_rate >= 8:
        flags.append({"key": "teleport", "text":
            f"{clean_n} same-server castle relocations at ~{clean_rate:.0f}/day (cross-server SVS artifacts excluded) "
            f"— far above normal teleport-item usage, consistent with a relocation script."})
        score += 25
    # 3) Perfil horario plano (script reparte uniforme)
    if total >= 300 and hours_covered >= 22 and busiest_share <= 0.07:
        flags.append({"key": "uniform", "text":
            f"Activity spread uniformly across {hours_covered}/24 hours (busiest hour only "
            f"{busiest_share*100:.0f}% of all actions) — humans concentrate on peak hours; a flat 24h profile is script-like."})
        score += 15
    # 4) Volumen sostenido muy alto
    if per_day >= 600:
        flags.append({"key": "volume", "text":
            f"{total} observed actions over {span_days:.1f} days ({per_day:.0f}/day) — "
            f"sustained action rate above typical manual play."})
        score += 10
    # 5) Presencia casi 24/7 (soporte menor)
    pct = round(week_hours / 168 * 100)
    if week_hours / 168 >= 0.80:
        flags.append({"key": "always_on", "text":
            f"Active in {week_hours}/168 week-hours ({pct}%) — near round-the-clock presence."})
        score += 5
    # 6) OPCIÓN B — CADENCIA ROBÓTICA (la más fuerte: difícil de fingir a mano)
    timing = timing_analysis(uid)
    if timing and timing.get("periodic"):
        flags.append({"key": "timing", "text":
            f"Machine-regular launch cadence: {int(timing['dominant_frac']*100)}% of {timing['samples']} "
            f"observed march launches are spaced ~{timing['dominant_interval']}s apart "
            f"(interval CV {timing['cv']}). Human launch timing is irregular — this is a scripted timer signature."})
        score += 50
    # 7) SPEED-HACK: velocidad de marcha por encima del máximo físico (no alcanzable ni
    #    con todos los buffs apilados). Evidencia casi-irrefutable.
    spd = SPEED_STATS.get(uid)
    if spd and spd.get("max", 0) >= IMPOSSIBLE_SPEED:
        ex = (spd.get("samples") or [{}])[0]
        flags.append({"key": "speed", "text":
            f"Impossible march speed: a march covered {ex.get('dist','?')} tiles in {ex.get('dur','?')}s "
            f"(~{spd['max']} tiles/s). This exceeds the maximum reachable even with all march-speed buffs "
            f"stacked — a hard signature of march/speed manipulation."})
        score += 60
    level = "high" if score >= 60 else ("medium" if score >= 35 else "low")
    return {
        "uid": uid, "total": total, "by_type": by_type,
        "span_days": round(span_days, 1), "per_day": round(per_day),
        "hours_covered": hours_covered, "week_hours": week_hours,
        "longest_quiet_h": longest_quiet, "relocate": relocate, "march": march,
        "days_observed": days_observed, "days_no_sleep": days_no_sleep,
        "clean_reloc": clean_n, "clean_reloc_rate": round(clean_rate, 1),
        "busiest_share": round(busiest_share, 3), "timing": timing,
        "max_speed": (SPEED_STATS.get(uid) or {}).get("max", 0),
        "speed_samples": (SPEED_STATS.get(uid) or {}).get("samples", []),
        "active_hours": [h for h in range(24) if hod24[h] > 0],
        "flags": flags, "score": score, "level": level,
        "first": int(first), "last": int(last),
    }

def families(max_seen=None, only_type="", want_summon=False):
    """[{name, variants, live}] — variants=tiers en config, live=cuantos en el mapa
    según el mismo filtro de max_seen que /api/data. Sin esto, el pill cuenta el
    cache acumulado de 24h (persistencia) mientras la tabla solo muestra los del
    último ciclo (default STALE_SECONDS=90s), causando discrepancia visual.
    only_type/want_summon: MISMO filtro que la tabla /api/data (load()). Por defecto la
    tabla del tab Monsters usa type=2 (Monsters); sin este filtro el pill contaba TODOS
    los tipos (p.ej. Warlords INVOCADOS con t!=2) y la tabla -filtrada a type=2- mostraba
    0 -> "el pill dice 7 pero al pulsar no sale nada". Con esto el pill == la lista."""
    cat = catalog()
    # 2026-08-12: max_seen None -> _onmap_ok aplica el SPLIT (300/900), coherente con /api/data.
    now = time.time()
    our_sv = our_server()
    with LOCK:
        objs = [o for o in OBJS.values()
                if _onmap_ok(o, now, max_seen)
                and not (o.get("srv") and int(o["srv"]) != our_sv)]   # excluir server enemigo
    live = {}
    for o in objs:
        if only_type and str(o.get("t")) != only_type:   # mismo filtro de tipo que la tabla
            continue
        c = CFG.get(str(o["id"])) or {}
        nm = c.get("name", "")
        if not nm:
            continue
        if want_summon and not (int(o.get("own", 0) or 0) >= SUMMON_OWNER_MIN and group_of(nm) == "Event"):
            continue   # "Show only Summons": mismo criterio que query()
        for disp, kws, grp in FAMILIES:
            if fam_match(nm, kws):
                live[disp] = live.get(disp, 0) + 1
    out = []
    for disp, kws, grp in FAMILIES:
        v = sum(1 for x in cat if fam_match(x["name"], kws))
        if v:
            out.append({"name": disp, "variants": v, "live": live.get(disp, 0), "group": grp})
    return out

_ID2FAM = {}          # id_str -> (family_disp, group, config_level). Memo: CFG es estático (se carga al arrancar).
_ID2FAM_N = 0
def _id2fam():
    """Map memoizado config_id -> (familia, grupo, nivel). Evita el fam_match O(objetos x familias) por request."""
    global _ID2FAM, _ID2FAM_N
    if _ID2FAM and _ID2FAM_N == len(CFG): return _ID2FAM
    m = {}
    for cid, c in CFG.items():
        nm = (c.get("name") or "")
        if not nm: continue
        for disp, kws, grp in FAMILIES:
            if fam_match(nm, kws): m[str(cid)] = (disp, grp, int(c.get("level", 0) or 0)); break
    _ID2FAM = m; _ID2FAM_N = len(CFG); return m

def _onmap_ok(o, now, max_seen=None):
    """¿el objeto sigue 'en el mapa' según su frescura? (2026-08-12)
    - Si max_seen se pasa EXPLÍCITO (p.ej. el bot con &max_seen=…): ventana única.
    - Si no (default de la vista): SPLIT POR GRUPO como el bot pero más suave —
      Boss/Event = ONMAP_RALLY (2700s), resto = ONMAP_FARM (300s).
    El grupo se saca de _id2fam() (map memoizado id->grupo, O(1))."""
    age = now - (o.get("ts") or 0)
    if max_seen is not None:
        return age <= max_seen
    f = _id2fam().get(str(o.get("id")))
    grp = (f[1] if f else "").lower()   # ⚠️ FAMILIES/_id2fam usan minúscula ("boss"/"event"); comparar en minúscula
    return age <= (ONMAP_RALLY if grp in RALLY_GROUPS_ONMAP else ONMAP_FARM)

QUICK_HIDE = {"Viking"}   # familias OCULTAS del menú de Shortcuts (no del escáner): el usuario no las quiere como shortcut
_QUICK_CACHE = {"ts": 0.0, "data": None, "ms": None}   # resultado cacheado (TTL 15s): /api/quick se pulsa seguido
def quick_families(max_seen=None):
    """Tab 'Shortcuts': por FAMILIA, niveles de config + conteo live por nivel (colorear botones Lvx). ADITIVO/solo lectura.
    RAPIDO: map memoizado id->familia (O(1) por objeto, sin fam_match) + snapshot minimo bajo LOCK + cache de resultado 15s."""
    # 2026-08-12: max_seen None -> _onmap_ok aplica el SPLIT (300/900).
    now = time.time(); cc = _QUICK_CACHE
    if cc["data"] is not None and cc["ms"] == max_seen and (now - cc["ts"]) < 15:
        return cc["data"]
    id2fam = _id2fam(); our_sv = our_server()
    cfg_levels = {}   # disp -> set(niveles de config)
    for disp, grp, lv in id2fam.values():
        if lv > 0: cfg_levels.setdefault(disp, set()).add(lv)
    with LOCK:   # snapshot MINIMO + filtrado bajo LOCK -> minimiza contencion con los threads de scan
        snap = [(o.get("id"), o.get("lv")) for o in OBJS.values()
                if o.get("ts") is not None and _onmap_ok(o, now, max_seen)
                and not (o.get("srv") and int(o["srv"]) != our_sv)]
    live = {}   # (disp, lv) -> count on-map
    for oid, olv in snap:   # O(objetos) con lookup O(1) — nada de fam_match aqui
        f = id2fam.get(str(oid))
        if not f: continue
        disp, grp, clv = f
        lv = int(olv or 0) or clv
        live[(disp, lv)] = live.get((disp, lv), 0) + 1
    out = []
    for disp, kws, grp in FAMILIES:
        if disp in QUICK_HIDE: continue   # p.ej. Viking: oculto del menú de Shortcuts
        lvls = sorted(l for l in cfg_levels.get(disp, ()) if l > 0)
        if not lvls: continue
        levels = [{"lv": lv, "count": live.get((disp, lv), 0)} for lv in lvls]
        out.append({"name": disp, "group": grp, "levels": levels, "live": sum(l["count"] for l in levels)})
    cc["ts"] = now; cc["data"] = out; cc["ms"] = max_seen
    return out

def fam_ids(selected_fams):
    """ids de config cuyos nombres caen en alguna familia seleccionada."""
    allkw = []
    for disp, kws, grp in FAMILIES:
        if disp in selected_fams:
            allkw += kws
    if not allkw:
        return set()
    ids = set()
    with LOCK:
        for cid, c in CFG.items():
            if fam_match(c.get("name", ""), allkw):
                ids.add(int(cid))
    return ids

def catalog():
    """Lista de configs de rally-boss: [{id,name,level,type,power,label}] ordenada."""
    with LOCK:
        items = []
        for cid, c in CFG.items():
            nm = c.get("name", "")
            if not nm or not is_boss(nm):
                continue
            lv = c.get("level", 0)
            items.append({
                "id": int(cid), "name": nm, "level": lv,
                "type": c.get("type", 0), "power": c.get("power", 0),
                "label": f"{nm} {lv}" if lv else nm,
            })
    items.sort(key=lambda r: (r["name"], r["level"]))
    return items

def query(params):
    ids = set()
    for s in params.get("ids", "").split(","):
        s = s.strip()
        if s.isdigit():
            ids.add(int(s))
    fams = [f for f in params.get("fams", "").split("||") if f]
    if fams:
        ids |= fam_ids(set(fams))
    try: cx = float(params.get("cx", "")) if params.get("cx") else None
    except: cx = None
    try: cy = float(params.get("cy", "")) if params.get("cy") else None
    except: cy = None
    try: rad = float(params.get("radius", "")) if params.get("radius") else None
    except: rad = None
    only_type = params.get("type", "")        # "" o "2" etc
    want_summon = params.get("summon", "") == "1"   # "Show only Summons": monstruo con nombre + owner>=SUMMON_OWNER_MIN
    sort = params.get("sort", "newest")
    # NOTA: query() devuelve la lista COMPLETA ordenada/filtrada. El paginado
    # (limit + page) lo hace el handler HTTP /api/data, no esta funcion.
    # max_seen: objetos vistos por ultima vez hace <= N seg.
    # 2026-08-12: sin max_seen explícito (None) -> _onmap_ok aplica el SPLIT por grupo:
    #   Boss/Event = ONMAP_RALLY (900s) · resto = ONMAP_FARM (300s). Antes era un único ONMAP_SECONDS.
    try: max_seen = float(params["max_seen"]) if params.get("max_seen") else None
    except: max_seen = None

    # PERF: filtrar sobre el objeto CRUDO (barato) y enriquecer SOLO los que pasan.
    # enrich() llama group_of() (~70 familias) + lookups de marchas/players → caro.
    # Antes se enriquecían los ~154k objetos en cada query (1-2s de lag al clicar un
    # pill). Los campos de filtro mapean 1:1 con el raw (t/id/ts/wx/wy), así que el
    # resultado es idéntico pero enrich corre solo sobre el subconjunto filtrado.
    now = time.time()
    our_sv = our_server()
    out = []
    with LOCK:
        for o in OBJS.values():
            # SVS: excluir monstruos vistos en el server ENEMIGO (escáner en 1954).
            # Solo mostramos los de NUESTRO server.
            if o.get("srv") and int(o["srv"]) != our_sv:
                continue
            if only_type and str(o.get("t")) != only_type:
                continue
            if want_summon and not (int(o.get("own", 0) or 0) >= SUMMON_OWNER_MIN
                                    and group_of((CFG.get(str(o.get("id"))) or {}).get("name", "")) == "Event"):
                continue
            if ids and o.get("id") not in ids:
                continue
            if not _onmap_ok(o, now, max_seen):
                continue
            if cx is not None and cy is not None:
                d = math.hypot(o.get("wx", 0) - cx, o.get("wy", 0) - cy)
                if rad is not None and d > rad:
                    continue
                r = enrich(o); r["dist"] = round(d)
            else:
                r = enrich(o); r["dist"] = None
            out.append(r)

    # SORT COMPUESTO (AND):
    #   sort/order   = criterio PRIMARIO  (la columna clicada en el frontend)
    #   sort2/order2 = criterio SECUNDARIO de desempate (el dropdown "Sort")
    # Si sort2 está vacío o coincide con sort, se ordena solo por el primario.
    # "desc" = orden natural (level/power grande primero; dist más cerca primero;
    # newest más reciente primero). "asc" lo invierte.
    def _comp(field, ordr):
        # devuelve una función que mapea cada fila a un valor ASCENDENTE-ordenable,
        # de modo que el orden natural de Python (ascendente) reproduzca 'desc'/'asc'.
        asc = (ordr == "asc")
        if field == "level":
            return (lambda r: r["level"]) if asc else (lambda r: -r["level"])
        if field == "power":
            return (lambda r: r["power"]) if asc else (lambda r: -r["power"])
        if field == "dist":
            # desc natural = más cerca primero (dist ascendente). asc = más lejos.
            # los None (sin centro/distancia) van siempre al final.
            return (lambda r: -(r["dist"] if r["dist"] is not None else -9e9)) if asc \
                   else (lambda r: (r["dist"] if r["dist"] is not None else 9e9))
        if field == "newest":
            # desc natural = más reciente primero (age ascendente). asc = más viejo.
            return (lambda r: -r["age"]) if asc else (lambda r: r["age"])
        return (lambda r: 0)

    order  = (params.get("order", "desc") or "desc").lower()
    sort2  = params.get("sort2", "") or ""
    order2 = (params.get("order2", "desc") or "desc").lower()
    if sort2 == "dist" and cx is None:
        sort2 = ""                       # sin centro no hay distancia para desempatar
    prim = _comp(sort, order)
    if sort2 and sort2 != sort:
        sec = _comp(sort2, order2)
        out.sort(key=lambda r: (prim(r), sec(r), r["name"]))
    else:
        out.sort(key=lambda r: (prim(r), r["name"]))
    return out

def names_list():
    with LOCK:
        seen = {}
        for o in OBJS.values():
            c = CFG.get(str(o["id"])) or {}
            nm = c.get("name", f"id{o['id']}")
            seen[nm] = seen.get(nm, 0) + 1
    return sorted(seen.items(), key=lambda x: (-x[1], x[0]))

def query_players(params):
    now = time.time()
    def num(k):
        v = params.get(k, "")
        try: return float(v) if v not in ("", None) else None
        except: return None
    lv_min = num("lv_min"); lv_max = num("lv_max")
    pw_min = num("pw_min"); pw_max = num("pw_max")   # en M (millones)
    shield = params.get("shield", "")                # "", "yes", "no"
    tag = params.get("tag", "").strip().lower()
    # Buscador libre: match parcial case-insensitive contra name/uid/tag
    # Si es un numero, tambien intenta match exacto contra uid.
    q = params.get("q", "").strip().lower()
    q_int = None
    if q:
        try: q_int = int(q)
        except: pass
    cx, cy, rad = num("cx"), num("cy"), num("radius")
    sort = params.get("sort", "power")
    limit = int(params.get("limit", "300"))
    max_seen = num("max_seen") or TTL_SECONDS
    with LOCK:
        ps = list(PLAYERS.values())
    out = []
    for p in ps:
        if now - p.get("ts", 0) > max_seen:
            continue
        lv = p.get("lv", 0)
        # power: el broadcast lleva 0; preferimos PLAYER_POWER[uid] (de power_rank_reply).
        pw_src = ""; pw_ts = 0
        pw = int(p.get("power", 0) or 0)
        pp_info = PLAYER_POWER.get(p["uid"])
        if pp_info and pp_info.get("power", 0) > 0:
            pw = int(pp_info["power"])
            pw_src = "power_rank"
            pw_ts = int(pp_info.get("ts", 0) or 0)   # cuándo se capturó del ranking (para mostrar antigüedad)
        elif pw > 0:
            pw_src = "broadcast"
        pwM = pw / 1e6
        has_shield = bool(p.get("shield", 0))
        if lv_min is not None and lv < lv_min: continue
        if lv_max is not None and lv > lv_max: continue
        if pw_min is not None and pwM < pw_min: continue
        if pw_max is not None and pwM > pw_max: continue
        if shield == "yes" and not has_shield: continue
        if shield == "no" and has_shield: continue
        if tag and tag not in (p.get("tag", "") or "").lower(): continue
        # Search libre: match si q aparece en name (parcial), tag (parcial), o uid (exacto si q es entero)
        if q:
            nm_lo = (p.get("name", "") or "").lower()
            tg_lo = (p.get("tag", "") or "").lower()
            uid_match = (q_int is not None and int(p.get("uid", 0)) == q_int)
            if not (q in nm_lo or q in tg_lo or uid_match):
                continue
        d = None
        if cx is not None and cy is not None:
            d = math.hypot(p.get("wx", 0) - cx, p.get("wy", 0) - cy)   # M6: wx/wy defensivo
            if rad is not None and d > rad: continue
            d = round(d)
        # ETA exacta de burbuja. Tres caches posibles, por prioridad:
        #  1) SHIELD_ETA[uid]               -> match directo (subcity / scout / psr ya matcheado)
        #  2) SHIELD_ETA["coord:wx,wy"]     -> psr capturado antes de tener uid en PLAYERS
        #     (al encontrar uid ahora, migramos el cache de coord a uid)
        sh_eta = 0; sh_src = ""
        eta_info = SHIELD_ETA.get(p["uid"])
        ckey = f"coord:{p.get('wx', 0)},{p.get('wy', 0)}"   # M6: wx/wy defensivo
        coord_info = SHIELD_ETA.get(ckey)
        if coord_info is not None:
            # Fusión coord -> uid. Ahora que conocemos el uid, decidimos cuál gana:
            #  - si no hay entrada por uid: migra la de coord (sea cual sea su confidence)
            #  - si la de uid NO es exact pero la de coord SÍ es exact y está viva:
            #    la EXACTA gana (corrige el caso RussC: inferred_undershot en uid +
            #    scout/psr exact guardado por coord que nunca se fusionaba).
            coord_exact_live = (coord_info.get("confidence") == "exact"
                                and int(coord_info.get("end_time", 0) or 0) > int(now))
            if eta_info is None or (eta_info.get("confidence") != "exact" and coord_exact_live):
                # preserva activation_ts previo si la entrada exacta no lo trae
                if eta_info is not None and not coord_info.get("activation_ts"):
                    coord_info["activation_ts"] = int(eta_info.get("activation_ts", 0) or 0)
                with LOCK: SHIELD_ETA[p["uid"]] = coord_info   # ALTO#3: mutar SHIELD_ETA bajo LOCK (prune_thread y /api/shields lo ITERAN bajo LOCK -> sin esto, "dict changed size during iteration")
                eta_info = coord_info
            # en cualquier caso la entrada por coord ya cumplió su función: la retiramos
            with LOCK: SHIELD_ETA.pop(ckey, None)   # ALTO#3: mutacion bajo LOCK
        sh_confidence = ""; sh_activation_ts = 0
        # Sanity check: broadcast tier es ground truth. Si tier=0 + confidence!=exact, descartamos eta.
        # Esto cubre el caso de cache stale donde no hubo transition que invalidara la entry.
        if eta_info and int(p.get("shield", 0) or 0) == 0 and eta_info.get("confidence") != "exact":
            with LOCK: SHIELD_ETA.pop(p["uid"], None)   # ALTO#3: mutacion bajo LOCK
            eta_info = None
        if eta_info:
            et = int(eta_info.get("end_time", 0) or 0)
            if et > int(now):
                sh_eta = et - int(now)
                sh_src = eta_info.get("src", "")
                sh_confidence = eta_info.get("confidence", "")
                sh_activation_ts = int(eta_info.get("activation_ts", 0) or 0)
            else:
                # caducado -> limpia lazy
                with LOCK: SHIELD_ETA.pop(p["uid"], None)   # ALTO#3: mutacion bajo LOCK
        out.append({
            "name": p.get("name", ""), "level": lv, "castle": p.get("clv", 0),
            "power": pw, "power_src": pw_src, "power_ts": pw_ts, "tag": p.get("tag", ""),
            "shield": int(p.get("shield", 0) or 0),     # tier code (0=ninguno, 1=item, 2=newbie)
            "shield_eta": sh_eta,                       # >0 = segundos restantes; 0 desconocida
            "shield_src": sh_src,                       # "subcity" | "scout" | "tier_transition" | ...
            "shield_confidence": sh_confidence,         # "exact" | "inferred_default" | "inferred_history" | "newbie_inferred"
            "shield_activation_ts": sh_activation_ts,   # unix ts de cuando se activo (si conocido)
            "shield_history_n": len(SHIELD_HISTORY.get(p["uid"], [])),  # cuantas muestras de historico hay
            "x": p.get("wx", 0), "y": p.get("wy", 0), "uid": p["uid"], "dist": d,   # M6: wx/wy defensivo (uid es la clave de PLAYERS -> siempre presente)
            "age": int(now - p.get("ts", now)),
            "gid": int(p.get("gid", 0) or 0),
            "member_lastseen": MEMBER_LASTSEEN.get(p["uid"]),  # None/0/1=online, epoch=ultimo visto (alliance member-list)
        })
    if sort == "power":   out.sort(key=lambda r: -r["power"])
    elif sort == "level": out.sort(key=lambda r: (-r["level"], -r["power"]))
    elif sort == "dist":  out.sort(key=lambda r: (r["dist"] if r["dist"] is not None else 9e9))
    elif sort == "newest":out.sort(key=lambda r: r["age"])
    elif sort == "eta":
        # ascendente por ETA: los que tienen burbuja exacta (eta>0) primero (los que
        # expiran antes a la cabeza); el resto al final. Util para anticipar ataques.
        out.sort(key=lambda r: (r.get("shield_eta", 0) <= 0, r.get("shield_eta", 0) or 9e9))
    return out[:limit]

# ---------------- HTTP ----------------
# README / tutorial (tab "README"): documento HTML autónomo servido en /readme y
# embebido en un <iframe> desde la UI (aísla su CSS/JS del escáner). Se carga UNA vez
# al arrancar; para actualizarlo basta con reemplazar readme.html y reiniciar el backend.
def _load_readme():
    try:
        with open(os.path.join(HERE, "readme.html"), "r", encoding="utf-8") as _f:
            body = _f.read()
        if "<!doctype" not in body[:200].lower():
            body = "<!doctype html>\n" + body
        return body
    except Exception as _e:
        return ("<!doctype html><meta charset=utf-8>"
                "<body style='font-family:system-ui;background:#0b1220;color:#e7eef8;padding:40px'>"
                "<h2>README not available</h2><p>readme.html was not found on the server.</p></body>")
README_HTML = _load_readme()

HTML = """<!doctype html><html><head><meta charset=utf-8>
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=5, user-scalable=yes">
<meta name="theme-color" content="#0f1115">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<title>Scout V5</title><style>
body{font-family:-apple-system,system-ui,sans-serif;margin:0;background:#0f1115;color:#e6e6e6}
header{padding:14px 18px;background:#161a22;border-bottom:1px solid #262b36;display:flex;gap:18px;align-items:center;flex-wrap:wrap}
h1{font-size:16px;margin:0;color:#7dd3fc}
.box{padding:12px 18px;display:flex;gap:22px;flex-wrap:wrap;align-items:flex-end;background:#12151c;border-bottom:1px solid #262b36}
.qpad{padding-left:18px}
.filt-toggle{display:none}  /* solo visible/colapsable en móvil (ver media query) */
.d-short{display:none}      /* "Discovered" compacto: oculto en desktop, visible en móvil */
.cur-tab{display:none}      /* nombre del tab actual: solo en móvil (ver media query) */
/* Pastillas de estado de los escáneres en la CABECERA (solo SuperAdmin). El color lo pone
   hdrScanRefresh(): verde=escaneando, ámbar=recuperándose/pausado, rojo=parado o sin objetos. */
.hdr-sc{font-size:10px;font-weight:800;letter-spacing:.04em;padding:2px 6px;border-radius:5px;
        border:1px solid #334155;background:#0f172a;color:#64748b;cursor:default;line-height:1.5}
label{font-size:11px;color:#9aa4b2;display:block;margin-bottom:4px}
input,select{background:#0f1115;border:1px solid #2c3340;color:#e6e6e6;padding:6px 8px;border-radius:6px;font-size:13px}
input[type=number]{width:80px}
button{background:#2563eb;color:#fff;border:0;padding:8px 16px;border-radius:6px;cursor:pointer;font-size:13px}
button.sec{background:#374151}
table{width:100%;border-collapse:collapse;font-size:13px}
th,td{text-align:left;padding:8px 14px;border-bottom:1px solid #1f2530}
/* Respawns: cuando la fila está resaltada (overdue rojo / <2min verde), el divisor
   se ve blanco suave para diferenciarse del fondo de color. Aplica en desktop y móvil. */
#rs_rows tr.rs-hot td{border-bottom-color:rgba(255,255,255,0.5)}
#rs_rows tr.rs-hot{border-bottom-color:rgba(255,255,255,0.5)}
th{color:#9aa4b2;font-weight:600;position:sticky;top:0;background:#12151c}
tr:hover{background:#171b24}
.lvl{color:#fbbf24;font-weight:600}
.cp{cursor:pointer;color:#60a5fa;font-size:12px}
.stat{font-size:12px;color:#9aa4b2}
.tag{background:#1e293b;color:#93c5fd;padding:1px 6px;border-radius:4px;font-size:11px}
.grp{padding:1px 8px;border-radius:4px;font-size:11px;font-weight:600}
.g-boss{background:#7f1d1d;color:#fecaca}
.g-event{background:#1e3a8a;color:#bfdbfe}
.g-shadow{background:#4c1d95;color:#ddd6fe}
.g-other{background:#374151;color:#fcd34d}
.g-normal{background:#1f2937;color:#9aa4b2}
.atk{padding:1px 8px;border-radius:4px;font-size:11px;font-weight:600;cursor:help}
.atk-rally{background:#7f1d1d;color:#fecaca}
.atk-damaged{background:#78350f;color:#fde68a}
.atk-owned{background:#1f2937;color:#93c5fd;font-weight:500}
/* toggle iOS-style para mostrar/ocultar el panel de ataques */
.atktoggle{display:flex;gap:12px;align-items:center;padding:10px 18px;background:#12151c;border-top:1px solid #262b36}
.atkLbl{font-size:13px;color:#fca5a5;font-weight:600;text-transform:uppercase;letter-spacing:.5px}
.atkLbl .atkIcon{font-size:18px;vertical-align:-2px;margin-right:4px}
th.sortableH{cursor:pointer;user-select:none;transition:background .12s}
th.sortableH:hover{background:#1f2937}
th.sortableH.active{color:#7dd3fc}
th.sortableH .arrow{font-size:10px;opacity:.5;margin-left:3px}
th.sortableH.active .arrow{opacity:1}
.ios-switch{position:relative;display:inline-block;width:42px;height:24px;flex:0 0 auto}
.ios-switch input{opacity:0;width:0;height:0}
.ios-switch .slider{position:absolute;cursor:pointer;inset:0;background:#374151;border-radius:24px;transition:.2s}
.ios-switch .slider:before{position:absolute;content:"";width:18px;height:18px;left:3px;top:3px;background:#fff;border-radius:50%;transition:.2s;box-shadow:0 1px 2px rgba(0,0,0,0.3)}
.ios-switch input:checked + .slider{background:#dc2626}
.ios-switch input:checked + .slider:before{transform:translateX(18px)}
/* panel de ataques */
#atkpanel{background:#0d0f15;border-bottom:2px solid #7f1d1d;border-top:1px solid #262b36;padding:0}
.atkhdr{display:flex;align-items:center;gap:14px;padding:10px 18px;background:#12151c;border-bottom:1px solid #262b36}
.atktitle{display:none}   /* "⚔ Active attacks" oculto: redundante con el toggle */
.atktbl{width:100%;border-collapse:collapse;font-size:12px}
.atktbl th{background:#161a22;color:#9aa4b2;font-weight:600;padding:6px 12px;text-align:left;border-bottom:1px solid #1f2530;font-size:11px}
.atktbl td{padding:6px 12px;border-bottom:1px solid #1a1f29}
.atktbl tr:hover td{background:#161a22}
.ph{padding:1px 7px;border-radius:4px;font-size:10px;font-weight:700;letter-spacing:.3px}
.ph-wait{background:#78350f;color:#fde68a}
.ph-way{background:#9a3412;color:#fed7aa}
.ph-combat{background:#7f1d1d;color:#fecaca}
.ph-return{background:#1e3a5f;color:#bfdbfe}
.ph-march,.ph-scout{background:#1f2937;color:#93c5fd}
.eta-val{font-family:ui-monospace,SFMono-Regular,monospace;font-weight:700;font-size:13px}
.eta-soon{color:#fca5a5}
tr.eta-imminent td{animation:atkblink 0.6s ease-in-out infinite}
tr.eta-imminent .eta-val{color:#fff;background:#7f1d1d;padding:2px 6px;border-radius:4px}
tr.atk-solo-new td{animation:atksoloblink 0.6s ease-in-out infinite}
@keyframes atksoloblink{0%,100%{background:rgba(59,130,246,0.34)} 50%{background:rgba(59,130,246,0.06)}}
/* multi-select chips */
.ms{position:relative;min-width:340px}
.msbox{display:flex;flex-wrap:wrap;gap:5px;align-items:center;background:#0f1115;border:1px solid #2c3340;border-radius:6px;padding:5px 6px;min-height:34px;cursor:text}
.chip{background:#312e81;color:#c7d2fe;border-radius:5px;padding:2px 6px;font-size:12px;display:flex;gap:5px;align-items:center}
.chip b{cursor:pointer;color:#a5b4fc}
.msbox input{border:0;background:transparent;flex:1;min-width:80px;padding:3px;color:#e6e6e6;outline:none}
.dd{position:absolute;z-index:9;left:0;right:0;top:100%;margin-top:3px;background:#12151c;border:1px solid #2c3340;border-radius:6px;max-height:280px;overflow:auto;display:none}
.dd.open{display:block}
.opt{padding:7px 10px;font-size:13px;cursor:pointer}
.opt:hover,.opt.act{background:#1e293b}
.opt small{color:#7c8595}
.fams{display:flex;flex-wrap:wrap;gap:6px;padding:10px 18px;background:#12151c;border-bottom:1px solid #262b36}
.fam{background:#1f2937;color:#cbd5e1;border:1px solid #2c3340;border-radius:14px;padding:5px 12px;font-size:12px;cursor:pointer;user-select:none}
.fam:hover{background:#283449}
.fam.on{background:#2563eb;color:#fff;border-color:#2563eb}
.fam.z{opacity:.4}
.famgrp{padding:8px 18px 0;background:#12151c}
.famgrp h3{margin:0 0 6px;font-size:11px;color:#7dd3fc;text-transform:uppercase;letter-spacing:.5px}
.famhdr{cursor:pointer;user-select:none}
.fcaret{display:inline-block;width:16px;height:16px;line-height:14px;text-align:center;font-weight:bold;font-size:14px;border:1px solid #475569;border-radius:3px;color:#e2e8f0;margin-right:6px;vertical-align:middle}
.famrow{display:flex;flex-wrap:wrap;gap:10px;padding:20px 20px}
tr.fresh td{background:rgba(255,235,59,0.10)}
tr.atk-active td{background:rgba(220,38,38,0.20); animation:atkblink 1.6s ease-in-out infinite}
@keyframes atkblink{0%,100%{background:rgba(220,38,38,0.20)} 50%{background:rgba(220,38,38,0.06)}}
tr.cfgloading td{padding:40px 18px;text-align:center;color:#fbbf24;font-size:14px;background:rgba(120,53,15,0.15);font-weight:500;line-height:1.6}
tr.cfgloading td .hint{font-size:12px;color:#9aa4b2;font-weight:400}
.sortbar{display:flex;gap:8px;align-items:center;padding:10px 18px;background:#12151c;border-bottom:1px solid #262b36}
.sortbar label{margin:0;font-size:12px;color:#9aa4b2}
.pager{display:flex;gap:10px;align-items:center;justify-content:center;padding:10px 18px;background:#12151c;border-top:1px solid #262b36;font-size:12px;color:#9aa4b2}
.pager button{background:#374151;color:#cbd5e1;border:0;padding:5px 12px;border-radius:5px;cursor:pointer;font-size:12px}
.pager button:disabled{opacity:0.35;cursor:not-allowed}
.pager .info{padding:0 8px}
.limbox{margin-left:auto;display:flex;gap:8px;align-items:center}
/* ── Navegación principal: barra "segmented control" en su propia fila completa.
   flex-basis:100% fuerza el salto dentro del header flex-wrap → nunca se superpone. ── */
.tabs{display:flex;gap:6px;flex-wrap:wrap;flex-basis:100%;width:100%;margin-top:12px;
      background:#0d1017;border:1px solid #283449;border-radius:12px;padding:7px}
.tab{display:inline-flex;align-items:center;gap:6px;background:#161d2b;color:#cbd5e1;
     border:1px solid #232c3d;border-radius:9px;padding:10px 18px;font-size:14px;font-weight:600;
     letter-spacing:.4px;text-transform:uppercase;
     line-height:1;cursor:pointer;white-space:nowrap;transition:background .15s,color .15s,box-shadow .15s}
/* iconos ocultos en todos los tabs EXCEPTO Watchlist */
.tab .tic{display:none}
#tab_wl .tic{display:inline-block;font-size:16px;line-height:1}
.tab:hover{background:#222c40;color:#fff;border-color:#3a4a66}
.tab.on{background:linear-gradient(135deg,#2563eb,#3b82f6);color:#fff;
        border-color:#60a5fa;box-shadow:0 3px 14px rgba(37,99,235,.5);font-weight:700}
.tab.on .tic{filter:drop-shadow(0 1px 1px rgba(0,0,0,.3))}
.sh{color:#34d399;font-weight:600}
.sh_inf{color:#fbbf24;font-weight:600}                /* amarillo: inferred */
.sh_newb{color:#a78bfa;font-weight:600}               /* lila: newbie */
.sh_unk{color:#60a5fa;font-weight:600}                /* azul: tier=1 sin transicion */
.sh_under{color:#f97316;font-weight:600}              /* naranja: undershot (inferencia corta) */
.nosh{color:#7c8595}
.wstar{cursor:pointer;color:#6b7280;font-size:14px;user-select:none;padding:0 4px}
.wstar:hover{color:#fbbf24}
.wstar-on{color:#fbbf24}
th.sortable{cursor:pointer;user-select:none}
th.sortable:hover{background:#1a2030}
.sortar{font-size:10px;margin-left:4px;display:inline-block;min-width:10px;color:#4a5568}     /* neutro: gris tenue */
.sortar.active{color:#7dd3fc;font-weight:bold}                                                /* activo: azul claro */
.wbanner{background:#7f1d1d;color:#fff;padding:8px 14px;border-radius:6px;margin:4px 0;font-size:13px;display:flex;justify-content:space-between;align-items:center;gap:14px}
.wbanner .who{font-weight:600}
.wbanner .meta{opacity:.85;font-size:12px}
.src_auto{background:#1e3a8a;color:#bfdbfe;padding:2px 6px;border-radius:4px;font-size:11px;font-weight:600}
.src_manual{background:#374151;color:#d1d5db;padding:2px 6px;border-radius:4px;font-size:11px;font-weight:600}
.shind{display:inline-flex;align-items:center;gap:5px;padding:5px 10px;background:#1f2937;border:1px solid #2c3340;border-radius:8px;cursor:pointer;font-size:13px;transition:background .15s,box-shadow .25s}
.shind:hover{background:#283446}
.shind.has{background:#0f3622;border-color:#1f6b2a;color:#34d399}
.shind.has:hover{background:#155033}
.shind .lbl{color:#7c8595;font-size:11px}
.shind.flash{animation:shflash .9s ease-out 0s 3}
@keyframes shflash{0%{box-shadow:0 0 0 0 rgba(52,211,153,.6)}70%{box-shadow:0 0 0 10px rgba(52,211,153,0)}100%{box-shadow:0 0 0 0 rgba(52,211,153,0)}}
.shpanel{position:absolute;right:14px;top:62px;background:#10141c;border:1px solid #2c3340;border-radius:10px;width:380px;max-height:480px;overflow:auto;z-index:9999;box-shadow:0 8px 30px rgba(0,0,0,.5)}
.shphead{display:flex;align-items:center;gap:8px;padding:10px 12px;background:#161a22;border-bottom:1px solid #2c3340;font-size:13px;font-weight:600}
.shitem{padding:8px 12px;border-bottom:1px solid #1d2330;font-size:12px;line-height:1.45}
.shitem:last-child{border-bottom:none}
.shitem .nm{color:#e6e6e6;font-weight:600}
.shitem .meta{color:#7c8595;font-size:11px}
.shitem .left{color:#34d399;font-weight:600;float:right}
.shitem .src{color:#fbbf24;font-size:10px;text-transform:uppercase;letter-spacing:.5px}
.shempty{padding:18px;text-align:center;color:#7c8595;font-size:12px}
#toasts{position:fixed;top:14px;right:14px;display:flex;flex-direction:column;gap:8px;z-index:99999;pointer-events:none}
.toast{pointer-events:auto;background:#7c2d12;color:#fff;border:1px solid #ea580c;border-radius:8px;padding:10px 14px;min-width:260px;max-width:340px;box-shadow:0 6px 20px rgba(0,0,0,.45);font-size:13px;animation:tslidein .25s ease-out}
.toast.expiring{background:#7c2d12;border-color:#fb923c}
.toast .title{font-weight:700;font-size:13px;margin-bottom:4px;display:flex;align-items:center;gap:6px}
.toast .meta{font-size:11px;color:#fed7aa}
.summontag{display:inline-block;font-size:9px;font-weight:800;letter-spacing:.4px;color:#04200f;background:#22c55e;border-radius:3px;padding:0 4px;vertical-align:middle;margin-left:3px}      /* verde = summon 100% (owner_id) */
.summontag-maybe{display:inline-block;font-size:9px;font-weight:800;letter-spacing:.4px;color:#1a1206;background:#fbbf24;border-radius:3px;padding:0 4px;vertical-align:middle;margin-left:3px} /* amarillo = probable (patrón) */
.toast.toast-solo{background:#15324f;border-color:#3b82f6}
.toast.toast-solo .meta{color:#bfdbfe}
.toast .close{position:absolute;top:6px;right:8px;cursor:pointer;color:#fed7aa;font-size:14px;border:none;background:transparent}
.toast .copied{margin-top:5px;font-size:11px;font-weight:700;color:#bbf7d0;letter-spacing:.3px}
@keyframes tslidein{from{transform:translateX(360px);opacity:0}to{transform:translateX(0);opacity:1}}

/* ═══════════════════════════════════════════════════════════════════════════
   RESPONSIVE — adaptación a móvil/tablet
   Breakpoints: 1024px (tablet), 768px (móvil portrait), 480px (móvil pequeño)
   ═══════════════════════════════════════════════════════════════════════════ */

/* Wrapper genérico para tablas → scroll horizontal en pantallas estrechas */
.tblwrap{overflow-x:auto;-webkit-overflow-scrolling:touch}
.tblwrap table{min-width:600px}   /* fuerza scroll si la pantalla < 600px */

/* Hamburger menu (visible solo en móvil) */
.mobile-menu-btn{display:none;background:#374151;color:#fff;border:0;padding:8px 12px;border-radius:6px;font-size:18px;cursor:pointer;flex:0 0 auto}
.mobile-only{display:none}
.desktop-only{display:inline-flex}

/* ────────────── Tablet: <= 1024px ────────────── */
@media (max-width: 1024px) {
  header{padding:10px 12px;gap:10px}
  .box{padding:10px 12px;gap:12px}
  .qpad{padding-left:12px}
  .fams{padding:8px 12px}
  .famgrp{padding:6px 12px 0}
  th,td{padding:6px 10px}
  .atkhdr{padding:8px 12px;gap:10px;flex-wrap:wrap}
  .ms{min-width:260px}
  #shield_indicator{padding:4px 8px}
  /* botones reset secundarios más compactos */
  header button.sec{font-size:11px;padding:5px 8px}
}

/* ────────────── Móvil portrait: <= 768px ────────────── */
@media (max-width: 768px) {
  body{font-size:14px}
  h1{font-size:14px}
  header{padding:10px 12px;gap:8px;position:sticky;top:0;z-index:50;flex-wrap:wrap;align-items:center}
  /* En móvil las tabs viven DENTRO del burger menu: ocultas con el menú cerrado,
     y al abrirlo se listan UNA DEBAJO DE OTRA (columna, cada item a línea completa). */
  .tabs{margin-top:8px;flex-basis:100%;width:100%;order:99;flex-direction:column;gap:5px;padding:6px}
  header.menu-closed .tabs{display:none}
  header.menu-open   .tabs{display:flex}
  .tab{width:100%;justify-content:flex-start;padding:11px 14px;font-size:14px;white-space:nowrap;border-radius:8px}
  .tab .tic{font-size:16px;margin-left:0px}
  .mobile-only{display:inline-flex}
  /* (2) Burger SIEMPRE en la esquina superior derecha del header */
  .mobile-menu-btn{display:inline-flex;margin-left:auto;order:0}
  /* (1) Ocultar Pause Scanner, Restart Scanners e info del header en móvil */
  #btn_scan{display:none}
  .reset-btn{display:none}
  /* ...pero Restart Scanners SÍ visible en el modal de Settings (recuperación útil desde móvil) */
  #settingsModal #btn_restart_scanners{display:inline-block}
  #btn_info{display:none}
  header.menu-open{flex-wrap:wrap}
  /* (3) Ocultar ETAs (shield indicator) en móvil */
  #shield_indicator{display:none}
  /* (4) Ocultar caja de usuario/logout del header en móvil.
     !important porque applyRole() le pone display:inline-flex inline (JS) y
     un estilo inline gana a una regla de hoja sin !important. El logout vive
     ahora dentro del burger (.tabs). */
  #user_box{display:none !important}
  /* Nombre del tab actual en el header (solo móvil) */
  .cur-tab{display:inline-flex;align-items:center;gap:6px;font-size:15px;font-weight:600;color:#e2e8f0;text-transform:uppercase;margin-left:40px}
  /* Stat string truncated */
  #stat{font-size:10px;color:#7c8595;max-width:140px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1 1 auto}
  /* Box (filters): stack vertical, full width */
  .box{padding:10px 12px;gap:10px;flex-direction:column;align-items:stretch}
  .box > *{width:100%}
  .box label{font-size:12px}
  /* Filtros del tab Monsters colapsables en móvil (plegados por defecto) */
  .filt-toggle{display:flex;align-items:center;gap:8px;cursor:pointer;user-select:none;
    padding:10px 12px;background:#12151c;border-bottom:1px solid #262b36;
    border-top:3px solid #2c3340;   /* (4) separador entre Other y Filters & search */
    font-size:13px;font-weight:600;color:#cbd5e1}
  /* Monsters: colapsa el box entero (su Sort va en una .sortbar aparte siempre visible) */
  .mon-filters:not(.open){display:none}
  .mon-filters.open{display:flex}
  /* Players/Resources/Relics/Arctic/Subcities/Respawns: el box queda visible mostrando
     SOLO el Sort (.sortkeep) cuando está colapsado; el resto de filtros se ocultan. */
  .pl-filters, .res-filters, .rel-filters, .arc-filters, .sc-filters, .rs-filters{display:flex}
  .pl-filters:not(.open) > *:not(.sortkeep),
  .res-filters:not(.open) > *:not(.sortkeep),
  .rel-filters:not(.open) > *:not(.sortkeep),
  .arc-filters:not(.open) > *:not(.sortkeep),
  .sc-filters:not(.open) > *:not(.sortkeep),
  .rs-filters:not(.open) > *:not(.sortkeep){display:none}
  .box input,.box select{width:100%;font-size:14px;padding:9px 10px;box-sizing:border-box}
  .box input[type=number]{width:100%}
  /* Botones touch-friendly */
  button{min-height:40px;padding:9px 14px;font-size:14px}
  header button{min-height:36px}
  table{font-size:12px;min-width:0}
  th,td{padding:7px 8px;white-space:nowrap}
  /* ── MÓVIL: tablas de resultados en formato TARJETA ──
     Cada fila se convierte en una tarjeta que CABE en el ancho del móvil (sin scroll
     horizontal). thead se oculta; la 1ª celda (Name) va a línea completa y el resto
     de campos fluyen debajo. Aplica a las tablas hijas directas de cada view_* y a
     las .t (respawns/relocations/watchlist). NO afecta a .atktbl (anidada en #atkpanel). */
  div[id^="view_"]{overflow-x:visible;max-width:100%}
  div[id^="view_"] > table, div[id^="view_"] table.t{min-width:0;width:100%;display:block}
  div[id^="view_"] > table > thead, div[id^="view_"] table.t > thead{display:none}
  div[id^="view_"] > table > tbody, div[id^="view_"] table.t > tbody{display:block}
  div[id^="view_"] > table tbody > tr, div[id^="view_"] table.t tbody > tr{
    display:flex;flex-wrap:wrap;align-items:baseline;gap:3px 10px;
    padding:9px 12px;border-bottom:1px solid #232a36}
  div[id^="view_"] > table tbody > tr > td, div[id^="view_"] table.t tbody > tr > td{
    border:none!important;padding:0;font-size:12px;color:#9fb0c3;white-space:normal;width:auto;line-height:1.4}
  div[id^="view_"] > table tbody > tr > td:first-child, div[id^="view_"] table.t tbody > tr > td:first-child{
    width:100%;font-size:13.5px;font-weight:600;color:#e6edf5}
  /* ── Formato tarjeta para TODAS las tablas de resultados ──
     línea 1 = primera celda (Name/Player/Monster) · línea 2 = resto de campos separados por " – ".
     Las celdas ocultas (X/Y) no pintan separador. */
  div[id^="view_"] > table tbody > tr > td:not(:first-child):not(:last-child)::after,
  div[id^="view_"] table.t tbody > tr > td:not(:first-child):not(:last-child)::after{
    content:'–';margin:0 6px;color:#475569}
  /* ocultar las columnas X / Y sueltas (las coords ya van en la celda "Copy") — por tabla */
  #rows  > tr > td:nth-child(5),  #rows  > tr > td:nth-child(6),    /* Monsters:  X=5  Y=6  */
  #prows > tr > td:nth-child(7),  #prows > tr > td:nth-child(8),    /* Players:   X=7  Y=8  */
  #rrows > tr > td:nth-child(5),  #rrows > tr > td:nth-child(6),    /* Resources: X=5  Y=6  */
  #rlrows> tr > td:nth-child(6),  #rlrows> tr > td:nth-child(7),    /* Relics:    X=6  Y=7  */
  #arrows> tr > td:nth-child(6),  #arrows> tr > td:nth-child(7),    /* Arctic:    X=6  Y=7  */
  #screws> tr > td:nth-child(8),  #screws> tr > td:nth-child(9),    /* Subcities: X=8  Y=9  */
  #rs_rows> tr > td:nth-child(3), #rs_rows> tr > td:nth-child(4){   /* Respawns:  X=3  Y=4  */
    display:none}
  /* prefijo "Lv." en la columna de nivel de Monsters */
  #rows > tr > td:nth-child(2)::before{content:'Lv. ';color:#94a3b8;font-weight:400}
  /* "Discovered" compacto (Xsec/Xmin ago) — Monsters */
  .d-full{display:none}
  .d-short{display:inline}
  /* (5) fresh / atk-active: el fondo amarillo/rojo cubre TODA la tarjeta (no solo la celda) */
  #rows > tr.fresh{background:rgba(255,235,59,0.12)}
  #rows > tr.atk-active{background:rgba(220,38,38,0.20);animation:atkblink 1.6s ease-in-out infinite}
  #rows > tr.fresh > td, #rows > tr.atk-active > td{background:transparent}
  /* divisor blanco suave en filas resaltadas (igual que Respawns) */
  #rows > tr.fresh, #rows > tr.atk-active{border-bottom-color:rgba(255,255,255,0.5)}
  /* Multi-select families */
  .ms{min-width:auto;width:100%}
  /* Multi-select chips */
  .msbox{min-height:42px;padding:6px}
  .chip{font-size:13px;padding:4px 8px}
  /* Family chips */
  .fam{padding:4px 9px;font-size:11px;border-radius:12px}
  .famgrp h3{font-size:12px}
  /* Atk panel header */
  .atkhdr{padding:8px 10px;gap:8px;flex-wrap:wrap;align-items:flex-start}
  .atktitle{font-size:13px;width:100%}
  /* Active Attacks en móvil → formato tarjeta (cabe en el ancho, sin scroll horizontal).
     Columnas: 1 Phase | 2 Attacker | 3 Target | 4 Coords | 5 Origin | 6 ETA */
  #atkpanel{overflow-x:visible;max-width:100%}
  .atktbl{display:block;width:100%;min-width:0;font-size:12px}
  .atktbl thead{display:none}
  .atktbl tbody{display:block}
  .atktbl tbody tr{display:flex;flex-wrap:wrap;align-items:baseline;gap:3px 10px;padding:9px 12px;border-bottom:1px solid #232a36}
  .atktbl tbody td{border:none!important;padding:0;font-size:12px;color:#9fb0c3;white-space:normal;width:auto;line-height:1.4}
  .atktbl tbody td:first-child{width:100%;font-size:13px;font-weight:600;color:#e6edf5}
  .atktbl tbody td:not(:first-child):not(:last-child)::after{content:'–';margin:0 6px;color:#475569}
  /* Sortbar */
  .sortbar{padding:8px 12px;flex-wrap:wrap;gap:6px}
  .sortbar label{font-size:11px}
  .sortbar input,.sortbar select{font-size:14px;padding:8px 10px}
  /* Pager */
  .pager{padding:8px 12px;flex-wrap:wrap;font-size:11px}
  .pager button{padding:8px 14px;font-size:13px}
  /* Shield panel (popup ETAs) — más ancho relativo en móvil */
  .shpanel{position:fixed;right:8px;left:8px;top:auto;bottom:8px;width:auto;max-height:65vh;border-radius:14px}
  .shphead{padding:12px 14px;font-size:14px}
  .shitem{padding:10px 14px;font-size:13px}
  /* Toasts ocultos en móvil (molestos en pantalla pequeña) */
  #toasts{display:none !important}
  /* Watch banner */
  .wbanner{padding:10px 12px;flex-direction:column;align-items:stretch;gap:6px;font-size:12px}
  .wbanner button{align-self:flex-end}
  /* Atk toggle */
  .atktoggle{padding:10px 12px;flex-wrap:wrap;gap:8px}
  /* Hide some helper texts on mobile */
  .hide-mobile{display:none}
  /* Watchlist global alerts banner (fixed top-right) → full width arriba en móvil */
  #wl_global_alerts{position:fixed !important;top:auto !important;bottom:80px !important;
                    right:8px !important;left:8px !important;width:auto !important;z-index:80}
  /* Intel dashboard: flex children con min-width:340 → relax para no romper en móvil */
  #view_intel > div[style*="display:flex"]{flex-direction:column !important;padding:0 12px 16px !important;gap:12px !important}
  #view_intel > div[style*="display:flex"] > div[style*="min-width"]{min-width:auto !important;width:100% !important;flex:1 1 auto !important}
  #intel_kpis{padding:8px 12px !important;gap:8px !important}
  #intel_kpis > div{flex:1 1 calc(50% - 8px) !important;min-width:140px !important;font-size:12px !important}
  /* Subcontainers de boxes con flex */
  .box > div[style*="flex"]{flex:1 1 auto !important;min-width:auto !important;width:100% !important}
  /* Sticky bottom action bar para los toggles del panel de ataques */
  .atktoggle{position:sticky;bottom:0;z-index:30;border-top:1px solid #262b36}
  /* El dropdown de sugerencias de una barra sticky-bottom (p.ej. 📍 SEND COORDS) abre HACIA ARRIBA + z-index alto:
     si abriera hacia abajo (top:100%) en móvil se saldría por debajo del viewport o lo taparía la sección siguiente. */
  .atktoggle .dd{top:auto;bottom:100%;margin-top:0;margin-bottom:4px;z-index:100;max-height:50vh}
}

/* ────────────── Móvil pequeño: <= 480px ────────────── */
@media (max-width: 480px) {
  header{padding:8px 10px;gap:6px}
  h1{font-size:13px}
  /* MÓVIL: el badge de servidor se oculta para dejar sitio a las pastillas W/E + ↻ del
     escáner (el server_id se sigue viendo en Settings > Info & Scanner Status). */
  h1 #server_badge{display:none!important}
  /* Estado W/E + ↻ del escáner: TAMBIÉN visible en MÓVIL (solo SuperAdmin — lo revela el gate
     de rol, que le pone display:inline-flex). Antes lo ocultaba aquí con display:none!important
     "por falta de sitio"; ahora se compacta para que quepa en la primera línea del header, a la
     izquierda del burger (que va con margin-left:auto). El header tiene flex-wrap, así que en
     pantallas muy estrechas simplemente baja de línea en vez de desbordar.
     El botón lleva estilos EN LÍNEA, así que hay que usar !important para reducirlo. */
  #hdr_scan_box{gap:5px;margin-left:4px}
  .hdr-sc{font-size:9px;padding:1px 5px;border-radius:4px}
  /* ↻ como objetivo táctil CUADRADO de 35x35 (con el dedo, 10px era imposible de acertar).
     El botón lleva estilos EN LÍNEA (padding/font-size), así que hacen falta !important. */
  #hdr_btn_restart{width:35px!important;height:35px!important;padding:0!important;
                   font-size:17px!important;line-height:1;display:inline-flex;
                   align-items:center;justify-content:center;flex:0 0 auto;border-radius:8px}
  .box{padding:8px 10px}
  .fams{padding:6px 10px}
  .famgrp{padding:4px 10px 0}
  .atkhdr{padding:6px 10px}
  th,td{padding:6px 6px;font-size:11px}
  .tag,.grp,.atk{font-size:10px;padding:1px 5px}
  .ph{font-size:9px;padding:1px 5px}
  .eta-val{font-size:12px}
  /* Tabs más compactas */
  .tab{padding:12px 0px;font-size:11px;padding-left:20px;width:calc(100% - 20px)}
  /* Botón menú más pequeño */
  .mobile-menu-btn{padding:6px 10px;font-size:16px;min-height:36px}
  /* Stat oculto si muy estrecho (solo se ve en panel de estados) */
  #stat{display:none}
}

/* iOS notch / safe area */
@supports (padding: max(0px)) {
  header{padding-left:max(12px, env(safe-area-inset-left));padding-right:max(12px, env(safe-area-inset-right));padding-top:max(10px, env(safe-area-inset-top))}
  #toasts{bottom:max(14px, env(safe-area-inset-bottom))}
}
/* Player Activity modal (Fase 1+2) */
#pactModal{position:fixed;inset:0;background:rgba(0,0,0,.62);display:none;align-items:center;justify-content:center;z-index:200;padding:18px}
#pactModal .card{background:#12151c;border:1px solid #2a3140;border-radius:10px;max-width:640px;width:100%;max-height:88vh;overflow:auto;box-shadow:0 12px 44px rgba(0,0,0,.6)}
#pactModal .hd{display:flex;align-items:center;justify-content:space-between;gap:10px;padding:12px 16px;border-bottom:1px solid #262b36;position:sticky;top:0;background:#12151c;z-index:1}
#pactModal .hd b{font-size:15px;color:#e2e8f0}
#pactModal .x{cursor:pointer;border:none;background:transparent;color:#9aa4b2;font-size:22px;line-height:1}
#pactModal .bd{padding:14px 16px;font-size:13px;color:#cbd5e1}
.pact-sec{margin-bottom:15px}
.pact-sec h4{margin:0 0 6px;font-size:10px;text-transform:uppercase;letter-spacing:.6px;color:#7c8595}
.pact-note{font-size:11px;color:#7c8595;line-height:1.5;border-top:1px solid #262b36;padding-top:10px}
.actrow{display:flex;gap:8px;align-items:center;padding:3px 0}
.hm{display:grid;grid-template-columns:34px repeat(24,1fr);gap:2px;font-size:9px}
.hm .hmh{color:#5a6472;text-align:center;font-size:8px;height:12px}
.hm .hmlab{color:#7c8595;text-align:right;padding-right:5px;line-height:13px}
.hm .cell{height:13px;border-radius:2px}
.plink{cursor:pointer;border-bottom:1px dotted #4a5568}
.plink:hover{color:#7dd3fc;border-bottom-color:#7dd3fc}
/* Farm reward modal (reusa el patron de pactModal) */
#farmModal{position:fixed;inset:0;background:rgba(0,0,0,.62);display:none;align-items:center;justify-content:center;z-index:200;padding:18px}
#farmModal .card{background:#12151c;border:1px solid #2a3140;border-radius:10px;max-width:560px;width:100%;max-height:88vh;overflow:auto;box-shadow:0 12px 44px rgba(0,0,0,.6)}
#farmModal .hd{display:flex;align-items:center;justify-content:space-between;gap:10px;padding:12px 16px;border-bottom:1px solid #262b36;position:sticky;top:0;background:#12151c;z-index:1}
#farmModal .hd b{font-size:15px;color:#e2e8f0}
#farmModal .x{cursor:pointer;border:none;background:transparent;color:#9aa4b2;font-size:22px;line-height:1}
#farmModal .bd{padding:14px 16px;font-size:13px;color:#cbd5e1}
/* ── Settings modal ── */
#settingsModal{position:fixed;inset:0;background:rgba(0,0,0,.62);display:none;align-items:center;justify-content:center;z-index:300;padding:18px}
#settingsModal .card{background:#12151c;border:1px solid #2a3140;border-radius:10px;max-width:540px;width:100%;max-height:90vh;overflow:auto;box-shadow:0 12px 44px rgba(0,0,0,.6)}
#settingsModal .hd{display:flex;align-items:center;justify-content:space-between;gap:10px;padding:12px 16px;border-bottom:1px solid #262b36;position:sticky;top:0;background:#12151c;z-index:1}
#settingsModal .hd b{font-size:15px;color:#e2e8f0}
#settingsModal .x{cursor:pointer;border:none;background:transparent;color:#9aa4b2;font-size:22px;line-height:1}
#settingsModal .bd{padding:8px 16px 16px;font-size:13px;color:#cbd5e1}
.settsec{padding:14px 0;border-bottom:1px solid #1e2430}
.settsec:last-child{border-bottom:none}
.settsec h4{margin:0 0 10px;font-size:12px;letter-spacing:.5px;text-transform:uppercase;color:#fbbf24;font-weight:700}
.set-sub{font-size:11px;color:#7c8595;margin-top:6px}
.set-actions{display:flex;gap:8px;flex-wrap:wrap;margin-top:12px}
/* scanner status rows */
.scst{display:flex;align-items:center;gap:10px;padding:8px 10px;border:1px solid #1e2430;border-radius:8px;margin-bottom:7px;background:#0d1422}
.scst .who{font-weight:700;color:#e2e8f0;min-width:74px}
.scst .dot{width:9px;height:9px;border-radius:50%;flex:0 0 auto;box-shadow:0 0 6px currentColor}
.scst .badge{font-size:11px;font-weight:700;padding:2px 8px;border-radius:10px;text-transform:uppercase;letter-spacing:.3px}
.scst .det{font-size:11px;color:#94a3b8;margin-left:auto;text-align:right;line-height:1.5}
.st-run{background:#06351c;color:#86efac;border:1px solid #15803d}
.st-pause{background:#3a2e07;color:#fde68a;border:1px solid #a16207}
.st-warm{background:#0b2a40;color:#93c5fd;border:1px solid #1d4ed8}
.st-recon{background:#3a1c07;color:#fdba74;border:1px solid #c2410c}
.st-off{background:#3a0d0d;color:#fca5a5;border:1px solid #b91c1c}
.st-stall{background:#422006;color:#fbbf24;border:1px solid #d97706}
.st-unk{background:#1f2937;color:#9ca3af;border:1px solid #374151}
/* notification toggle rows */
.set-tg{display:flex;align-items:center;gap:10px;padding:7px 2px}
.set-tg .lbl{flex:1 1 auto;display:flex;flex-direction:column}
.set-tg .lbl .t{color:#e2e8f0;font-size:13px}
.set-tg .lbl .d{color:#7c8595;font-size:11px}
.set-tg.master{border-bottom:1px solid #1e2430;padding-bottom:11px;margin-bottom:5px}
.set-tg.master .lbl .t{color:#fbbf24;font-weight:700}
.set-tg.dim{opacity:.45;pointer-events:none}
/* info icon con tooltip (hover en escritorio, tap en móvil) */
.infohint{position:relative;display:inline-block;cursor:help;color:#60a5fa;font-size:12px;margin-left:7px;font-weight:400;vertical-align:middle}
.infohint .tip{display:none;position:absolute;left:0;top:135%;z-index:20;width:min(280px,80vw);background:#0b1120;border:1px solid #2a3140;border-radius:8px;padding:9px 11px;font-size:11px;font-weight:400;line-height:1.55;color:#cbd5e1;box-shadow:0 8px 24px rgba(0,0,0,.55);text-transform:none;letter-spacing:0;white-space:normal}
.infohint:hover .tip, .infohint:focus .tip, .infohint.show .tip{display:block}
/* Focus zone dentro de Scanner Settings */
.set-focus{display:flex;flex-wrap:wrap;align-items:center;gap:7px;font-size:12px}
.set-focus input,.set-focus select{background:#0d1422;color:#e2e8f0;border:1px solid #2a3140;border-radius:6px;padding:3px 6px;font-size:12px}
.set-inl{display:inline-flex;align-items:center;gap:4px;color:#cbd5e1}
.fr-sec{margin-bottom:15px}
.fr-sec h4{margin:0 0 7px;font-size:10px;text-transform:uppercase;letter-spacing:.6px;color:#7c8595}
.fr-kv{display:flex;flex-wrap:wrap;gap:6px 16px;margin-bottom:8px}
.fr-kv span b{color:#e2e8f0}
.fr-itbl{width:100%;border-collapse:collapse;font-size:12px}
.fr-itbl td{padding:3px 6px;border-bottom:1px solid #1d222c}
.fr-itbl td.amt{text-align:right;color:#cbd5e1}
.fr-itbl td.pri{text-align:right;width:54px}
.fr-gua{color:#4ade80}.fr-hi{color:#fbbf24}.fr-lo{color:#94a3b8}
.fr-note{font-size:11px;color:#7c8595;line-height:1.5;border-top:1px solid #262b36;padding-top:10px}
.fr-obs h4{color:#fbbf24}
.frlink{cursor:pointer}
.frlink:hover{color:#7dd3fc}
</style></head><body>
<div id=toasts></div>
<div id=pactModal onclick="if(event.target===this)closePlayerActivity()">
 <div class=card>
  <div class=hd><b id=pactTitle>Player</b><button class=x onclick=closePlayerActivity() title="Close">×</button></div>
  <div class=bd id=pactBody></div>
 </div>
</div>
<div id=farmModal onclick="if(event.target===this)closeFarmReward()">
 <div class=card>
  <div class=hd><b id=farmTitle>Reward</b><button class=x onclick=closeFarmReward() title="Close">×</button></div>
  <div class=bd id=farmBody></div>
 </div>
</div>
<header class=menu-closed id=mainHeader><h1>__SCANNER_VERSION_BADGE__<span id=server_badge style="font-size:13px;color:#fbbf24;font-weight:normal;background:#1e293b;padding:3px 8px;border-radius:4px;display:none">Server #—</span></h1>
<!-- Estado de los escáneres W/E + reinicio, EN LA CABECERA. Solo SuperAdmin (lo revela el gate de rol). -->
<span id=hdr_scan_box style="display:none;align-items:center;gap:5px;margin-left:6px">
  <span id=hdr_sc_W class=hdr-sc title="Scanner W">W</span>
  <span id=hdr_sc_E class=hdr-sc title="Scanner E">E</span>
  <button id=hdr_btn_restart class=sec onclick="restartScanners()" title="Cierra Evony en LOS DOS Pixel y la vuelve a abrir (frida-server fresco + reattach). ~1-1,5 min." style="padding:2px 7px;font-size:11px;background:#7f1d1d;border-color:#b91c1c">↻</button>
</span>
<span id=cur_tab class=cur-tab>Shortcuts</span>
<button id=btn_info class=sec onclick="toggleStat()" title="Mostrar/ocultar estadísticas de escaneo" style="padding:5px 9px;display:none">ⓘ info</button>
<span class=stat id=stat style="display:none">loading...</span>
<label style="display:none">auto<input type=checkbox id=auto checked></label>
<button class="mobile-menu-btn mobile-only" onclick="toggleMobileMenu()" title="Secciones">☰</button>
<!-- Pause/Resume Scanner, Restart Scanners y Scanner Profile se movieron al panel ⚙ Settings. -->

<span id=user_box style="margin-left:auto;display:none;align-items:center;gap:8px;font-size:12px;color:#94a3b8">
  <span id=user_label></span>
  <button id=btn_sessions class=sec onclick="toggleSessionsPanel(event)" title="Ver usuarios conectados" style="padding:4px 9px;display:none">👥 Sesiones</button>
  <button id=btn_settings class=sec onclick="openSettings()" title="Settings: scanner status, profile, notifications" style="padding:4px 9px">⚙ Settings</button>
  <button id=btn_readme class=sec onclick="openReadme()" title="Guide / README" style="padding:4px 9px">📖 README</button>
  <button class=sec onclick="doLogout()" title="Log out" style="padding:4px 9px">Log out</button>
</span>
<div id=sessions_panel style="display:none;position:fixed;top:54px;right:14px;z-index:9999;width:min(560px,94vw);max-height:78vh;overflow:auto;background:#0f1830;border:1px solid #243049;border-radius:10px;box-shadow:0 14px 48px rgba(0,0,0,.6);padding:14px 16px;font-size:13px;color:#e6e6e6">
  <div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:10px">
    <b style="color:#fbbf24">Sesiones activas</b>
    <span style="display:flex;gap:8px">
      <button class=sec onclick="refreshSessions()" style="padding:3px 8px">↻</button>
      <button class=sec onclick="toggleSessionsPanel(event)" style="padding:3px 8px">✕</button>
    </span>
  </div>
  <div id=sessions_body style="color:#94a3b8">Cargando…</div>
</div>
<div id=shield_indicator class=shind onclick="toggleShieldPanel(event)" title="Click to view active bubble ETAs (matched and pending)">
  <span id=sh_icon>🛡</span> <span id=sh_count>0</span> <span class=lbl>ETAs</span>
</div>
<div class=tabs>
  <span class="tab on" id=tab_quick onclick="switchTab('quick')"><span class=tic>⚡</span>Shortcuts</span>
  <span class="tab" id=tab_mon onclick="switchTab('mon')"><span class=tic>🐉</span>Monsters</span>
  <span class=tab id=tab_pl onclick="switchTab('pl')"><span class=tic>👤</span>Players</span>
  <span class=tab id=tab_res onclick="switchTab('res')"><span class=tic>🌾</span>Resources</span>
  <span class=tab id=tab_farm onclick="switchTab('farm')"><span class=tic>🎯</span>Farm</span>
  <span class=tab id=tab_rel onclick="switchTab('rel')"><span class=tic>🏛️</span>Relics/Pyramids</span>
  <span class=tab id=tab_arc onclick="switchTab('arc')"><span class=tic>❄️</span>Arctic Barbarians</span>
  <span class=tab id=tab_sc onclick="switchTab('sc')"><span class=tic>🏘️</span>Subcities</span>
  <!-- [respawns-disabled] tab Respawns oculto temporalmente (info poco fiable).
       Se mantiene el elemento (display:none) porque switchTab lo referencia por id. -->
  <span class=tab id=tab_rs onclick="switchTab('rs')" style="display:none"><span class=tic>⏱️</span>Respawns</span>
  <span class=tab id=tab_rl onclick="switchTab('rl')"><span class=tic>📍</span>Relocations</span>
  <span class=tab id=tab_wl onclick="switchTab('wl')"><span class=tic>⭐</span>Watchlist <span id=wl_count style="opacity:.7;font-size:11px"></span></span>
  <span class=tab id=tab_svs onclick="switchTab('svs')" style="background:#7f1d1d;color:#fecaca"><span class=tic>⚔️</span>SVS</span>
  <!-- TEMPORALMENTE OCULTOS (ahorro de memoria/CPU): Protocol + Server Stats. Reactivar quitando display:none y el guard en switchTab. -->
  <span class=tab id=tab_proto onclick="switchTab('proto')" style="background:#581c87;color:#f3e8ff;display:none">🔬 Protocol</span>
  <span class=tab id=tab_intel onclick="switchTab('intel')" style="background:#065f46;color:#d1fae5;display:none">📊 Server Stats</span>
  <!-- Log out dentro del burger (solo móvil; en escritorio se usa #user_box). NO usar
       style=display:none inline: rompe la media query .mobile-only. -->
  <span class="tab mobile-only" id=mobile_user style="opacity:.6;pointer-events:none;text-transform:none;font-weight:500"></span>
  <!-- Settings dentro del burger (solo móvil): scanner status, profile, notifications. -->
  <button class="tab mobile-only" id=mobile_settings onclick="openSettings()">⚙ Settings</button>
  <button class="tab mobile-only" id=mobile_readme onclick="openReadme()">📖 README</button>
  <button class="tab mobile-only" id=mobile_logout onclick="doLogout()">⎋ Log out</button>
</div></header>
<div id=shield_panel class=shpanel style="display:none">
  <div class=shphead>Recent bubble captures <span class=stat id=sh_phead></span>
    <button class=sec style="margin-left:auto;font-size:11px;padding:2px 8px" onclick="toggleShieldPanel(event)">close</button></div>
  <div id=sh_list></div>
</div>

<!-- ⚙ Settings modal: configuraciones globales (status, profile, notifications) -->
<div id=settingsModal onclick="if(event.target===this)closeSettings()">
 <div class=card>
  <div class=hd><b>⚙ Settings</b><button class=x onclick=closeSettings() title="Close">×</button></div>
  <div class=bd>
   <!-- (1) Info & Scanner Status -->
   <div class=settsec>
    <h4>📊 Info &amp; Scanner Status</h4>
    <div id=set_serverline style="margin:0 0 10px;font-size:12px;color:#cbd5e1"></div>
    <div id=set_scanstatus></div>
    <div id=set_statline class=set-sub></div>
    <div class=set-actions>
     <button id=btn_scan class=sec onclick="toggleScanner()" title="Pause/resume the camera jumps so you can interact with the game (scout, open profiles).">⏸ Pause Scanner</button>
     <button id=btn_restart_scanners class="sec reset-btn" onclick="restartScanners()" title="Closes Evony on BOTH Pixel scanners and launches it again (fresh frida-server + reattach). ~1-1.5 min. The backend keeps running." style="background:#7f1d1d;border-color:#b91c1c">↻ Restart Scanners</button>
    </div>
   </div>
   <!-- (2) Scanner Profile (solo superadmin) -->
   <div class=settsec id=set_profile_sec style="display:none">
    <h4>📡 Scanner Profile</h4>
    <div class=set-actions style="margin-top:0">
     <select id=scanprof_sel style="font-size:12px;background:#0d1422;color:#e2e8f0;border:1px solid #2a3140;border-radius:6px;padding:4px 8px;max-width:100%"></select>
     <button class=sec onclick="applyScanProfile()" style="padding:4px 12px">Apply</button>
    </div>
    <div id=scanprof_msg class=set-sub></div>
    <div class=set-sub>home = 6000:W / 6002:E on our server · svs_split = 6000:FULL/our + 6002:FULL/enemy. Applied live (no recompile).</div>
   </div>
   <!-- (3) Send Coords visibility (preferencia por usuario) -->
   <div class=settsec>
    <h4>📍 Send Coords</h4>
    <div class=set-tg>
     <span class=lbl><span class=t>Show "Send Coords" panel</span><span class=d>The 📍 SEND COORDS box on the Monsters tab (hidden by default)</span></span>
     <label class=ios-switch><input type=checkbox id=set_sc_vis onchange="scVisSet(this.checked)"><span class=slider></span></label>
    </div>
   </div>
   <!-- (4) Scanner Settings — Focus Zone (global, compartido por todos) -->
   <div class=settsec>
    <h4>🎯 Scanner Settings<span class=infohint tabindex=0 onclick="this.classList.toggle('show')">ⓘ<span class=tip>Focus zone: the chosen scanner continuously circles one area to catch ALL attacks there (even 2-second ones). While ON, that scanner does NOT sweep the rest of the map.<br><br>⚠ Focus is a single GLOBAL setting shared by all users — editing it changes it for everyone instantly. Reopen Settings to see changes made by others.</span></span></h4>
    <div class=set-focus>
     <span style="color:#cbd5e1;font-weight:600">🎯 Focus zone</span>
     <label class=set-inl><input type=checkbox id=focus_on onchange=saveFocus()> ON</label>
     <select id=focus_half><option value=W>W</option><option value=E selected>E</option></select>
     <input id=focus_tag placeholder="alliance tag (e.g. NUD)" style="width:130px">
     <span>or</span>
     <input id=focus_cx type=number placeholder=cx style="width:60px">
     <input id=focus_cy type=number placeholder=cy style="width:60px">
     <label class=set-inl>r<input id=focus_radius type=number value=200 style="width:52px"></label>
     <button class=sec onclick=saveFocus() style="padding:4px 12px">Apply</button>
     <button class=sec onclick=defenseFocus() title="Cobertura defensiva: ronda NUESTRA alianza para captar ataques entrantes al instante" style="padding:4px 12px;background:#0b2a40;border-color:#1d4ed8">🛡 Defend us</button>
     <span id=focus_msg style="font-size:11px"></span>
    </div>
   </div>
   <!-- (5) Notifications -->
   <div class=settsec>
    <h4>🔔 Notifications</h4>
    <div class="set-tg master">
     <span class=lbl><span class=t>All notifications</span><span class=d>Master switch — turn every alert on/off</span></span>
     <label class=ios-switch><input type=checkbox id=ntf_master onchange="notifSet('master',this.checked)"><span class=slider></span></label>
    </div>
    <div id=ntf_group>
     <div class=set-tg>
      <span class=lbl><span class=t>Rally alerts</span><span class=d>New enemy rally summoned</span></span>
      <label class=ios-switch><input type=checkbox id=ntf_rally onchange="notifSet('rally',this.checked)"><span class=slider></span></label>
     </div>
     <div class=set-tg>
      <span class=lbl><span class=t>SOLO attacks</span><span class=d>Single-player attack incoming</span></span>
      <label class=ios-switch><input type=checkbox id=ntf_solo onchange="notifSet('solo',this.checked)"><span class=slider></span></label>
     </div>
     <div class=set-tg>
      <span class=lbl><span class=t>Bubbles</span><span class=d>A watched bubble is about to expire</span></span>
      <label class=ios-switch><input type=checkbox id=ntf_bubbles onchange="notifSet('bubbles',this.checked)"><span class=slider></span></label>
     </div>
     <div class=set-tg>
      <span class=lbl><span class=t>Enemy alerts (SVS)</span><span class=d>Enemy bubble drops &amp; relocations</span></span>
      <label class=ios-switch><input type=checkbox id=ntf_enemy onchange="notifSet('enemy',this.checked)"><span class=slider></span></label>
     </div>
     <div class=set-tg>
      <span class=lbl><span class=t>Summons</span><span class=d>A player summons an event monster on the map</span></span>
      <label class=ios-switch><input type=checkbox id=ntf_spawns onchange="notifSet('spawns',this.checked)"><span class=slider></span></label>
     </div>
     <div class=set-tg>
      <span class=lbl><span class=t>Ares Statue</span><span class=d>Ares Statue spotted on our server</span></span>
      <label class=ios-switch><input type=checkbox id=ntf_ares onchange="notifSet('ares',this.checked)"><span class=slider></span></label>
     </div>
     <div class=set-tg>
      <span class=lbl><span class=t>Sound</span><span class=d>Beep on alerts</span></span>
      <label class=ios-switch><input type=checkbox id=ntf_sound onchange="notifSet('sound',this.checked)"><span class=slider></span></label>
     </div>
     <div class=set-tg>
      <span class=lbl><span class=t>Browser / OS notifications</span><span class=d>System pop-ups even when tab is hidden</span></span>
      <label class=ios-switch><input type=checkbox id=ntf_browser onchange="notifSetBrowser(this)"><span class=slider></span></label>
     </div>
    </div>
   </div>
   <!-- (6) Scanner Health & Incidents — ÚLTIMA sección y SOLO SuperAdmin (lo revela el gate de rol) -->
   <div class=settsec id=set_incidents_sec style="display:none">
    <h4>🩺 Scanner Health &amp; Incidents</h4>
    <div id=inc_summary class=set-sub style="margin:0 0 8px"></div>
    <div id=inc_list style="max-height:280px;overflow-y:auto;border:1px solid #2a3140;border-radius:6px;background:#0b1220"></div>
    <div class=set-actions>
     <button class=sec onclick="loadIncidents()" style="padding:4px 12px">↻ Refresh</button>
     <label class=set-sub style="display:inline-flex;align-items:center;gap:5px;cursor:pointer">
       <input type=checkbox id=inc_auto checked onchange="toggleIncAuto()"> auto (10s)
     </label>
    </div>
    <div class=set-sub>Reasons why a half stopped scanning: no objects, injection failures, blank screen, restarts. A half stuck off-map is restarted cleanly after <b id=inc_after>90</b>s.</div>
   </div>
  </div>
 </div>
</div>

<div id=readme_modal style="display:none;position:fixed;inset:0;z-index:1000;background:#0b1220;flex-direction:column">
 <div style="display:flex;align-items:center;gap:10px;padding:8px 14px;background:#0f1727;border-bottom:1px solid #22304a">
   <b style="color:#e7eef8;font-size:14px">📖 Scanner Guide</b>
   <span style="color:#93a3bd;font-size:12px">README</span>
   <button class=sec onclick="closeReadme()" title="Close (Esc)" style="margin-left:auto;padding:4px 12px">✕ Close</button>
 </div>
 <iframe id=readme_frame title="README" style="flex:1;width:100%;border:0;background:#0b1220"></iframe>
</div>
<div id=view_quick>
<style>
.qbar{display:flex;flex-wrap:wrap;gap:14px;align-items:flex-end;margin-bottom:4px}
.qbar > div label{display:block;font-size:11px;color:#94a3b8;margin-bottom:3px}
.qbar input,.qbar select{background:#0f172a;border:1px solid #334155;color:#e2e8f0;border-radius:8px;padding:6px 8px;font-size:13px}
.qmenu-btn{background:#1f6feb;border:0;color:#fff;border-radius:9px;padding:10px 18px;font-size:15px;font-weight:700;cursor:pointer}
.qmenu-btn:hover{background:#388bfd}
.qmenu-btn.on{background:#0d419d}
.qshowmore{background:#3a2f0a;border:1px solid #d4a72c;color:#e3b341;border-radius:12px;padding:9px 18px;font-size:14px;font-weight:600;cursor:pointer;display:inline-block}
.qshowmore:hover{background:#4a3d0d;color:#f2cc60}
.qshowmore.on{background:#d4a72c;color:#1c1400}
.qgrp{margin:12px 0}
.qgrp h4{margin:0 0 7px;font-size:14px;color:#e2e8f0;display:flex;align-items:center;gap:8px;flex-wrap:wrap}
.qgrp .qgl{font-size:10px;color:#64748b;font-weight:400;text-transform:uppercase;letter-spacing:.03em}
.qlv{display:inline-flex;flex-wrap:wrap;gap:7px}
.qlv button{border:0;border-radius:9px;padding:9px 13px;font-size:14px;font-weight:700;color:#fff;min-width:54px;position:relative}
.qlv button.on{background:#16a34a;cursor:pointer}
.qlv button.on:hover{filter:brightness(1.12)}
.qlv button.off{background:#3f1d1d;color:#b98a8a;cursor:pointer;opacity:.75}
.qlv button.off:hover{filter:brightness(1.25)}
.qlv button.sel{outline:2px solid #38bdf8;outline-offset:2px}
.qlv button.sel::after{content:'✓';position:absolute;top:-6px;right:-6px;background:#38bdf8;color:#04263a;font-size:11px;font-weight:800;width:16px;height:16px;line-height:16px;border-radius:50%;text-align:center;box-shadow:0 0 0 2px #0d1117}
/* Menú de shortcuts (#quick_menu) SOLO: 3 estados por color de fondo — verde=seleccionado, rojo=disponible sin seleccionar, gris=no disponible. El main screen (#quick_main) sigue verde. */
#quick_menu .qlv button.sel{background:#16a34a;color:#fff;opacity:1}
#quick_menu .qlv button.on:not(.sel){background:#dc2626;color:#fff;opacity:1}
#quick_menu .qlv button.off:not(.sel){background:#7c8595;color:#e2e8f0;opacity:.2}
#quick_list{margin:6px 0 12px;display:block;padding-bottom:100px}
#quick_list .qlh{font-size:13px;color:#cbd5e1;margin-bottom:8px;display:flex;justify-content:space-between;align-items:center;gap:10px;flex-wrap:wrap}
#quick_list .qlgrp{font-size:15px;color:#e2e8f0;font-weight:700;margin-right:10px}
#quick_list .qlsub{font-size:12px;color:#64748b}
#quick_list .qlrow{font-size:13px;padding:3px 2px;border-bottom:1px solid #1e293b;font-variant-numeric:tabular-nums}
#quick_list .qlc{color:#38bdf8;cursor:pointer}
#quick_list .qld{color:#94a3b8}
#quick_list .qlacts{display:flex;align-items:center;gap:8px;flex:0 0 auto}
#quick_list .qlpill{background:#1e293b;border:1px solid #334155;color:#cbd5e1;border-radius:999px;padding:5px 14px;font-size:13px;font-weight:600;cursor:pointer}
#quick_list .qlpill:hover{background:#334155}
#quick_list .qlpill.snd{background:#166534;border-color:#22c55e;color:#dcfce7}
#quick_list .qlpill.snd:hover{background:#15803d}
#quick_list .qlpill:disabled{opacity:.5;cursor:default}
.qlclose{background:#64748b;border:0;color:#fff;border-radius:50%;width:34px;height:34px;min-width:34px;min-height:34px;padding:0;cursor:pointer;font-size:15px;font-weight:700;line-height:1;display:inline-flex;align-items:center;justify-content:center;flex:0 0 auto}
.qlclose:hover{background:#ef4444}
/* Móvil: el ✕ va al borde DERECHO de la fila de acciones (Copy/Send a la izquierda), círculo intacto */
@media (max-width: 768px){
  #quick_list .qlh .qlacts{flex:1 1 100%}
  #quick_list .qlacts .qlclose{margin-left:auto}
}
</style>
<div class=qpad style="margin:2px 0 12px"><button id=qmenu_btn class=qmenu-btn onclick="toggleQuickMenu()">&#9776; Menu</button></div>
<div id=quick_menu style="display:none">
<div class="box">
  <div class=qbar>
    <div><label>&#127968; Hive X</label><input id=q_hx type=number style="width:80px"></div>
    <div><label>Hive Y</label><input id=q_hy type=number style="width:80px"></div>
    <div><label>Sort by</label><select id=q_sort><option value=distance>Distance</option><option value=power>Power</option></select></div>
    <div><label>Results</label><input id=q_n type=number value=40 min=1 max=200 style="width:80px"></div>
    <button class=sec onclick="saveQuickPrefs();if(document.getElementById('quick_menu').style.display!=='none')loadQuick();">Apply</button>
    <span id=q_hint class=qgl></span>
  </div>
</div>
<div id=quick_grid class="box"></div>
<div id=quick_absent class=qpad style="margin-top:16px"></div>
</div>
<div id=quick_main class=qpad style="margin:2px 0 10px"></div>
<div id=quick_eventboss class=qpad style="margin:24px 0 10px"></div>
<div id=quick_list class="box" style="display:none"></div>
</div>
<div id=view_mon style="display:none">
<div class=famgrp><h3 class=famhdr onclick="toggleFamGrp(this)"><span class=fcaret>+</span> Boss Monsters</h3><div class=famrow id=fams_boss style="display:none"></div></div>
<div class=famgrp><h3 class=famhdr onclick="toggleFamGrp(this)"><span class=fcaret>+</span> Event Monsters</h3><div class=famrow id=fams_event style="display:none"></div></div>
<div class=famgrp><h3 class=famhdr onclick="toggleFamGrp(this)" style="color:#a78bfa"><span class=fcaret>+</span> Shadow of Dawn</h3><div class=famrow id=fams_shadow style="display:none"></div></div>
<div class=famgrp style="padding-bottom:10px"><h3 class=famhdr onclick="toggleFamGrp(this)" style="color:#fcd34d"><span class=fcaret>+</span> Other</h3><div class=famrow id=fams_other style="display:none"></div></div>
<!-- Sección SEND COORDS (encima de Show Active Attacks). Oculta por defecto; cada usuario
     la muestra desde ⚙ Settings → Send Coords (preferencia por usuario, server-side). -->
<div class=atktoggle id=send_coords_section style="flex-wrap:wrap;gap:10px;align-items:center;display:none">
  <span class=atkLbl><span class=atkIcon>📍</span>SEND COORDS</span>
  <div class=ms id=ms_sc style="flex:1;min-width:240px">
    <div class=msbox id=msbox_sc><input id=msin_sc placeholder="pick monsters to share (Cerberus, Ymir, Warlord...)" autocomplete=off></div>
    <div class=dd id=dd_sc></div>
  </div>
  <!-- Destinatario (SOLO superadmin): elegir a qué miembro de [LAN] enviar la lista.
       Para el resto de usuarios va siempre a su propio player_name (selector oculto). -->
  <select id=sc_recipient title="Recipient (alliance member)" style="display:none;font-size:12px;max-width:180px"></select>
  <button class=sec id=btn_sc_send onclick=sendCoords() style="background:#14532d;border-color:#16a34a">📍 Send Top 20</button>
  <span id=sc_msg style="font-size:12px;color:#94a3b8"></span>
</div>
<!-- Sección SHOW ACTIVE ATTACKS movida aquí (encima de #mon_filters) -->
<!-- El .atkhdr (controles/filtros) va DENTRO del .atktoggle; se muestra solo cuando la sección está activa. -->
<div class=atktoggle style="flex-wrap:wrap">
  <span class=atkLbl><span class=atkIcon>⚔</span>SHOW ACTIVE ATTACKS</span>
  <label class=ios-switch>
    <input type=checkbox id=showatk>
    <span class=slider></span>
  </label>
  <div class=atkhdr id=atkhdr style="display:none;flex:1 1 auto;padding:0;background:transparent;border:none">
    <span class=atktitle>⚔ Active attacks</span>
    <span id=atkcount class=stat>—</span>
    <select id=atkfilter onchange=renderAttacks()>
      <option value=all>all</option>
      <option value=alliance>alliance only</option>
      <option value=solo>monarch only</option>
      <option value=boss>Boss/Event only</option>
      <option value=svs>enemy SVS server only</option>
    </select>
    <label style="display:inline;font-size:11px;color:#94a3b8;margin-left:8px"><input type=checkbox id=show_helps onchange=loadAttacks()> include alliance helps</label>
    <label style="display:inline;font-size:11px;color:#94a3b8;margin-left:8px"><input type=checkbox id=excl_own onchange=renderAttacks() checked> exclude our alliance</label>
    <span class=stat style="margin-left:auto;font-size:11px">⏱ live countdown</span>
  </div>
</div>
<div id=atkpanel style="display:none">
  <!-- 🎯 Focus zone movido a ⚙ Settings → Scanner Settings. -->
  <table class=atktbl>
   <thead><tr><th>Phase</th><th>Attacker</th><th>Target</th><th>Coords</th><th>Origin</th><th>ETA</th></tr></thead>
   <tbody id=atkrows></tbody>
  </table>
</div>
<div class=filt-toggle onclick="toggleMonFilters()"><span class=fcaret id=mf_caret>+</span> Filters &amp; search</div>
<div class="box mon-filters" id=mon_filters>
 <div style="flex:1;min-width:340px"><label>Filter by monster/boss (empty = all)</label>
   <div class=ms id=ms>
     <div class=msbox id=msbox><input id=msin placeholder="search boss... (Cerberus, Sphinx, Bayard, Lava Turtle...)" autocomplete=off></div>
     <div class=dd id=dd></div>
   </div></div>
 <div><label>Center X</label><input type=number id=cx value=458></div>
 <div><label>Center Y</label><input type=number id=cy value=568></div>
 <div><label>Radius (tiles, empty=whole map)</label><input type=number id=rad placeholder="whole map"></div>
 <div style="align-self:flex-end"><label class="chk" style="display:flex;align-items:center;gap:6px;cursor:pointer;font-size:13px;color:#cbd5e1"><input type=checkbox id=only_summons onchange=load()> 🟢 Show only Summons</label></div>
 <div><button onclick=load()>Apply</button>
   <button class=sec onclick="SEL.clear();FAM.clear();renderChips();loadFamilies();load();savePills()">Clear</button></div>
</div>
<div class=sortbar><label>Sort</label><select id=sort onchange="setSort(this.value,'desc');load()">
   <option value=newest selected>Recent</option><option value=level>Level</option>
   <option value=power>Power</option><option value=dist>Distance</option></select>
 <button class=sec onclick=copyVisibleRows() title="Copia los items visibles al portapapeles: X,Y Name Level (Distance)" style="margin-left:8px;font-size:12px;padding:5px 12px">📋 Copy rows</button><span id=copyMsg style="margin-left:8px;color:#4caf50;font-size:12px;font-weight:600;display:none">Rows Copied!</span>
 <span class=limbox><label>Limit</label><select id=lim onchange=load()>
   <option>20</option><option>50</option><option>100</option>
   <option>200</option><option>400</option><option selected>500</option>
   <option value="0">No limit</option></select></span></div>
<table><thead><tr><th>Name</th>
 <th class=sortableH data-sort=level onclick="onHeaderSort('level')">Level <span class=arrow></span></th>
 <th class=sortableH data-sort=power onclick="onHeaderSort('power')">Power <span class=arrow></span></th>
 <th>Family</th><th>X</th><th>Y</th>
 <th class=sortableH data-sort=dist onclick="onHeaderSort('dist')">Dist <span class=arrow></span></th>
 <th>Copy</th>
 <th class=sortableH data-sort=newest onclick="onHeaderSort('newest')">Discovered <span class=arrow></span></th>
</tr></thead>
<tbody id=rows></tbody></table>
<div class=pager id=pager style="display:none">
  <button id=pg_first onclick="gotoPage(1)">«</button>
  <button id=pg_prev  onclick="gotoPage(curPage-1)">← prev</button>
  <span class=info id=pginfo>—</span>
  <button id=pg_next  onclick="gotoPage(curPage+1)">next →</button>
  <button id=pg_last  onclick="gotoPage(99999)">»</button>
</div>
</div><!-- /view_mon -->

<div id=view_players style="display:none">
<div class=filt-toggle onclick="toggleFilters('pl_filters','plf_caret')"><span class=fcaret id=plf_caret>+</span> Filters · Players</div>
<div class="box pl-filters" id=pl_filters>
 <div style="flex:1;min-width:200px"><label>🔎 Search (name / uid / tag)</label>
   <input id=p_search placeholder="partial match, case-insensitive" style="width:100%;max-width:280px" oninput=onPlayerSearchInput() onkeydown="if(event.key==='Enter')loadPlayers()"></div>
 <div><label>Level min</label><input type=number id=p_lvmin placeholder=1 style="width:70px"></div>
 <div><label>Level max</label><input type=number id=p_lvmax placeholder=45 style="width:70px"></div>
 <div><label>Power min (M)</label><input type=number id=p_pwmin placeholder=0 style="width:80px"></div>
 <div><label>Power max (M)</label><input type=number id=p_pwmax placeholder="∞" style="width:80px"></div>
 <div><label>Bubble</label>
   <select id=p_shield><option value="">All</option><option value="yes">With bubble</option><option value="no">No bubble</option></select></div>
 <div><label>Alliance (tag)</label><input id=p_tag placeholder="e.g. ABC" style="width:110px"></div>
 <div><label>Center X</label><input type=number id=p_cx value=458></div>
 <div><label>Center Y</label><input type=number id=p_cy value=568></div>
 <div><label>Radius (tiles, empty=whole map)</label><input type=number id=p_rad placeholder="whole map"></div>
 <div class=sortkeep><label>Sort</label><select id=p_sort>
   <option value=power>Power</option><option value=level>Level</option>
   <option value=dist>Distance</option><option value=newest>Recent</option>
   <option value=eta>Bubble ETA</option></select></div>
 <div><label>Limit</label><input type=number id=p_lim value=500></div>
 <div><label>Max age (min, empty=cycle)</label><input type=number id=p_maxage placeholder=10 style="width:70px"></div>
 <div><button onclick=loadPlayers()>Apply</button>
   <button class=sec onclick="['p_search','p_lvmin','p_lvmax','p_pwmin','p_pwmax','p_tag'].forEach(i=>document.getElementById(i).value='');document.getElementById('p_shield').value='';loadPlayers()">Clear</button></div>
</div>
<table><thead><tr>
 <th>Name</th>
 <th class=sortable onclick="setPlayerSort('tag')">Alliance <span id=p_sorth_tag class=sortar></span></th>
 <th class=sortable onclick="setPlayerSort('level')">Level <span id=p_sorth_level class=sortar></span></th>
 <th class=sortable onclick="setPlayerSort('castle')">Castle <span id=p_sorth_castle class=sortar></span></th>
 <th class=sortable onclick="setPlayerSort('power')">Power <span id=p_sorth_power class=sortar></span></th>
 <th class=sortable onclick="setPlayerSort('bubble')">Bubble <span id=p_sorth_bubble class=sortar></span></th>
 <th>X</th><th>Y</th>
 <th class=sortable onclick="setPlayerSort('dist')">Dist <span id=p_sorth_dist class=sortar></span></th>
 <th>Copy</th>
</tr></thead>
<tbody id=prows></tbody></table>
</div><!-- /view_players -->

<div id=view_resources style="display:none">
<div class=filt-toggle onclick="toggleFilters('res_filters','resf_caret')"><span class=fcaret id=resf_caret>+</span> Filters · Resources</div>
<div class="box res-filters" id=res_filters>
 <div><label>Resource type</label><select id=r_type>
   <option value="">All</option>
   <option value="1">Farm (food)</option>
   <option value="2">Sawmill (wood)</option>
   <option value="3">Quarry (stone)</option>
   <option value="4">Iron Mine</option>
 </select></div>
 <div><label>Occupied</label><select id=r_occupied>
   <option value="">All</option>
   <option value="free">Free</option>
   <option value="ext">Occupied (no my alliance)</option>
   <option value="mine">Occupied (in my alliance)</option>
 </select></div>
 <div><label>Alliance tag</label><input id=r_alliance placeholder="e.g. NBB" style="width:90px"></div>
 <div><label>Level min</label><input type=number id=r_lvmin placeholder=1 style="width:70px"></div>
 <div><label>Level max</label><input type=number id=r_lvmax placeholder=15 style="width:70px"></div>
 <div><label>Center X</label><input type=number id=r_cx value=458></div>
 <div><label>Center Y</label><input type=number id=r_cy value=568></div>
 <div><label>Radius (tiles, empty=whole map)</label><input type=number id=r_rad placeholder="whole map"></div>
 <div class=sortkeep><label>Sort</label><select id=r_sort>
   <option value=newest>Recent</option><option value=level>Level</option>
   <option value=type>Type</option><option value=available>Available</option>
   <option value=dist>Distance</option></select></div>
 <div><label>Limit</label><input type=number id=r_lim value=500></div>
 <div><button onclick=loadResources()>Apply</button>
   <button class=sec onclick="['r_lvmin','r_lvmax','r_alliance'].forEach(i=>document.getElementById(i).value='');['r_type','r_occupied'].forEach(i=>document.getElementById(i).value='');loadResources()">Clear</button></div>
</div>
<table><thead><tr>
 <th>Name</th>
 <th class=sortable onclick="setResSort('level')">Level <span id=r_sorth_level class=sortar></span></th>
 <th class=sortable onclick="setResSort('available')">Available <span id=r_sorth_available class=sortar></span></th>
 <th>Occupied</th><th>X</th><th>Y</th>
 <th class=sortable onclick="setResSort('dist')">Dist <span id=r_sorth_dist class=sortar></span></th>
 <th>Copy</th>
 <th class=sortable onclick="setResSort('discovered')">Discovered <span id=r_sorth_discovered class=sortar></span></th>
</tr></thead>
<tbody id=rrows></tbody></table>
<div class=pager>
  <button id=rpg_first onclick="gotoPageRes(1)">«</button>
  <button id=rpg_prev  onclick="gotoPageRes(rCurPage-1)">← prev</button>
  <span class=info id=rpginfo>—</span>
  <button id=rpg_next  onclick="gotoPageRes(rCurPage+1)">next →</button>
  <button id=rpg_last  onclick="gotoPageRes(99999)">»</button>
</div>
</div><!-- /view_resources -->

<div id=view_farm style="display:none">
<div class=filt-toggle onclick="toggleFilters('farm_filters','farmf_caret')"><span class=fcaret id=farmf_caret>−</span> Filters · Farm targets</div>
<div class="box farm-filters" id=farm_filters>
 <div><label>Group</label><select id=farm_groups>
   <option value="Boss||Event" selected>Boss + Event</option>
   <option value="Boss">Boss only</option>
   <option value="Event">Event only</option>
   <option value="Shadow of Dawn">Shadow of Dawn</option>
   <option value="all">All monsters</option>
 </select></div>
 <div><label>Level min</label><input type=number id=farm_lvmin placeholder=1 style="width:70px"></div>
 <div><label>Level max</label><input type=number id=farm_lvmax placeholder=15 style="width:70px"></div>
 <div><label>Center X</label><input type=number id=farm_cx value=458></div>
 <div><label>Center Y</label><input type=number id=farm_cy value=568></div>
 <div><label>Radius (tiles, empty=whole map)</label><input type=number id=farm_rad placeholder="whole map"></div>
 <div style="display:flex;align-items:center;gap:6px;margin-top:18px">
   <input type=checkbox id=farm_free><label for=farm_free style="margin:0">Only free (no rally on it)</label></div>
 <div class=sortkeep><label>Sort</label><select id=farm_sort>
   <option value=score selected>Best (score)</option>
   <option value=reward>Reward (per kill)</option>
   <option value=vps>Value / stamina</option>
   <option value=dist>Nearest</option>
   <option value=level>Level</option>
   <option value=power>Power</option></select></div>
 <div><label>Limit</label><input type=number id=farm_lim value=200></div>
 <div><button onclick=loadFarm()>Apply</button>
   <button class=sec onclick="['farm_lvmin','farm_lvmax','farm_rad'].forEach(i=>document.getElementById(i).value='');document.getElementById('farm_groups').value='Boss||Event';document.getElementById('farm_free').checked=false;loadFarm()">Clear</button>
   <button class=sec onclick=copyFarmRows() style="margin-left:4px">📋 Copy</button>
   <span id=farmCopyMsg style="margin-left:6px;color:#4caf50;font-size:12px;font-weight:600;display:none">Copied!</span>
 </div>
</div>
<div class=sortbar><span class=stat id=farm_stat>—</span>
 <span class=stat style="margin-left:12px;font-size:11px;color:#7c8595">Score = proximity (more kills/hour) + level. Reward = per-kill value from the game's static loot table (exp+honor+credits+items). Val/stam = reward ÷ stamina cost. 🎯 = real loot observed by W/E (calibration). First-kill resource bonuses are one-time and excluded from per-kill reward.</span></div>
<table><thead><tr>
 <th>#</th><th>Name</th>
 <th class=sortable onclick="setFarmSort('level')">Level <span id=farm_sorth_level class=sortar></span></th>
 <th class=sortable onclick="setFarmSort('power')">Power <span id=farm_sorth_power class=sortar></span></th>
 <th>Group</th>
 <th>X</th><th>Y</th>
 <th class=sortable onclick="setFarmSort('dist')">Dist <span id=farm_sorth_dist class=sortar></span></th>
 <th>Status</th>
 <th class=sortable onclick="setFarmSort('score')">Score <span id=farm_sorth_score class=sortar></span></th>
 <th class=sortable onclick="setFarmSort('reward')">Reward <span id=farm_sorth_reward class=sortar></span></th>
 <th class=sortable onclick="setFarmSort('vps')">Val/stam <span id=farm_sorth_vps class=sortar></span></th>
 <th>Copy</th>
</tr></thead>
<tbody id=farmrows></tbody></table>
</div><!-- /view_farm -->

<div id=view_relics style="display:none">
<div class=filt-toggle onclick="toggleFilters('rel_filters','relf_caret')"><span class=fcaret id=relf_caret>+</span> Filters · Relics</div>
<div class="box rel-filters" id=rel_filters>
 <div><label>Kind</label><select id=rl_kind>
   <option value="">All</option>
   <option value="boss">Boss (rallyable)</option>
   <option value="pyramid">Pyramid</option>
   <option value="altar">Altar</option>
   <option value="ruins">Ruins</option>
 </select></div>
 <div><label>Occupied</label><select id=rl_occupied>
   <option value="">All</option>
   <option value="free">Free</option>
   <option value="ext">Occupied (no my alliance)</option>
   <option value="mine">Occupied (in my alliance)</option>
 </select></div>
 <div><label>Alliance tag</label><input id=rl_alliance placeholder="e.g. NBB" style="width:90px"></div>
 <div><label>Level min</label><input type=number id=rl_lvmin placeholder=1 style="width:70px"></div>
 <div><label>Level max</label><input type=number id=rl_lvmax placeholder=15 style="width:70px"></div>
 <div><label>Center X</label><input type=number id=rl_cx value=458></div>
 <div><label>Center Y</label><input type=number id=rl_cy value=568></div>
 <div><label>Radius (tiles, empty=whole map)</label><input type=number id=rl_rad placeholder="whole map"></div>
 <div class=sortkeep><label>Sort</label><select id=rl_sort>
   <option value=level>Level</option><option value=kind>Kind</option>
   <option value=dist>Distance</option><option value=newest>Recent</option></select></div>
 <div><label>Limit</label><input type=number id=rl_lim value=500></div>
 <div><button onclick=loadRelics()>Apply</button>
   <button class=sec onclick="['rl_lvmin','rl_lvmax','rl_alliance'].forEach(i=>document.getElementById(i).value='');['rl_kind','rl_occupied'].forEach(i=>document.getElementById(i).value='');loadRelics()">Clear</button></div>
</div>
<table><thead><tr><th>Name</th><th>Kind</th><th>Level</th><th>Power</th><th>Occupied</th><th>X</th><th>Y</th><th>Dist</th><th>Copy</th><th>Discovered</th></tr></thead>
<tbody id=rlrows></tbody></table>
<div class=pager>
  <button id=rlpg_first onclick="gotoPageRel(1)">«</button>
  <button id=rlpg_prev  onclick="gotoPageRel(rlCurPage-1)">← prev</button>
  <span class=info id=rlpginfo>—</span>
  <button id=rlpg_next  onclick="gotoPageRel(rlCurPage+1)">next →</button>
  <button id=rlpg_last  onclick="gotoPageRel(99999)">»</button>
</div>
</div><!-- /view_relics -->

<div id=view_arctic style="display:none">
<div class=filt-toggle onclick="toggleFilters('arc_filters','arcf_caret')"><span class=fcaret id=arcf_caret>+</span> Filters · Arctic Barbarians</div>
<div class="box arc-filters" id=arc_filters>
 <div><label>Occupied</label><select id=ar_occupied>
   <option value="">All</option>
   <option value="free">Free</option>
   <option value="ext">Occupied (no my alliance)</option>
   <option value="mine">Occupied (in my alliance)</option>
 </select></div>
 <div><label>Alliance tag</label><input id=ar_alliance placeholder="e.g. NBB" style="width:90px"></div>
 <div><label>Level min</label><input type=number id=ar_lvmin placeholder=1 style="width:70px"></div>
 <div><label>Level max</label><input type=number id=ar_lvmax placeholder=15 style="width:70px"></div>
 <div><label>Center X</label><input type=number id=ar_cx value=458></div>
 <div><label>Center Y</label><input type=number id=ar_cy value=568></div>
 <div><label>Radius (tiles, empty=whole map)</label><input type=number id=ar_rad placeholder="whole map"></div>
 <div class=sortkeep><label>Sort</label><select id=ar_sort>
   <option value=level>Level</option><option value=power>Power</option>
   <option value=dist>Distance</option><option value=newest>Recent</option></select></div>
 <div><label>Limit</label><input type=number id=ar_lim value=500></div>
 <div><button onclick=loadArctic()>Apply</button>
   <button class=sec onclick="['ar_lvmin','ar_lvmax','ar_alliance'].forEach(i=>document.getElementById(i).value='');document.getElementById('ar_occupied').value='';loadArctic()">Clear</button></div>
</div>
<table><thead><tr><th>Name</th><th>Kind</th><th>Level</th><th>Power</th><th>Occupied</th><th>X</th><th>Y</th><th>Dist</th><th>Copy</th><th>Discovered</th></tr></thead>
<tbody id=arrows></tbody></table>
<div class=pager>
  <button id=arpg_first onclick="gotoPageArc(1)">«</button>
  <button id=arpg_prev  onclick="gotoPageArc(arCurPage-1)">← prev</button>
  <span class=info id=arpginfo>—</span>
  <button id=arpg_next  onclick="gotoPageArc(arCurPage+1)">next →</button>
  <button id=arpg_last  onclick="gotoPageArc(99999)">»</button>
</div>
</div><!-- /view_arctic -->

<div id=view_subcities style="display:none">
<div class=filt-toggle onclick="toggleFilters('sc_filters','scf_caret')"><span class=fcaret id=scf_caret>+</span> Filters · Subcities</div>
<div class="box sc-filters" id=sc_filters>
 <div><label>Quality</label><select id=sc_quality>
   <option value="">All</option>
   <option value="white">White</option>
   <option value="green">Green</option>
   <option value="blue">Blue</option>
   <option value="purple">Purple</option>
   <option value="gold">Gold</option>
   <option value="red">Red</option>
 </select></div>
 <div><label>Culture</label><select id=sc_culture>
   <option value="">All</option>
   <option value="1">European</option>
   <option value="2">American</option>
   <option value="3">Chinese</option>
   <option value="4">Russian</option>
   <option value="5">Korean</option>
   <option value="6">Arabia</option>
   <option value="7">Japan</option>
   <option value="10010">Vietnam (Famous)</option>
 </select></div>
 <div><label>Occupied</label><select id=sc_occupied>
   <option value="">All</option>
   <option value="free">Free (NPC)</option>
   <option value="ext">Occupied (no my alliance)</option>
   <option value="mine">Occupied (in my alliance)</option>
 </select></div>
 <div><label>Alliance tag</label><input id=sc_alliance placeholder="e.g. NBB" style="width:90px"></div>
 <div><label>Level min</label><input type=number id=sc_lvmin placeholder=1 style="width:70px"></div>
 <div><label>Level max</label><input type=number id=sc_lvmax placeholder=15 style="width:70px"></div>
 <div><label>Center X</label><input type=number id=sc_cx value=458></div>
 <div><label>Center Y</label><input type=number id=sc_cy value=568></div>
 <div><label>Radius (tiles, empty=whole map)</label><input type=number id=sc_rad placeholder="whole map"></div>
 <div class=sortkeep><label>Sort</label><select id=sc_sort>
   <option value=power selected>Power</option><option value=level>Level</option>
   <option value=quality>Quality</option><option value=dist>Distance</option>
   <option value=newest>Recent</option></select></div>
 <div><label>Limit</label><input type=number id=sc_lim value=500></div>
 <div><button onclick=loadSubcities()>Apply</button>
   <button class=sec onclick="['sc_lvmin','sc_lvmax','sc_alliance'].forEach(i=>document.getElementById(i).value='');['sc_quality','sc_culture','sc_occupied'].forEach(i=>document.getElementById(i).value='');loadSubcities()">Clear</button></div>
</div>
<table><thead><tr>
 <th>Name</th>
 <th class=sortable onclick="setScSort('quality')">Quality <span id=sc_sorth_quality class=sortar></span></th>
 <th class=sortable onclick="setScSort('culture')">Culture <span id=sc_sorth_culture class=sortar></span></th>
 <th class=sortable onclick="setScSort('level')">Level <span id=sc_sorth_level class=sortar></span></th>
 <th class=sortable onclick="setScSort('power')">Power <span id=sc_sorth_power class=sortar></span></th>
 <th class=sortable onclick="setScSort('occupied')">Occupied <span id=sc_sorth_occupied class=sortar></span></th>
 <th class=sortable onclick="setScSort('bubble')">Bubble <span id=sc_sorth_bubble class=sortar></span></th>
 <th>X</th><th>Y</th>
 <th class=sortable onclick="setScSort('dist')">Dist <span id=sc_sorth_dist class=sortar></span></th>
 <th>Copy</th>
 <th class=sortable onclick="setScSort('discovered')">Discovered <span id=sc_sorth_discovered class=sortar></span></th>
</tr></thead>
<tbody id=screws></tbody></table>
<div class=pager>
  <button id=scpg_first onclick="gotoPageSc(1)">«</button>
  <button id=scpg_prev  onclick="gotoPageSc(scCurPage-1)">← prev</button>
  <span class=info id=scpginfo>—</span>
  <button id=scpg_next  onclick="gotoPageSc(scCurPage+1)">next →</button>
  <button id=scpg_last  onclick="gotoPageSc(99999)">»</button>
</div>
</div><!-- /view_subcities -->

<div id=view_respawns style="display:none">
 <div class=famgrp><h3 class=famhdr onclick="toggleFamGrp(this)"><span class=fcaret>−</span> Boss Monsters</h3><div class=famrow id=fams_boss_rs></div></div>
 <div class=famgrp><h3 class=famhdr onclick="toggleFamGrp(this)"><span class=fcaret>+</span> Event Monsters</h3><div class=famrow id=fams_event_rs style="display:none"></div></div>
 <div class=famgrp><h3 class=famhdr onclick="toggleFamGrp(this)" style="color:#a78bfa"><span class=fcaret>+</span> Shadow of Dawn</h3><div class=famrow id=fams_shadow_rs style="display:none"></div></div>
 <div class=famgrp style="padding-bottom:10px"><h3 class=famhdr onclick="toggleFamGrp(this)" style="color:#fcd34d"><span class=fcaret>+</span> Other</h3><div class=famrow id=fams_other_rs style="display:none"></div></div>
 <div class=filt-toggle onclick="toggleFilters('rs_filters','rsf_caret')"><span class=fcaret id=rsf_caret>+</span> Filters · Respawns</div>
 <div class="box rs-filters" id=rs_filters>
  <div class=sortkeep><label>Sort</label><select id=rs_sort onchange="setRsSort(this.value)">
    <option value="">Default</option><option value=predicted>Predicted in</option>
    <option value=level>Level</option><option value=last>Last spawn</option>
    <option value=cycle>Cycle</option><option value=reliability>Reliability</option></select></div>
  <div style="flex:1;min-width:340px"><label>Filter by monster/boss (empty = all)</label>
    <div class=ms id=ms_rs>
      <div class=msbox id=msbox_rs><input id=msin_rs placeholder="search boss... (Cerberus, Sphinx, Bayard, Lava Turtle...)" autocomplete=off></div>
      <div class=dd id=dd_rs></div>
    </div></div>
  <div><label>State</label>
    <select id=rs_state onchange=loadRespawns()>
     <option value="empty">Empty tiles only</option>
     <option value="all">All (includes alive ones)</option>
    </select></div>
  <div><label>Min samples</label>
    <select id=rs_ms onchange=loadRespawns()>
     <option value=2 selected>2+ (basic median)</option>
     <option value=3>3+ (solid median)</option>
     <option value=4>4+ (high confidence)</option>
    </select></div>
  <div><label>Show "Normal" monsters</label>
    <select id=rs_normal onchange=loadRespawns()>
     <option value="0">No (Boss/Event only)</option>
     <option value="1" selected>Yes (all)</option>
    </select></div>
  <div><button onclick=loadRespawns()>Apply</button>
    <button class=sec onclick="SEL_RS.clear();FAM_RS.clear();renderChipsRs();loadFamiliesRs();loadRespawns()">Clear</button></div>
 </div>
 <div class=sortbar><span class=stat id=rs_stat>—</span></div>
 <table class=t><thead><tr>
   <th>Monster</th>
   <th class=sortable onclick="setRsSort('level')">Lv <span id=rs_sorth_level class=sortar></span></th>
   <th>X</th><th>Y</th>
   <th class=sortable onclick="setRsSort('last')">Last spawn <span id=rs_sorth_last class=sortar></span></th>
   <th class=sortable onclick="setRsSort('cycle')">Cycle ~ <span id=rs_sorth_cycle class=sortar></span></th>
   <th class=sortable onclick="setRsSort('predicted')">Predicted in <span id=rs_sorth_predicted class=sortar></span></th>
   <th>Samples</th>
   <th class=sortable title="Reliability of the prediction: 'high' = ≥3 samples + std deviation <30%; 'medium' = ≥3 samples; 'low' = <3 samples (broad estimate)" onclick="setRsSort('reliability')">Reliability <span id=rs_sorth_reliability class=sortar></span></th>
   <th>Copy</th>
 </tr></thead><tbody id=rs_rows></tbody></table>
</div><!-- /view_respawns -->

<div id=view_relocations style="display:none">
 <div class=panel style="margin-bottom:8px">
  <label>Last</label>
  <select id=rl_age>
   <option value=1>1h</option>
   <option value=6 selected>6h</option>
   <option value=24>24h</option>
   <option value=72>3 days</option>
  </select>
  <label>Center</label> X:<input id=rloc_cx type=number style="width:60px" value=458>
  Y:<input id=rloc_cy type=number style="width:60px" value=568>
  Radius:<input id=rloc_rad type=number style="width:60px" placeholder="whole map">
  <button onclick=loadRelocations()>Refresh</button>
  <button class=sec onclick="document.getElementById('rloc_cx').value='';document.getElementById('rloc_cy').value='';document.getElementById('rloc_rad').value='';loadRelocations()">Clear</button>
  <span class=stat id=rl_stat style="margin-left:12px">—</span>
 </div>
 <table class=t><thead><tr>
   <th>When</th><th>Player</th><th>Alliance</th><th>From → To</th><th>Hop dist</th><th>Total hops</th><th>Copy dest</th>
 </tr></thead><tbody id=rl_rows></tbody></table>
</div><!-- /view_relocations -->

<div id=view_watchlist style="display:none">
 <div class=atktoggle style="gap:6px">
  <button class="tab on" id=wl_sub_members_btn onclick="wlSubView('members')" style="border-radius:6px">⭐ Members</button>
  <button class=tab id=wl_sub_cheat_btn onclick="wlSubView('cheat')" style="border-radius:6px;display:none">🚩 Cheat scan <span id=wl_cheat_count style="opacity:.7;font-size:11px"></span></button>
  <button class=tab id=wl_sub_steal_btn onclick="wlSubView('steal')" style="border-radius:6px;display:none">🥷 Rally steals <span id=wl_steal_count style="opacity:.7;font-size:11px"></span></button>
 </div>
 <div id=wl_sub_members>
 <div class=box>
  <div style="flex:1;min-width:260px"><label>🔎 Search (name / uid / tag)</label>
    <input id=wl_search placeholder="partial match, case-insensitive" style="width:100%;max-width:260px" oninput=onWatchlistSearchInput()></div>
  <div style="flex:1;min-width:280px">
   <div class=stat style="font-size:12px;line-height:1.5">
    Auto-add applies rules every 60s on PLAYERS. Manual = clicked ⭐. AUTO = added by rules.
    Top-right banner triggers when a watchlist shield expires in &lt;10min.
   </div>
  </div>
  <div><label>Default sort (when no column active)</label>
   <select id=wl_sort onchange=loadWatchlistView()>
    <option value=eta selected>Shield ETA (asc)</option>
    <option value=level>Castle level (desc)</option>
    <option value=name>Name</option>
    <option value=added>Recently added</option>
   </select></div>
  <div><label>&nbsp;</label><button onclick=loadWatchlistView()>Refresh</button></div>
  <div><label>&nbsp;</label><button class=sec onclick=toggleWlRules()>⚙ Auto-add rules</button></div>
  <div><label>Bulk remove by alliance</label>
   <span style="display:flex;gap:6px;align-items:center">
    <select id=wl_bulk_tag style="min-width:110px"><option value="">— tag —</option></select>
    <button class=sec onclick=bulkRemoveTag() style="background:#7f1d1d;border-color:#b91c1c" title="Elimina del watchlist TODOS los jugadores de esta alianza">🗑 Remove all</button>
   </span></div>
 </div>
 <!-- Panel de reglas auto-watchlist (colapsable) -->
 <div id=wl_rules_panel class=box style="display:none;background:#0f1115;border-top:1px solid #1d2330">
  <div><label><input type=checkbox id=wlr_enabled> Enabled</label></div>
  <div style="flex:1;min-width:240px"><label>Enemy tags (comma-separated, case-insensitive)</label>
    <input id=wlr_enemy placeholder="NBB, OPS, ABC" style="width:100%;max-width:320px"></div>
  <div style="flex:1;min-width:240px"><label>Exclude tags (your alliance + allies)</label>
    <input id=wlr_exclude placeholder="MVP" style="width:100%;max-width:240px"></div>
  <div><label>Min Castle</label><input type=number id=wlr_min_castle style="width:70px"></div>
  <div><label>Min Power (M)</label><input type=number id=wlr_min_pwr style="width:90px"></div>
  <div><label><input type=checkbox id=wlr_require_shield> Only players with shield ever seen</label></div>
  <div><label>&nbsp;</label><button onclick=saveWlRules()>Save rules</button>
    <span id=wlr_status class=stat style="margin-left:8px"></span></div>
 </div>
 <div id=wl_alerts_box style="padding:0 18px"></div>
 <table class=t><thead><tr>
   <th class=sortable onclick="setWlSort('name')">Player <span id=wl_sorth_name class=sortar></span></th>
   <th class=sortable onclick="setWlSort('tag')">Alliance <span id=wl_sorth_tag class=sortar></span></th>
   <th class=sortable onclick="setWlSort('castle')">Castle <span id=wl_sorth_castle class=sortar></span></th>
   <th>Coords</th>
   <th class=sortable onclick="setWlSort('shield')">Shield <span id=wl_sorth_shield class=sortar></span></th>
   <th class=sortable onclick="setWlSort('confidence')">Confidence <span id=wl_sorth_confidence class=sortar></span></th>
   <th class=sortable onclick="setWlSort('source')">Src <span id=wl_sorth_source class=sortar></span></th>
   <th>Note</th><th>Action</th>
 </tr></thead><tbody id=wl_rows></tbody></table>
 </div><!-- /wl_sub_members -->
 <div id=wl_sub_cheat style="display:none">
  <div class=box>
   <div class=stat style="font-size:12px;line-height:1.5;flex:1;min-width:280px">
     Behavioral anomaly ranking — passive map observation (marches / relocations / shields over time).
     Higher score = more consistent with automation / 24-7 / scripting. Click a row for the full report.
   </div>
   <div><label>Min actions</label>
     <select id=cs_min onchange=loadCheatScan()>
       <option value=100 selected>≥100</option><option value=300>≥300</option>
       <option value=1000>≥1000</option><option value=30>≥30</option>
     </select></div>
   <div><label><input type=checkbox id=cs_only_wl onchange=loadCheatScan()> Watchlist only</label></div>
   <div><label>&nbsp;</label><button onclick=loadCheatScan()>Refresh</button></div>
  </div>
  <table class=t><thead><tr>
    <th>#</th><th>Player</th><th>Alliance</th><th>Srv</th><th>Score</th><th>Level</th>
    <th>Actions</th><th>/day</th><th>Window</th><th>No-sleep days</th><th>Tel/day</th><th>Flags</th><th>Coords</th>
  </tr></thead><tbody id=cs_rows></tbody></table>
 </div><!-- /wl_sub_cheat -->
 <div id=wl_sub_steal style="display:none">
  <div class=box>
   <div class=stat style="font-size:12px;line-height:1.5;flex:1;min-width:300px">
     Rally contention — monster tiles where <b>our alliance</b> and an enemy rallied the SAME target within a time window.
     "Steals" = the enemy rallied at the same time or AFTER us (disputing/sniping our target). Observed passively; competitive overlap, not proof of cheating.
   </div>
   <div><label>Window</label>
     <select id=rc_win onchange=loadRallyContention()>
       <option value=15>15 min</option><option value=30 selected>30 min</option><option value=60>60 min</option>
     </select></div>
   <div><label>Lookback</label>
     <select id=rc_age onchange=loadRallyContention()>
       <option value=24>24 h</option><option value=48 selected>48 h</option><option value=168>7 d</option>
     </select></div>
   <div><label>&nbsp;</label><button onclick=loadRallyContention()>Refresh</button></div>
   <div class=stat id=rc_meta style="font-size:11px;align-self:center"></div>
  </div>
  <h4 style="margin:10px 16px 4px;color:#fca5a5">By alliance</h4>
  <table class=t><thead><tr><th>#</th><th>Alliance</th><th>Steals</th><th>Contested</th></tr></thead><tbody id=rc_ally_rows></tbody></table>
  <h4 style="margin:14px 16px 4px;color:#fca5a5">By player</h4>
  <table class=t><thead><tr>
    <th>#</th><th>Player</th><th>Alliance</th><th>Steals</th><th>Contested</th><th>Recent (target · gap · ours)</th>
  </tr></thead><tbody id=rc_player_rows></tbody></table>
 </div><!-- /wl_sub_steal -->
</div><!-- /view_watchlist -->

<!-- SVS: Hit-list de objetivos enemigos + config de enemigo -->
<div id=view_svs style="display:none">
 <div class=box id=svs_enemy_cfg style="background:#2a0f12;border-bottom:2px solid #7f1d1d;display:block">
  <div style="display:flex;align-items:center;gap:14px;flex-wrap:wrap">
   <b style="color:#f87171">⚔️ SVS Enemy</b>
   <span style="font-size:12px;color:#94a3b8">Our server: <b id=svs_our_server style="color:#34d399">—</b></span>
   <label style="font-size:12px;color:#94a3b8">Enemy server <input type=number id=svs_enemy_server placeholder=auto style="width:90px"></label>
   <label style="font-size:12px;color:#94a3b8">Enemy Alliance Tag: <input type=text id=svs_enemy_tags placeholder="TAG1, TAG2..." style="width:180px"></label>
   <button class=sec onclick="saveEnemyConfig()" style="background:#7f1d1d;border-color:#b91c1c">Save</button>
   <span id=svs_cfg_msg style="font-size:12px"></span>
  </div>
  <div style="display:flex;align-items:center;gap:14px;flex-wrap:wrap;margin-top:8px;font-size:12px;color:#94a3b8">
   <label title="Notify when an enemy loses/is about to lose their bubble, AND when an enemy relocates (esp. jumping to our server)"><input type=checkbox id=svs_alerts_on onchange="svsToggleAlerts()"> 🔔 Enemy alerts</label>
   <label>Pre-alert <input type=number id=svs_pre_min min=1 max=120 style="width:55px" onchange="localStorage.setItem('svs_pre_min', this.value)"> min before</label>
   <label style="display:flex;align-items:center;gap:6px" title="Show OS/browser notifications for alerts">Browser notifications
    <span class=ios-switch><input type=checkbox id=svs_notif_toggle onchange="svsToggleNotif()"><span class=slider></span></span>
   </label>
  </div>
  <div id=svs_foreign style="margin-top:8px;font-size:12px;color:#94a3b8"></div>
 </div>
 <div style="display:flex;gap:6px;margin:8px 0;align-items:center;flex-wrap:wrap">
  <button class="tab on" id=svs_sub_hl_btn onclick="svsSubView('hitlist')" style="border-radius:6px">🎯 Hit-list</button>
  <button class=tab id=svs_sub_def_btn onclick="svsSubView('defense')" style="border-radius:6px">🛡️ Defense <span id=svs_def_count style="opacity:.7;font-size:11px"></span></button>
  <button class=tab id=svs_sub_reinf_btn onclick="svsSubView('reinforce')" style="border-radius:6px">🛟 Reinforce <span id=svs_reinf_count style="opacity:.7;font-size:11px"></span></button>
  <button id=svs_share_btn onclick="sendSvsShare()" title="Whisper the top open (shieldless) enemies to chat" style="margin-left:auto;background:#14532d;border-color:#16a34a">📤 Share top targets</button>
  <span id=svs_share_msg style="font-size:12px;color:#34d399;min-width:60px"></span>
 </div>
 <div id=svs_sub_hitlist>
 <div class=filt-toggle onclick="toggleFilters('svs_filters','svsf_caret')"><span class=fcaret id=svsf_caret>−</span> Filters · Hit-list</div>
 <div class="box" id=svs_filters>
  <div><label>State</label><select id=svs_state><option value="">All</option><option value=open>No shield</option><option value=drops_soon>Dropping soon</option><option value=shielded>Shielded</option></select></div>
  <div><label>Center X</label><input type=number id=svs_cx placeholder=optional style="width:80px"></div>
  <div><label>Center Y</label><input type=number id=svs_cy style="width:80px"></div>
  <div><label>Sort</label><select id=svs_sort><option value=score>Score</option><option value=power>Power</option><option value=eta>Shield drop</option><option value=dist>Distance</option><option value=active>Activity</option></select></div>
  <div><label title="Hide enemies marked as 'Gone' (offline + stale position)">Hide gone</label>
   <input type=checkbox id=svs_hide_gone onchange="localStorage.setItem('svs_hide_gone',this.checked?'1':'0');loadSvs()"></div>
  <div><button onclick=loadSvs()>Apply</button>
   <button class=sec onclick="document.getElementById('svs_state').value='';document.getElementById('svs_cx').value='';document.getElementById('svs_cy').value='';document.getElementById('svs_sort').value='score';loadSvs()">Clear</button></div>
 </div>
 <table><thead><tr>
   <th>#</th><th>Name</th><th>Alliance</th><th title="Still on the field or gone back to their server? On field=seen on map recently · Online=member-list online · Gone=offline+stale position">Presence</th><th>Bubble</th><th>Bubble ETA</th><th>Power</th><th>Coords</th><th>Dist</th><th>Active</th><th title="Daily window when this enemy is usually OFFLINE (least active) = best time to hit. ✅ = they're likely offline right now.">Best hit</th><th>Score</th>
 </tr></thead><tbody id=svs_rows></tbody></table>
 </div><!-- /svs_sub_hitlist -->
 <div id=svs_sub_defense style="display:none">
  <div style="font-size:12px;color:#94a3b8;margin:6px 2px">Incoming attacks on your alliance's castles, sorted by ETA (most imminent on top).</div>
  <table><thead><tr>
    <th>ETA</th><th>Target (ally)</th><th>Coords</th><th>Attacker</th><th>Atk power</th><th>Phase</th><th>Marches</th>
  </tr></thead><tbody id=svs_def_rows></tbody></table>
 </div><!-- /svs_sub_defense -->
 <div id=svs_sub_reinforce style="display:none">
  <div style="font-size:12px;color:#94a3b8;margin:6px 2px">Enemy castles receiving alliance reinforcements (they're stacking up). Count = number of incoming help marches (not troop size — scout for real garrison strength).</div>
  <table><thead><tr>
    <th>Enemy target</th><th>Alliance</th><th>Power</th><th>Coords</th><th>Reinf. marches</th><th>Next ETA</th>
  </tr></thead><tbody id=svs_reinf_rows></tbody></table>
 </div><!-- /svs_sub_reinforce -->
</div><!-- /view_svs -->

<!-- V3 RESEARCH: Protocol Stats dashboard -->
<div id=view_protocol style="display:none">
 <div class=box style="background:#1e1b4b;border-bottom:2px solid #581c87">
  <div style="flex:1">
   <h3 style="margin:0;color:#c084fc">🔬 Protocol Tracer (V3 Research)</h3>
   <div class=stat style="font-size:12px;line-height:1.6">
    Live count of UP/DOWN messages intercepted via NetworkManager hooks
    (SendProtobufMessage + DecodeCallback). Refreshes every 2s.
    Trigger actions in-game (open Mail, Rankings, etc.) and watch which RPCs fire.
   </div>
  </div>
  <div><label>Sort by</label>
   <select id=proto_sort onchange=loadProtoStats()>
    <option value=total_desc selected>Total count (desc)</option>
    <option value=name>Name</option>
    <option value=w_desc>W count (desc)</option>
    <option value=e_desc>E count (desc)</option>
   </select></div>
  <div><label>Direction</label>
   <select id=proto_dir onchange=loadProtoStats()>
    <option value=both selected>Both</option>
    <option value=up>UP only</option>
    <option value=down>DOWN only</option>
   </select></div>
  <div><label>&nbsp;</label><button onclick=loadProtoStats()>Refresh</button></div>
  <div><label>&nbsp;</label><button class=sec onclick=resetProtoCounts()>Reset counts (server-side)</button></div>
 </div>
 <div style="padding:8px 18px;display:flex;gap:30px;font-size:13px">
  <div><b style="color:#c084fc">Total UP:</b> <span id=proto_total_up>—</span></div>
  <div><b style="color:#7dd3fc">Total DOWN:</b> <span id=proto_total_down>—</span></div>
  <div><b style="color:#fbbf24">Rate (last 2s):</b> <span id=proto_rate>—</span></div>
 </div>
 <table class=t><thead><tr>
   <th>Direction</th>
   <th>Message type</th>
   <th>W count</th>
   <th>E count</th>
   <th>Total</th>
 </tr></thead><tbody id=proto_rows></tbody></table>
</div><!-- /view_protocol -->

<!-- V3 RESEARCH: Server Stats dashboard -->
<div id=view_intel style="display:none">
 <div class=box style="background:#022c22;border-bottom:2px solid #065f46">
  <h3 style="margin:0;color:#34d399">📊 <span id=intel_server_title>Server</span> Intelligence Dashboard</h3>
  <div style="margin-left:auto">
    <button onclick=loadKingdomIntel()>Refresh</button>
  </div>
 </div>
 <!-- Top KPI cards -->
 <div id=intel_kpis style="display:flex;flex-wrap:wrap;gap:10px;padding:10px 18px"></div>
 <!-- Two-column layout -->
 <div style="display:flex;flex-wrap:wrap;gap:18px;padding:0 18px 18px">
  <div style="flex:1;min-width:340px">
    <h4 style="color:#fbbf24">⚔️ Top 10 Power</h4>
    <table class=t style="font-size:12px"><thead><tr><th>#</th><th>Player</th><th>Tag</th><th>Power</th></tr></thead><tbody id=intel_top_power></tbody></table>
  </div>
  <div style="flex:1;min-width:340px">
    <h4 style="color:#ef4444">💀 Top 10 Kills</h4>
    <table class=t style="font-size:12px"><thead><tr><th>#</th><th>Player</th><th>Tag</th><th>Kills</th></tr></thead><tbody id=intel_top_kills></tbody></table>
  </div>
 </div>
 <!-- ═══════════ PvE INTELLIGENCE ═══════════ -->
 <div class=box style="background:#0f1f15;border-top:2px solid #15803d;border-bottom:2px solid #15803d;margin-top:6px">
  <h3 style="margin:0;color:#86efac">🐉 PvE Intelligence — Caza de Bosses <span id=pvp_totals class=stat style="font-weight:normal;margin-left:10px"></span></h3>
  <div style="margin-left:auto"><button onclick=loadPvpStats()>Refresh</button></div>
  <div style="width:100%;font-size:11px;color:#7c8595">Reportes de monstruo (PvE) capturados de tus cuentas. El bloque PvP de la derecha solo se llena con battle reports de jugador compartidos por aliados de la alianza.</div>
 </div>
 <div style="display:flex;flex-wrap:wrap;gap:18px;padding:0 18px 18px">
  <div style="flex:1;min-width:340px">
    <h4 style="color:#86efac">🐉 Top Bosses cazados</h4>
    <table class=t style="font-size:12px"><thead><tr><th>Boss</th><th>Grupo</th><th>Kills</th></tr></thead><tbody id=pvp_pve_bosses></tbody></table>
    <h4 style="color:#34d399;margin-top:14px">🏆 Top Cazadores (por experiencia)</h4>
    <table class=t style="font-size:12px"><thead><tr><th>Player</th><th>Tag</th><th>Kills</th><th>Experiencia</th><th>Poder despl.</th></tr></thead><tbody id=pvp_pve_damage></tbody></table>
    <h4 style="color:#a78bfa;margin-top:14px">🔍 Scout Intel (formaciones defensivas)</h4>
    <table class=t style="font-size:11px"><thead><tr><th>Enemy</th><th>Army</th><th>Wall</th><th>Def Gen</th><th>Coords</th></tr></thead><tbody id=pvp_scout></tbody></table>
  </div>
  <div style="flex:1;min-width:340px">
    <div style="font-size:11px;color:#fca5a5;margin-bottom:4px;border-left:3px solid #7f1d1d;padding-left:6px">⚔ PvP — solo de reportes de jugador compartidos por aliados</div>
    <h4 style="color:#fca5a5">🏰 Alliance War (atacantes)</h4>
    <table class=t style="font-size:12px"><thead><tr><th>Tag</th><th>Battles</th><th>Wins</th><th>Win%</th><th>Power destroyed</th></tr></thead><tbody id=pvp_alliance_war></tbody></table>
    <h4 style="color:#34d399;margin-top:14px">💀 Top Attackers (K/D)</h4>
    <table class=t style="font-size:12px"><thead><tr><th>Player</th><th>Battles</th><th>Kills</th><th>K/D</th></tr></thead><tbody id=pvp_top_attackers></tbody></table>
    <h4 style="color:#fca5a5;margin-top:14px">📜 Recent Battles</h4>
    <table class=t style="font-size:11px"><thead><tr><th>When</th><th>Attacker → Defender</th><th>Result</th><th>Kills</th><th>Coords</th></tr></thead><tbody id=pvp_recent></tbody></table>
    <h4 style="color:#fbbf24;margin-top:14px">⚔️ Top Attack Generals (meta)</h4>
    <table class=t style="font-size:12px"><thead><tr><th>General</th><th>Uses</th><th>Wins</th><th>Win%</th></tr></thead><tbody id=pvp_atk_generals></tbody></table>
    <h4 style="color:#93c5fd;margin-top:14px">🛡️ Top Defense Generals (vistos)</h4>
    <table class=t style="font-size:12px"><thead><tr><th>General</th><th>Seen</th></tr></thead><tbody id=pvp_def_generals></tbody></table>
  </div>
 </div>
</div><!-- /view_intel -->

<!-- Top banner para alertas globales de shield expiring (siempre visible) -->
<div id=wl_global_alerts style="position:fixed;top:8px;right:8px;width:340px;z-index:99;pointer-events:none">
 <div id=wl_alerts_inner style="pointer-events:auto"></div>
</div>

<script>
function fmtPow(p){p=+p||0;if(p>=1e6)return (p/1e6).toFixed(1)+'M';if(p>=1e3)return (p/1e3).toFixed(0)+'K';return p}
// Mapea el nombre de grupo (Boss/Event/Shadow of Dawn/Other/Normal) a su clase CSS (g-*)
function grpClass(g){return ({'Boss':'boss','Event':'event','Shadow of Dawn':'shadow','Other':'other','Normal':'normal'})[g]||'normal';}
// Colapsar/expandir un bloque de filtros (solo móvil). Genérico por id de box + caret.
function toggleFilters(boxId, caretId){
 const b=document.getElementById(boxId); if(!b) return;
 const open=b.classList.toggle('open');
 const c=document.getElementById(caretId); if(c) c.textContent = open ? '−' : '+';
}
// Colapsar/expandir el bloque de filtros del tab Monsters (solo móvil)
function toggleMonFilters(){ toggleFilters('mon_filters','mf_caret'); }
// Mostrar/ocultar la línea de estadísticas del header (results | total map | sweep | cfg | catalog)
function toggleStat(){
 const s=document.getElementById('stat'); if(!s) return;
 s.style.display = (s.style.display==='none') ? 'inline' : 'none';
}
// Colapsar/expandir una sección de familias (Monsters). Boss abierta por defecto.
function toggleFamGrp(h){
 const row = h.nextElementSibling;
 const caret = h.querySelector('.fcaret');
 const open = (row.style.display === 'none');
 row.style.display = open ? '' : 'none';
 if(caret) caret.textContent = open ? '−' : '+';
}
// Móvil: la sección "Boss Monsters" arranca COLAPSADA (en desktop queda abierta).
// Aplica tanto al tab Monsters (#fams_boss) como al tab Respawns (#fams_boss_rs).
(function(){
 try{
   if(!window.matchMedia('(max-width: 768px)').matches) return;
   for(const id of ['fams_boss','fams_boss_rs']){
     const r=document.getElementById(id); if(!r) continue;
     r.style.display='none';
     const h=r.previousElementSibling, c=h&&h.querySelector('.fcaret');
     if(c) c.textContent='+';
   }
 }catch(e){}
})();
function fmtAgo(s){s=Math.max(0,Math.round(+s||0));
 if(s<60)return s+' sec ago';
 if(s<3600){const m=Math.floor(s/60);return m+' min ago';}
 const h=Math.floor(s/3600),m=Math.floor((s%3600)/60);return h+'h '+m+'m ago';}
// versión compacta para móvil: "30sec ago" / "5min ago" / "2h ago"
function fmtAgoShort(s){s=Math.max(0,Math.round(+s||0));
 if(s<60)return s+'sec ago';
 if(s<3600)return Math.round(s/60)+'min ago';
 if(s<86400)return Math.round(s/3600)+'h ago';
 return Math.round(s/86400)+'d ago';}
function fmtDur(s){if(s==null)return '—';s=+s;
 if(s<60)return s.toFixed(1)+'s';
 const m=Math.floor(s/60),ss=Math.round(s%60);return m+'m '+ss+'s';}
function swStr(j){const w=j.sw_W||{},e=j.sw_E||{};
 return `W no.${w.num||0} (${fmtDur(w.last_secs)}) · E no.${e.num||0} (${fmtDur(e.last_secs)})`;}
function fmtEta(s){s=Math.max(0,Math.round(+s||0));if(s<=0)return '';
 if(s<60)return s+'s';
 if(s<3600){const m=Math.floor(s/60),ss=s%60;return m+'min '+ss+'s';}
 const h=Math.floor(s/3600),m=Math.floor((s%3600)/60),ss=s%60;
 return h+'h '+m+'min '+ss+'s';}
function fmtAtk(x){if(!x.atk)return '<span class=stat>—</span>';
 const nm=(x.atk_name||'').replace(/"/g,'&quot;');
 const tag=(x.atk_tag||'').replace(/"/g,'&quot;');
 const who = nm ? ((tag?('['+tag+'] '):'')+nm) : ('u'+x.atk_uid);
 const eta = fmtEta(x.atk_eta);
 const n=+x.atk_count||1;
 const tip=`uid=${x.atk_uid} ali=${x.atk_guild} mty=${x.atk_mty} eta=${x.atk_eta}s n=${n} hp=${x.atk_hp} st=${x.atk_st}`;
 if(x.atk==='rally'){
  if(n>1){
    // rally de alianza (mty 19/20/21): lider + N-1 miembros
    const extra = n-1;
    return `<span class="atk atk-rally" title="${tip}">⚔⚔ ${who} +${extra}${eta?(' · '+eta):''}</span>`;
  }
  // rally solo (mty=2 monster, mty=43 scout)
  return `<span class="atk atk-rally" title="${tip}">⚔ ${who}${eta?(' · '+eta):''}</span>`;
 }
 if(x.atk==='owned') return `<span class="atk atk-owned" title="${tip}">${who}</span>`;
 return `<span class="atk atk-damaged" title="${tip}">DAMAGED</span>`;}
let CAT=[];                       // catalogo [{id,name,level,label,power}]
const WATCHLIST_UIDS = new Set(); // uids del watchlist (live, sincronizado con /api/watchlist)
async function loadWatchlist(){
 try {
   const r = await fetch('/api/watchlist',{cache:'no-store'});
   const j = await r.json();
   WATCHLIST_UIDS.clear();
   for(const row of (j.rows||[])) WATCHLIST_UIDS.add(row.uid);
 } catch(e) {}
}
async function toggleWatchlist(uid, el){
 const isOn = WATCHLIST_UIDS.has(uid);
 try {
   if (isOn) {
     await fetch('/api/watchlist?uid='+uid, {method:'DELETE'});
     WATCHLIST_UIDS.delete(uid);
     if(el){ el.textContent='☆'; el.classList.remove('wstar-on'); el.title='Add to watchlist'; }
   } else {
     await fetch('/api/watchlist', {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({uid})});
     WATCHLIST_UIDS.add(uid);
     if(el){ el.textContent='★'; el.classList.add('wstar-on'); el.title='Remove from watchlist'; }
   }
   updateWatchlistCount();
 } catch(e){ console.error(e); }
}
function updateWatchlistCount(){
 const el = document.getElementById('wl_count');
 if(el) el.textContent = WATCHLIST_UIDS.size > 0 ? '('+WATCHLIST_UIDS.size+')' : '';
}
function shieldBadgeFor(row){
 // Comparte la logica con la tabla Players pero contra una row de /api/watchlist.
 const eta = row.shield_eta || 0;
 const c = row.shield_confidence || '';
 const tier = row.shield_tier || 0;
 if (eta > 0) {
   const txt = fmtLeft(eta);
   if (c === 'exact') return `<span class=sh title="Exact ETA via ${row.shield_src||'?'}">YES · ${txt}</span>`;
   if (c === 'inferred_sku') return `<span class=sh_inf title="Inferred from this player's usual shield SKU">~${txt}</span>`;
   if (c === 'inferred_history' || c === 'inferred_default') return `<span class=sh_inf title="Inferred (${c})">~${txt}</span>`;
   if (c === 'newbie_inferred') return `<span class=sh_newb title="Newbie max bound">NEWBIE ≤${txt}</span>`;
   return `<span class=sh>YES · ${txt}</span>`;
 }
 if (tier === 2) return `<span class=sh_newb>NEWBIE</span>`;
 if (tier === 1) return `<span class=sh_unk title="No transition seen">BUBBLE (?)</span>`;
 return `<span class=nosh>no</span>`;
}
let _watchlistSearchTimer = null;
function onWatchlistSearchInput(){
 // debounce 250ms: filtrado client-side, no necesita request al server
 if(_watchlistSearchTimer) clearTimeout(_watchlistSearchTimer);
 _watchlistSearchTimer = setTimeout(loadWatchlistView, 250);
}
// ---- Sort columnas Watchlist ----
let WL_SORT_COL = null;
let WL_SORT_DIR = 'desc';
function setWlSort(col){
 if(WL_SORT_COL === col){
   if(WL_SORT_DIR === 'desc') WL_SORT_DIR = 'asc';
   else { WL_SORT_COL = null; WL_SORT_DIR = 'desc'; }
 } else {
   WL_SORT_COL = col; WL_SORT_DIR = 'desc';
 }
 updateWlSortArrows();
 loadWatchlistView();
}
function updateWlSortArrows(){
 for(const c of ['name','tag','castle','shield','confidence','source']){
   const el = document.getElementById('wl_sorth_'+c);
   if(!el) continue;
   if(c === WL_SORT_COL){
     el.textContent = WL_SORT_DIR === 'desc' ? '▼' : '▲';
     el.classList.add('active');
   } else {
     el.textContent = '⇅';
     el.classList.remove('active');
   }
 }
}
function getWlRowSortKey(row, col){
 if(col === 'name')   return (row.name||'').toLowerCase();
 if(col === 'tag')    return (row.tag||'').toLowerCase();
 if(col === 'castle') return +row.castle || 0;
 if(col === 'shield') {
   // mismo criterio que Players: exact eta > tier > nada
   const e = +row.shield_eta || 0;
   const t = +row.shield_tier || 0;
   if(e > 0) return e + 1e9;
   if(t > 0) return t * 1000;
   return 0;
 }
 if(col === 'confidence') return row.shield_confidence || 'zzz';   // sin conf al final
 if(col === 'source')     return row.source || 'zzz';
 return 0;
}

// Borrado en bloque del watchlist por alianza (tag seleccionado en el dropdown)
async function bulkRemoveTag(){
 const sel = document.getElementById('wl_bulk_tag');
 const tag = sel ? (sel.value||'').trim() : '';
 if(!tag){ alert('Select an alliance tag first.'); return; }
 // count actual para el confirm
 let n = 0;
 try {
   const opt = sel.options[sel.selectedIndex];
   const m = (opt.textContent||'').match(/\((\d+)\)/);
   n = m ? parseInt(m[1],10) : 0;
 } catch(e){}
 if(!confirm(`Remove all ${n||''} players of alliance [${tag}] from the watchlist?\\nThis does NOT blacklist them — auto-rules may re-add if [${tag}] matches enemy_tags.`)) return;
 try {
   const r = await fetch('/api/watchlist?tag='+encodeURIComponent(tag), {method:'DELETE'});
   const j = await r.json();
   loadWatchlistView();
 } catch(e){ alert('Error: '+e); }
}

// ── Watchlist subtabs: Members / Cheat scan ──────────────────────────────────
let WL_SUB='members';
function wlSubView(which){
 WL_SUB=which;
 document.getElementById('wl_sub_members').style.display=(which==='members'?'':'none');
 document.getElementById('wl_sub_cheat').style.display=(which==='cheat'?'':'none');
 document.getElementById('wl_sub_steal').style.display=(which==='steal'?'':'none');
 document.getElementById('wl_sub_members_btn').classList.toggle('on', which==='members');
 document.getElementById('wl_sub_cheat_btn').classList.toggle('on', which==='cheat');
 document.getElementById('wl_sub_steal_btn').classList.toggle('on', which==='steal');
 if(which==='cheat') loadCheatScan();
 else if(which==='steal') loadRallyContention();
 else loadWatchlistView();
}
async function loadRallyContention(){
 const win=(document.getElementById('rc_win')||{}).value||30;
 const age=(document.getElementById('rc_age')||{}).value||48;
 let j;
 try{ const r=await fetch('/api/rally_contention?window_m='+win+'&max_age_h='+age+'&_='+Date.now(),{cache:'no-store'}); j=await r.json(); }
 catch(e){ return; }
 const cc=document.getElementById('wl_steal_count'); if(cc) cc.textContent=j.contested_total?('('+j.contested_total+')'):'';
 const meta=document.getElementById('rc_meta'); if(meta) meta.textContent=(j.contested_total||0)+' contested events · rally log '+(j.rally_log_size||0)+' · window '+(j.window_m)+'m / '+(j.max_age_h)+'h';
 const at=document.getElementById('rc_ally_rows'); at.innerHTML='';
 if(!(j.alliances||[]).length){ at.innerHTML='<tr><td colspan=4 class=stat style="padding:12px">No contested rallies yet (accumulates as both alliances rally the same monsters).</td></tr>'; }
 (j.alliances||[]).forEach((a,i)=>{ const tr=document.createElement('tr');
   tr.innerHTML=`<td>${i+1}</td><td>[${csEsc(a.tag||('g'+a.gid))}]</td><td><b style="color:#fca5a5">${a.steals}</b></td><td>${a.count}</td>`; at.appendChild(tr); });
 const pt=document.getElementById('rc_player_rows'); pt.innerHTML='';
 (j.players||[]).forEach((p,i)=>{ const tr=document.createElement('tr'); tr.style.cursor='pointer'; tr.onclick=()=>openPlayerActivity(p.uid, true);
   const ex=(p.examples||[]).slice(0,4).map(e=>`${csEsc(e.tname||(e.x+','+e.y))}${e.lv?(' L'+e.lv):''} ·${e.gap>=0?'+':''}${e.gap}s ·${csEsc(e.our)}`).join(' | ');
   tr.innerHTML=`<td>${i+1}</td><td><span class=plink>${csEsc(p.name)}</span></td><td>[${csEsc(p.tag||('g'+p.gid))}]</td>`
    +`<td><b style="color:#fca5a5">${p.steals}</b></td><td>${p.count}</td><td style="font-size:11px">${ex}</td>`; pt.appendChild(tr); });
 if(!(j.players||[]).length) pt.innerHTML='<tr><td colspan=6 class=stat style="padding:12px">—</td></tr>';
}
function csEsc(s){ return String(s==null?'':s).replace(/[&<>"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c])); }
async function loadCheatScan(){
 const tb=document.getElementById('cs_rows'); if(!tb) return;
 const minT=(document.getElementById('cs_min')||{}).value||100;
 const onlyWl=(document.getElementById('cs_only_wl')||{}).checked?'&watchlist=1':'';
 tb.innerHTML='<tr><td colspan=13 class=stat style="padding:14px">Scanning…</td></tr>';
 let j;
 try{ const r=await fetch('/api/cheat_scan?min_total='+minT+onlyWl+'&_='+Date.now(),{cache:'no-store'}); j=await r.json(); }
 catch(e){ tb.innerHTML='<tr><td colspan=13 class=stat>Error</td></tr>'; return; }
 const cc=document.getElementById('wl_cheat_count'); if(cc) cc.textContent=j.total?('('+j.total+')'):'';
 if(!(j.rows||[]).length){ tb.innerHTML='<tr><td colspan=13 class=stat style="padding:14px">No flagged players (need observed activity over time).</td></tr>'; return; }
 const LV={high:['HIGH','#f87171'],medium:['MED','#fbbf24'],low:['LOW','#94a3b8']};
 const FK={no_sleep:'no-sleep',always_on:'24/7',teleport:'teleport',volume:'volume'};
 tb.innerHTML='';
 j.rows.forEach((r,i)=>{
  const [lbl,col]=LV[r.level]||['?','#cbd5e1'];
  const flags=(r.flag_keys||[]).map(k=>FK[k]||k).join(', ');
  const tr=document.createElement('tr'); tr.style.cursor='pointer';
  tr.onclick=()=>openPlayerActivity(r.uid, true);
  if(r.level==='high') tr.style.background='rgba(248,113,113,0.10)';
  tr.innerHTML=`<td>${i+1}</td>`
   +`<td><span class=plink>${csEsc(r.name)}</span>${r.in_watchlist?' ⭐':''}</td>`
   +`<td>${r.tag?('['+csEsc(r.tag)+']'):''}</td><td>${r.sv||''}</td>`
   +`<td><b style="color:${col}">${r.score}</b></td>`
   +`<td><span class=tag style="color:${col};border-color:${col}66">${lbl}</span></td>`
   +`<td>${r.total}</td><td>${r.per_day}</td><td>${r.span_days}d</td>`
   +`<td>${(r.days_no_sleep!=null?r.days_no_sleep:'?')}/${(r.days_observed!=null?r.days_observed:'?')}</td>`
   +`<td>${(r.clean_reloc_rate!=null?r.clean_reloc_rate:0)}</td>`
   +`<td style="font-size:11px">${flags}</td>`
   +`<td><span class=cp onclick="event.stopPropagation();navigator.clipboard.writeText('${r.x},${r.y}')">${r.x},${r.y} &#8682;</span></td>`;
  tb.appendChild(tr);
 });
}
async function loadWatchlistView(){
 try {
   const r = await fetch('/api/watchlist?_='+Date.now(), {cache:'no-store'});
   const j = await r.json();
   // sincroniza set local
   WATCHLIST_UIDS.clear();
   for(const row of (j.rows||[])) WATCHLIST_UIDS.add(row.uid);
   updateWatchlistCount();
   updateWlSortArrows();
   // Poblar dropdown de bulk-remove con los tags presentes + count
   try {
     const tagCount = {};
     for(const row of (j.rows||[])){ const t=(row.tag||'').toUpperCase(); if(t) tagCount[t]=(tagCount[t]||0)+1; }
     const sel = document.getElementById('wl_bulk_tag');
     if(sel){
       const prev = sel.value;
       const tags = Object.keys(tagCount).sort();
       sel.innerHTML = '<option value="">— tag —</option>' +
         tags.map(t=>`<option value="${t}">${t} (${tagCount[t]})</option>`).join('');
       if(tags.includes(prev)) sel.value = prev;   // preservar selección si sigue existiendo
     }
   } catch(e){}
   const sort = document.getElementById('wl_sort').value;
   const qRaw = (document.getElementById('wl_search').value || '').trim().toLowerCase();
   const qInt = qRaw && /^\d+$/.test(qRaw) ? parseInt(qRaw, 10) : null;
   let rows = (j.rows||[]).slice();
   const totalBefore = rows.length;
   // Search filter (parcial en name+tag, exacto en uid si q es numero)
   if(qRaw){
     rows = rows.filter(r =>
       (r.name||'').toLowerCase().includes(qRaw) ||
       (r.tag||'').toLowerCase().includes(qRaw) ||
       (qInt !== null && r.uid === qInt)
     );
   }
   // Sort por columna activa (sobrescribe el dropdown default)
   if(WL_SORT_COL){
     const dir = WL_SORT_DIR === 'asc' ? 1 : -1;
     rows.sort((a,b)=>{
       const av = getWlRowSortKey(a, WL_SORT_COL);
       const bv = getWlRowSortKey(b, WL_SORT_COL);
       if(typeof av === 'string') return av.localeCompare(bv) * dir;
       return (av - bv) * dir;
     });
   }
   else if(sort==='eta') rows.sort((a,b)=>(a.shield_eta||9e9) - (b.shield_eta||9e9));
   else if(sort==='level') rows.sort((a,b)=>(b.castle||0)-(a.castle||0));
   else if(sort==='name') rows.sort((a,b)=>(a.name||'').localeCompare(b.name||''));
   const tb = document.getElementById('wl_rows'); tb.innerHTML='';
   if(rows.length === 0){
     const msg = qRaw
       ? `No matches for "${qRaw}" (${totalBefore} total in watchlist).`
       : 'Watchlist empty. Click ★ on any player in the Players tab to add them.';
     tb.innerHTML = `<tr><td colspan=9 class=stat style="padding:14px">${msg}</td></tr>`;
   } else {
     for(const x of rows){
       const tr = document.createElement('tr');
       const sh = shieldBadgeFor(x);
       const tg = x.tag ? `<span class=tag>${csEsc(x.tag)}</span>` : '<span class=stat>—</span>';
       const coords = (x.x||x.y) ? `${x.x},${x.y}` : '<span class=stat>?</span>';
       const cp = (x.x||x.y) ? `<span class=cp onclick="navigator.clipboard.writeText('${x.x},${x.y}')">⎘</span>` : '';
       const srcBadge = (x.source==='auto')
         ? '<span class=src_auto title="Auto-added by rules">AUTO</span>'
         : '<span class=src_manual title="Added manually via ⭐">MANUAL</span>';
       tr.innerHTML = `
         <td>${csEsc(x.name||'?')} <span class=stat>uid=${x.uid}</span></td>
         <td>${tg}</td>
         <td>Lv${x.level||0}/C${x.castle||0}</td>
         <td>${coords} ${cp}</td>
         <td>${sh}</td>
         <td><span class=stat title="${csEsc(x.shield_src||'')}">${x.shield_confidence||'—'}</span></td>
         <td>${srcBadge}</td>
         <td><span class=stat>${csEsc(x.note||'')}</span></td>
         <td><span class="wstar wstar-on" title="Remove from watchlist" onclick="toggleWatchlist(${x.uid},this);setTimeout(loadWatchlistView,200)">★</span></td>`;
       tb.appendChild(tr);
     }
   }
 } catch(e){ console.error('loadWatchlistView fail', e); }
}
// Banner global de alertas de shield expirando (cada 5s)
let ALERT_SEEN = {};            // uid -> ts(ms) en que se mostró por primera vez
const ALERT_TTL_MS = 15000;    // auto-ocultar cada alerta 15s tras aparecer
function dismissAlert(uid, el){ ALERT_SEEN[uid] = 0; if(el&&el.parentElement) el.parentElement.remove(); }
async function checkWatchlistAlerts(){
 const inner = document.getElementById('wl_alerts_inner');
 // Respeta las preferencias de notificación del usuario: este banner global de
 // burbujas vigiladas que caen es del tipo "Bubbles" (+ master). Si está OFF, no se muestra.
 if(!notifOn('bubbles')){ if(inner) inner.innerHTML=''; return; }
 try {
   const r = await fetch('/api/watchlist?_='+Date.now(), {cache:'no-store'});
   const j = await r.json();
   const nowMs = Date.now();
   const ALERT_THRESHOLD = 600; // 10 min
   const expiring = (j.rows||[]).filter(r => r.shield_eta > 0 && r.shield_eta <= ALERT_THRESHOLD);
   // purgar las vistas que ya no expiran → si el escudo vuelve a entrar en rango, reaparece
   const cur = new Set(expiring.map(r => r.uid));
   for(const k of Object.keys(ALERT_SEEN)){ if(!cur.has(+k)) delete ALERT_SEEN[k]; }
   // mostrar solo las que llevan <=15s desde su primera aparición
   const visible = [];
   for(const r of expiring){
     if(ALERT_SEEN[r.uid] == null) ALERT_SEEN[r.uid] = nowMs;
     if(nowMs - ALERT_SEEN[r.uid] <= ALERT_TTL_MS) visible.push(r);
   }
   if(visible.length === 0){ inner.innerHTML=''; return; }
   inner.innerHTML = visible.map(r => {
     const conf = r.shield_confidence === 'exact' ? 'exact' : 'inferred';
     return `<div class=wbanner>
       <div>
         <div class=who>⚠ ${csEsc(r.name||'?')}${r.tag?(' ['+csEsc(r.tag)+']'):''}</div>
         <div class=meta>${conf} · expires in ${fmtLeft(r.shield_eta)} · (${r.x},${r.y})</div>
       </div>
       <button class=sec style="padding:4px 8px;font-size:11px" onclick="dismissAlert(${r.uid}, this)">dismiss</button>
     </div>`;
   }).join('');
 } catch(e){}
}
setInterval(checkWatchlistAlerts, 5000);
// fire initial check tras 2s para que load_watchlist se haya hecho
setTimeout(checkWatchlistAlerts, 2000);

// ============================================================================
// V3 RESEARCH: Protocol Stats dashboard
// ============================================================================
let _lastProtoSnap = null;
let _lastProtoTs = 0;
async function loadProtoStats(){
 try {
   const r = await fetch('/api/protocol_stats?_='+Date.now(), {cache:'no-store'});
   const j = await r.json();
   const halves = j.halves || {};
   // Combine W + E
   const combined = {};
   let totalUp = 0, totalDown = 0;
   for(const h of ['W','E']){
     const ps = halves[h] || {up:{}, down:{}, total_up:0, total_down:0};
     totalUp += ps.total_up || 0;
     totalDown += ps.total_down || 0;
     for(const [name, cnt] of Object.entries(ps.up || {})){
       combined[name] = combined[name] || {name, up_w:0, up_e:0, down_w:0, down_e:0};
       if(h === 'W') combined[name].up_w = cnt; else combined[name].up_e = cnt;
     }
     for(const [name, cnt] of Object.entries(ps.down || {})){
       combined[name] = combined[name] || {name, up_w:0, up_e:0, down_w:0, down_e:0};
       if(h === 'W') combined[name].down_w = cnt; else combined[name].down_e = cnt;
     }
   }
   // Rate calculation
   const now = Date.now();
   let rateStr = '—';
   if(_lastProtoSnap && _lastProtoTs > 0){
     const dt = (now - _lastProtoTs) / 1000;
     const dUp = totalUp - _lastProtoSnap.totalUp;
     const dDown = totalDown - _lastProtoSnap.totalDown;
     rateStr = `${(dUp/dt).toFixed(1)} UP/s · ${(dDown/dt).toFixed(1)} DOWN/s`;
   }
   _lastProtoSnap = {totalUp, totalDown};
   _lastProtoTs = now;

   document.getElementById('proto_total_up').textContent = totalUp;
   document.getElementById('proto_total_down').textContent = totalDown;
   document.getElementById('proto_rate').textContent = rateStr;

   // Filter direction
   const dir = document.getElementById('proto_dir').value;
   let rows = Object.values(combined);
   rows = rows.map(r => ({
     ...r,
     up_total: r.up_w + r.up_e,
     down_total: r.down_w + r.down_e,
     total: r.up_w + r.up_e + r.down_w + r.down_e,
   }));
   if(dir === 'up')   rows = rows.filter(r => r.up_total > 0);
   if(dir === 'down') rows = rows.filter(r => r.down_total > 0);

   // Sort
   const sort = document.getElementById('proto_sort').value;
   if(sort === 'total_desc') rows.sort((a,b)=>b.total - a.total);
   else if(sort === 'name')   rows.sort((a,b)=>a.name.localeCompare(b.name));
   else if(sort === 'w_desc') rows.sort((a,b)=>(b.up_w+b.down_w) - (a.up_w+a.down_w));
   else if(sort === 'e_desc') rows.sort((a,b)=>(b.up_e+b.down_e) - (a.up_e+a.down_e));

   const tb = document.getElementById('proto_rows'); tb.innerHTML = '';
   for(const r of rows){
     // Para cada mensaje, mostrar 2 filas si tiene UP Y DOWN, o 1 si solo una direccion
     const hasUp = r.up_total > 0;
     const hasDown = r.down_total > 0;
     if((dir === 'both' || dir === 'up') && hasUp){
       const tr = document.createElement('tr');
       tr.innerHTML = `<td><span class=tag style="background:#581c87;color:#f3e8ff">UP↑</span></td>
         <td><code style="color:#c084fc">${r.name}</code></td>
         <td class=lvl>${r.up_w || '·'}</td>
         <td class=lvl>${r.up_e || '·'}</td>
         <td class=lvl><b>${r.up_total}</b></td>`;
       tb.appendChild(tr);
     }
     if((dir === 'both' || dir === 'down') && hasDown){
       const tr = document.createElement('tr');
       tr.innerHTML = `<td><span class=tag style="background:#075985;color:#bae6fd">DOWN↓</span></td>
         <td><code style="color:#7dd3fc">${r.name}</code></td>
         <td class=lvl>${r.down_w || '·'}</td>
         <td class=lvl>${r.down_e || '·'}</td>
         <td class=lvl><b>${r.down_total}</b></td>`;
       tb.appendChild(tr);
     }
   }
 } catch(e){
   console.error('loadProtoStats fail', e);
   document.getElementById('proto_rate').textContent = 'error: ' + e;
 }
}
function resetProtoCounts(){
 if(!confirm('Reset all protocol counters server-side? (no destructive, just zeros)')) return;
 fetch('/api/protocol_stats/reset', {method:'POST'}).then(()=>loadProtoStats());
}

// ============================================================================
// V3 RESEARCH: Server Stats dashboard (kingdom_intel endpoint)
// ============================================================================
async function loadKingdomIntel(){
 try {
   const r = await fetch('/api/kingdom_intel?_='+Date.now(), {cache:'no-store'});
   const j = await r.json();
   // V3: server_id auto-detectado en el title del dashboard
   try {
     const sr = await fetch('/api/scanner', {cache:'no-store'});
     const sj = await sr.json();
     const titleEl = document.getElementById('intel_server_title');
     if (titleEl) titleEl.textContent = sj.server_id > 0 ? ('Server #' + sj.server_id) : 'Server (detecting…)';
   } catch {}
   // KPI cards
   const t = j.totals || {};
   const kpiHtml = [
     ['#0f766e', 'Players total', t.players],
     ['#1d4ed8', 'Active (C10+)', t.players_active_c10plus],
     ['#a16207', 'Competitive (C20+)', t.players_competitive_c20plus],
     ['#7f1d1d', 'Top tier (C30+)', t.players_top_c30plus],
     ['#581c87', 'Alliances', t.alliances_count],
     ['#374151', 'Pool size', t.objs_in_pool],
     ['#075985', 'Subcities', t.subcities],
     ['#7c2d12', 'Active marches', t.marches_active],
   ].map(([color, lbl, val]) => `
     <div style="background:${color};color:#fff;padding:10px 14px;border-radius:8px;min-width:130px;text-align:center">
       <div style="font-size:11px;opacity:.8">${lbl}</div>
       <div style="font-size:22px;font-weight:bold">${(val||0).toLocaleString()}</div>
     </div>`).join('');
   document.getElementById('intel_kpis').innerHTML = kpiHtml;

   // Rankings tables
   const rk = j.rankings || {};
   const renderRanking = (rows, fmtVal) => {
     if(!rows || rows.length === 0) return '<tr><td colspan=4 class=stat>no data (waiting for ranking poll…)</td></tr>';
     return rows.map((r, i) => `
       <tr><td>${i+1}</td><td>${r.name}</td><td><span class=tag>${r.tag||'—'}</span></td><td><b>${fmtVal(r.value)}</b></td></tr>`).join('');
   };
   document.getElementById('intel_top_power').innerHTML = renderRanking(rk.power, v => fmtPow(v));
   document.getElementById('intel_top_kills').innerHTML = renderRanking(rk.kills, v => (v||0).toLocaleString());
 } catch(e) {
   console.error('loadKingdomIntel fail', e);
 }
}

// ════════ PvP Intelligence ════════
async function loadPvpStats(){
 try {
   const r = await fetch('/api/pvp_stats?_='+Date.now(), {cache:'no-store'});
   const j = await r.json();
   const t = j.totals || {};
   const tot = document.getElementById('pvp_totals');
   if(tot) tot.textContent = `· ${t.battles||0} battles · ${t.scouts||0} scouts · ${t.pve_kills||0} PvE kills`;
   const fmtPw = v => { v=+v||0; if(v>=1e9)return (v/1e9).toFixed(1)+'B'; if(v>=1e6)return (v/1e6).toFixed(1)+'M'; if(v>=1e3)return (v/1e3).toFixed(0)+'K'; return v; };
   const ago = ts => { if(!ts)return '—'; const s=Math.max(0,Math.floor(Date.now()/1000)-ts); if(s<60)return s+'s'; if(s<3600)return Math.floor(s/60)+'m'; if(s<86400)return Math.floor(s/3600)+'h'; return Math.floor(s/86400)+'d'; };
   const setRows = (id, rows, fn) => { const tb=document.getElementById(id); if(!tb)return;
     tb.innerHTML = (rows&&rows.length) ? rows.map(fn).join('') : '<tr><td colspan=6 class=stat>no data yet</td></tr>'; };
   setRows('pvp_alliance_war', j.alliance_war, a=>
     `<tr><td><span class=tag>${a.tag}</span></td><td>${a.battles}</td><td>${a.wins}</td><td><b>${a.win_rate}%</b></td><td>${fmtPw(a.power_destroyed)}</td></tr>`);
   setRows('pvp_atk_generals', j.top_attack_generals, g=>
     `<tr><td>${g.general}</td><td>${g.uses}</td><td>${g.wins}</td><td><b>${g.win_rate}%</b></td></tr>`);
   setRows('pvp_def_generals', j.top_defense_generals, g=>
     `<tr><td>${g.general}</td><td>${g.seen}</td></tr>`);
   setRows('pvp_top_attackers', j.top_attackers, a=>
     `<tr><td>[${a.tag||'?'}] ${a.name||('u'+a.uid)}</td><td>${a.battles}</td><td>${fmtPw(a.kills)}</td><td><b>${a.kd}</b></td></tr>`);
   setRows('pvp_recent', j.recent_battles, b=>{
     const cls = b.result==='WIN' ? 'style="color:#34d399;font-weight:600"' : 'style="color:#fca5a5;font-weight:600"';
     return `<tr><td class=stat>${ago(b.ts)}</td><td>${b.attacker} → ${b.defender}</td><td ${cls}>${b.result}</td><td>${fmtPw(b.atk_killed)}</td><td><span class=cp onclick="navigator.clipboard.writeText('${b.wx},${b.wy}')">${b.wx},${b.wy}</span></td></tr>`;
   });
   setRows('pvp_scout', j.scout_intel, s=>
     `<tr><td>${s.name||('u'+s.uid)}</td><td>${fmtPw(s.total_army)}</td><td>${fmtPw(s.total_wall)}</td><td>${s.def_general||'—'}</td><td><span class=cp onclick="navigator.clipboard.writeText('${s.wx},${s.wy}')">${s.wx},${s.wy}</span></td></tr>`);
   setRows('pvp_pve_bosses', j.pve_top_bosses, b=>
     `<tr><td>${b.name}</td><td><span class="grp g-${grpClass(b.group||'Normal')}">${b.group||'Normal'}</span></td><td><b>${b.kills}</b></td></tr>`);
   setRows('pvp_pve_damage', j.pve_top_hunters, d=>
     `<tr><td>${d.name||('u'+d.uid)}</td><td><span class=tag>${d.tag||'?'}</span></td><td>${d.kills}</td><td><b>${(d.experience||0).toLocaleString()}</b></td><td>${fmtPw(d.power)}</td></tr>`);
 } catch(e) { console.error('loadPvpStats fail', e); }
}

// ---- Auto-watchlist rules ----
function toggleWlRules(){
 const p = document.getElementById('wl_rules_panel');
 const willOpen = p.style.display === 'none';
 p.style.display = willOpen ? '' : 'none';
 if(willOpen) loadWlRulesIntoUI();
}
async function loadWlRulesIntoUI(){
 try {
   const r = await fetch('/api/watchlist/rules', {cache:'no-store'});
   const j = await r.json();
   document.getElementById('wlr_enabled').checked = !!j.enabled;
   document.getElementById('wlr_enemy').value = (j.enemy_tags||[]).join(', ');
   document.getElementById('wlr_exclude').value = (j.exclude_tags||[]).join(', ');
   document.getElementById('wlr_min_castle').value = j.min_castle||0;
   document.getElementById('wlr_min_pwr').value = j.min_power_M||0;
   document.getElementById('wlr_require_shield').checked = !!j.require_shield_seen;
 } catch(e){}
}
async function saveWlRules(){
 const status = document.getElementById('wlr_status');
 status.textContent = 'saving…';
 const parseList = s => s.split(/[, ]+/).map(x=>x.trim()).filter(Boolean);
 const body = {
   enabled: document.getElementById('wlr_enabled').checked,
   enemy_tags: parseList(document.getElementById('wlr_enemy').value),
   exclude_tags: parseList(document.getElementById('wlr_exclude').value),
   min_castle: parseInt(document.getElementById('wlr_min_castle').value||0,10),
   min_power_M: parseFloat(document.getElementById('wlr_min_pwr').value||0),
   require_shield_seen: document.getElementById('wlr_require_shield').checked,
 };
 try {
   await fetch('/api/watchlist/rules', {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(body)});
   status.textContent = 'saved (next auto-tick in <60s)';
   setTimeout(()=>{status.textContent='';}, 4000);
 } catch(e){ status.textContent = 'failed: '+e; }
}
const SEL=new Map();              // id -> label seleccionado
const FAM=new Set();              // familias rapidas activas
// Persistencia per-usuario de los pills marcados (FAM=familias, SEL=monstruos concretos),
// keyed por CURRENT_USER (mismo patrón que scKey/SC_SEL). Sobrevive al refresh.
function pillsKey(){ return 'mon_pills_'+(CURRENT_USER||'_'); }
function savePills(){
 try{ localStorage.setItem(pillsKey(), JSON.stringify({fam:[...FAM], sel:[...SEL.entries()]})); }catch(e){}
}
function loadPillsState(){
 try{ const raw=localStorage.getItem(pillsKey()); if(!raw) return;
  const o=JSON.parse(raw)||{};
  FAM.clear(); (o.fam||[]).forEach(f=>FAM.add(f));
  SEL.clear(); (o.sel||[]).forEach(p=>{ if(Array.isArray(p)) SEL.set(p[0],p[1]); });
 }catch(e){}
}
let curPage=1;                    // paginacion (1-indexed); cualquier cambio de filtro lo resetea
function gotoPage(p){curPage=Math.max(1,p|0); load(true);}
// --- panel de ataques (toggle + countdown vivo) ---
let SHOWATK = localStorage.getItem('showatk')==='1';
let ATKS = []; let ATKS_TS = 0;   // ATKS_TS = epoch del cliente al recibir; restamos delta para countdown vivo
async function loadAttacks(){
 if(!SHOWATK) return;
 const inc = document.getElementById('show_helps');
 const incHelps = (inc && inc.checked) ? '&include_helps=1' : '';
 const r=await fetch('/api/attacks?_='+Date.now()+incHelps,{cache:'no-store'});
 const j=await r.json();
 ATKS = j.attacks||[]; ATKS_TS = Date.now()/1000;
 window.ATKS_RELOS_COUNT = j.relos_filtered||0;
 window.ATKS_HELPS_COUNT = j.helps_filtered||0;
 window.ATKS_STALE_COUNT = j.stale_filtered||0;
 window.ATKS_ENEMY_HIDDEN = j.enemy_hidden||0;
 window.ATKS_ALLY_FILTERED = j.ally_filtered||0;
 renderAttacks();
}
function renderAttacks(){
 const panel=document.getElementById('atkpanel');
 const hdr=document.getElementById('atkhdr');
 if(!SHOWATK){panel.style.display='none'; if(hdr) hdr.style.display='none'; return;}
 panel.style.display=''; if(hdr) hdr.style.display='';
 const filt=document.getElementById('atkfilter').value;
 const delta = Date.now()/1000 - ATKS_TS;
 let list = ATKS.slice();
 if(filt==='alliance') list = list.filter(a=>a.kind==='alliance');
 else if(filt==='solo') list = list.filter(a=>a.kind==='solo');
 else if(filt==='boss') list = list.filter(a=>a.tgroup==='Boss'||a.tgroup==='Event');
 else if(filt==='svs') list = list.filter(a=>a.enemy);
 // EXCLUIR NUESTRA ALIANZA: oculta los ataques cuyo atacante es de nuestra alianza (LAN).
 const _eo=document.getElementById('excl_own');
 if(_eo && _eo.checked) list = list.filter(a => (a.guild||0) !== LAN_GUILD_ID);
 // recalcula ETA en vivo y reordena
 for(const a of list) a._eta = Math.max(0, Math.round(a.eta - delta));
 list.sort((a,b)=>a._eta-b._eta);
 const relos = (window.ATKS_RELOS_COUNT||0);
 const helps = (window.ATKS_HELPS_COUNT||0);
 const stale = (window.ATKS_STALE_COUNT||0);
 const enmH = (window.ATKS_ENEMY_HIDDEN||0);
 const allyF = (window.ATKS_ALLY_FILTERED||0);
 const extra = (enmH?(', '+enmH+' enemy-server'):'')+(allyF?(', '+allyF+' off-ally'):'');
 document.getElementById('atkcount').textContent =
   `${list.length} shown · ${ATKS.length} attacks · filtered: ${relos} relos, ${helps} helps, ${stale} stale${extra}`;
 const tb=document.getElementById('atkrows'); tb.innerHTML='';
 for(const a of list){
  const tr=document.createElement('tr');
  const eta=a._eta;
  // parpadeo rojo: SOLO para el rally que acabamos de notificar (toast), durante 10s.
  // Usamos RALLY_TOAST_AT (no RALLY_ALERTED) para no pintar los rallies ya activos
  // que se registran como baseline al refrescar la pagina.
  const _rkey=a.tx+','+a.ty;
  const _isRally=(a.mty===19||a.mty===20||a.mty===21);
  const _ftoast=RALLY_TOAST_AT[_rkey];
  if(_isRally && _ftoast!=null && (Date.now()-_ftoast)<=RALLY_NEW_MS) tr.classList.add('eta-imminent');
  // marca azul para ataques SOLO: SOLO parpadea durante los 10s del inicio
  // (cuando se notifica el SOLO). Pasados los 10s no queda fondo.
  if(a.kind==='solo'){
   const _sk=a.tx+','+a.ty+','+a.uid;
   const _stoast=SOLO_TOAST_AT[_sk];
   if(_stoast!=null && (Date.now()-_stoast)<=SOLO_NEW_MS) tr.classList.add('atk-solo-new');
  }
  const ph=a.phase;
  const landed = !!a.landed;
  if(landed) tr.style.opacity='0.55';   // aterrizada (mostrada ~12s tras impactar)
  const phLab = landed ? 'LANDED' : (ph==='wait'?'RALLY':ph==='way'?'MARCH':ph==='combat'?'COMBAT':ph==='return'?'RETURN':ph==='scout'?'SCOUT':'SOLO');
  const who = a.name ? ((a.tag?('['+csEsc(a.tag)+'] '):'')+csEsc(a.name)) : ('u'+a.uid);
  const whoExtra = a.kind==='alliance' && a.count>1 ? ` +${a.count-1}` : '';
  // badge si la marcha ocurre en el SERVER ENEMIGO del SVS (lo etiqueta el escáner de 1954)
  const svsBadge = a.enemy ? ` <span title="Happening on the enemy SVS server (#${a.srv})" style="font-size:9px;font-weight:700;color:#f87171;border:1px solid #f8717188;border-radius:3px;padding:0 3px;vertical-align:middle">⚔ SVS ${a.srv}</span>` : '';
  const tgt = (a.tname ? `${csEsc(a.tname)}${a.tlevel?(' Lv'+a.tlevel):''}` : `tile(${a.tx},${a.ty})`) + (a.tsummon?' <span class=summontag title="Invocado (confirmado)">SUMMON</span>':(a.tsummon_likely?' <span class=summontag-maybe title="Probable summon: apareció hace <25s y fue atacado">SUMMON?</span>':''));
  const etaCls = eta<30?'eta-val eta-soon':(eta<300?'eta-val eta-soon':'eta-val');
  tr.innerHTML = `
    <td><span class="ph ph-${ph}">${phLab}</span></td>
    <td>${who}${whoExtra}${svsBadge}</td>
    <td>${tgt}</td>
    <td><span class=cp onclick="navigator.clipboard.writeText('${a.tx},${a.ty}')">${a.tx},${a.ty} &#8682;</span></td>
    <td><span class=stat style="font-size:11px">${(a.sx===a.tx&&a.sy===a.ty)?'(rally point)':'('+a.sx+','+a.sy+')'}</span></td>
    <td>${landed?'<span class=stat style="font-size:11px;color:#fca5a5">landed</span>':'<span class="'+etaCls+'">'+(fmtEta(eta)||'0s')+'</span>'}</td>`;
  tb.appendChild(tr);
 }
}
// toggle persistente
function toggleAtk(on){
 SHOWATK = !!on;
 localStorage.setItem('showatk', SHOWATK?'1':'0');
 if(SHOWATK){loadAttacks(); loadFocus();} else {document.getElementById('atkpanel').style.display='none'; const _h=document.getElementById('atkhdr'); if(_h) _h.style.display='none';}
}
// countdown vivo cada 1s (solo display, sin pedir al servidor)
setInterval(()=>{ if(SHOWATK) renderAttacks(); }, 1000);
// ── Focus Zone (captura total de ataques en una zona; opt-in, superadmin) ──
async function saveFocus(){
 const body = {
   active: document.getElementById('focus_on').checked,
   half: document.getElementById('focus_half').value,
   tag: document.getElementById('focus_tag').value.trim(),
   cx: parseInt(document.getElementById('focus_cx').value)||0,
   cy: parseInt(document.getElementById('focus_cy').value)||0,
   radius: parseInt(document.getElementById('focus_radius').value)||60,
 };
 const msg=document.getElementById('focus_msg');
 try{
   const r=await fetch('/api/focus',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});
   const j=await r.json();
   if(r.ok&&j.ok){ msg.style.color='#34d399'; msg.textContent = j.active?('ON · '+(j.tag?('['+j.tag+']'):('@'+j.cx+','+j.cy))+' · '+j.half):'OFF'; loadFocus(); }
   else { msg.style.color='#f87171'; msg.textContent=(j.detail||j.error||('HTTP '+r.status)); }
 }catch(e){ msg.style.color='#f87171'; msg.textContent='error'; }
}
// Preset DEFENSE: ronda NUESTRA alianza (cobertura defensiva contra ataques entrantes).
async function defenseFocus(){
 const half=(document.getElementById('focus_half')||{}).value||'W';
 const msg=document.getElementById('focus_msg'); if(msg){ msg.style.color='#94a3b8'; msg.textContent='aplicando defensa…'; }
 try{
   const r=await fetch('/api/focus',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({preset:'defense',half})});
   const j=await r.json();
   if(r.ok&&j.ok){ if(msg){ msg.style.color='#34d399'; msg.textContent='🛡 Defending '+(j.resolved_tag?('['+j.resolved_tag+']'):'our alliance')+' · '+j.half; } loadFocus(); }
   else if(msg){ msg.style.color='#f87171'; msg.textContent=(j.detail||j.error||('HTTP '+r.status)); }
 }catch(e){ if(msg){ msg.style.color='#f87171'; msg.textContent='error'; } }
}
async function loadFocus(){
 try{
   const r=await fetch('/api/focus?_='+Date.now(),{cache:'no-store'}); if(!r.ok)return; const j=await r.json();
   const on=document.getElementById('focus_on'); if(on) on.checked=!!j.active;
   const hf=document.getElementById('focus_half'); if(hf&&document.activeElement!==hf) hf.value=j.half||'W';
   const tg=document.getElementById('focus_tag'); if(tg&&document.activeElement!==tg) tg.value=j.tag||'';
   const rd=document.getElementById('focus_radius'); if(rd&&document.activeElement!==rd) rd.value=j.radius||60;
   const cx=document.getElementById('focus_cx'), cy=document.getElementById('focus_cy');
   if(cx&&document.activeElement!==cx) cx.value=j.cx||'';   // siempre refleja el valor global vivo
   if(cy&&document.activeElement!==cy) cy.value=j.cy||'';
   const msg=document.getElementById('focus_msg');
   const byTxt = j.by ? (' — '+j.by) : '';
   const ctr = j.tag?('['+j.tag+'] '+(j.tag_count||0)+'p @'+j.cx+','+j.cy):('@'+j.cx+','+j.cy);
   if(msg) msg.textContent = j.active ? ('ON · '+ctr+' · '+j.half+byTxt) : ('OFF'+byTxt);
 }catch(e){}
}
// Nota: el Focus es global; loadFocus se llama al abrir el panel / cargar la página
// (toggleAtk), así que siempre arranca con el valor global vivo. No hace falta polling.
async function loadFamilies(){
 try{
  // Max age field eliminado -> el conteo de familias usa el default del backend (active only),
  // que coincide con la tabla principal (también active only por defecto).
  // 2026-08-17: pasar el MISMO filtro de tipo/summon que la tabla (load) -> el pill "Nombre (N)"
  // cuenta exactamente lo que muestra al pulsarlo (arregla "Warlord (7)" con lista vacía: los 7
  // eran type!=2 -invocados- que la tabla type=2 oculta). onlyS = "Show only Summons".
  const onlyS=(document.getElementById('only_summons')||{}).checked;
  const r=await fetch('/api/families?type='+(onlyS?'':'2')+(onlyS?'&summon=1':'')+'&_='+Date.now(),{cache:'no-store'});
  const j=await r.json();
  const cols={boss:document.getElementById('fams_boss'),event:document.getElementById('fams_event'),
              shadow:document.getElementById('fams_shadow'),other:document.getElementById('fams_other')};
  for(const k in cols) if(cols[k]) cols[k].innerHTML='';
  for(const f of j){const b=document.createElement('span');
   b.className='fam'+(FAM.has(f.name)?' on':'')+(f.live?'':' z');
   b.textContent=f.name+' ('+f.live+')';
   b.title=f.variants+' tipos en config · '+f.live+' en el mapa ahora';
   b.onclick=()=>{FAM.has(f.name)?FAM.delete(f.name):FAM.add(f.name);b.classList.toggle('on');savePills();load();};
   (cols[f.group]||cols.event).appendChild(b);}
  // Si un acordeón contiene algún pill marcado, abrirlo (que persista tras refresh).
  for(const k in cols){ const row=cols[k]; if(!row) continue;
   if(row.querySelector('.fam.on')){ row.style.display='';
    const h=row.previousElementSibling, c=h&&h.querySelector('.fcaret'); if(c) c.textContent='−'; }
  }
 }catch(e){}
}
const msbox=document.getElementById('msbox'),msin=document.getElementById('msin'),dd=document.getElementById('dd');

async function loadCatalog(){
 try{const r=await fetch('/api/catalog?_='+Date.now(),{cache:'no-store'});CAT=await r.json();}catch(e){}
}
function renderChips(){
 [...msbox.querySelectorAll('.chip')].forEach(c=>c.remove());
 for(const [id,lab] of SEL){
  const c=document.createElement('span');c.className='chip';
  c.innerHTML=lab+' <b>&times;</b>';
  c.querySelector('b').onclick=()=>{SEL.delete(id);renderChips();savePills();load();};
  msbox.insertBefore(c,msin);
 }
}
function renderDD(){
 const q=msin.value.trim().toLowerCase();
 const list=CAT.filter(o=>!SEL.has(o.id) && (!q||o.label.toLowerCase().includes(q))).slice(0,60);
 dd.innerHTML='';
 if(!list.length){dd.classList.remove('open');return;}
 for(const o of list){
  const d=document.createElement('div');d.className='opt';
  d.innerHTML=`${o.name} <small>Lv${o.level} · ${fmtPow(o.power)}</small>`;
  d.onclick=()=>{SEL.set(o.id,o.label);msin.value='';renderChips();renderDD();savePills();load();msin.focus();};
  dd.appendChild(d);
 }
 dd.classList.add('open');
}
msin.addEventListener('focus',renderDD);
msin.addEventListener('input',renderDD);
document.addEventListener('click',e=>{if(!document.getElementById('ms').contains(e.target))dd.classList.remove('open');});
msbox.addEventListener('click',()=>msin.focus());

// SORT COMPUESTO (AND): el dropdown "Sort" es el criterio BASE (secundario);
// al clicar una columna esta pasa a ser el criterio PRIMARIO y el dropdown
// queda de desempate. Sin columna activa, ordena solo por el dropdown (clásico).
//   p.ej. dropdown=Distance + clic en Level  =>  ORDER BY Level , Distance
let PRIMARY = null;          // data-sort de la columna clicada | null = solo dropdown
let PRIMARY_ORDER = 'desc';  // 'asc' | 'desc' de la columna primaria
function setSort(field, order){          // compat: lo llama el onchange del dropdown
 document.getElementById('sort').value = field;
 updateSortArrows();
}
function onHeaderSort(field){
 if (PRIMARY === field){
  PRIMARY_ORDER = (PRIMARY_ORDER === 'desc') ? 'asc' : 'desc';  // mismo campo: toggle
 } else {
  PRIMARY = field; PRIMARY_ORDER = 'desc';                      // nuevo campo primario
 }
 updateSortArrows();
 load();
}
function sortParams(){
 // {sort,order,sort2,order2} según el modelo compuesto
 const dd = document.getElementById('sort').value;
 if (PRIMARY){
  const sec = (dd && dd !== PRIMARY) ? dd : '';
  return { sort: PRIMARY, order: PRIMARY_ORDER, sort2: sec, order2: 'desc' };
 }
 return { sort: dd, order: 'desc', sort2: '', order2: 'desc' };
}
function updateSortArrows(){
 // Monsters tab. Columna primaria => flecha ▼/▲. Columna que coincide con el
 // dropdown (desempate, si difiere de la primaria) => marca "₂". Las funciones
 // de Players/Resources/Watchlist son aparte para no colisionar.
 const dd = document.getElementById('sort').value;
 document.querySelectorAll('th.sortableH').forEach(th => {
  const f = th.dataset.sort;
  const isPrim = (PRIMARY && f === PRIMARY);
  const isSec  = (!isPrim && PRIMARY && f === dd);
  th.classList.toggle('active', !!isPrim);
  const arr = th.querySelector('.arrow');
  if (arr){
   if (isPrim)                     arr.textContent = (PRIMARY_ORDER === 'desc' ? '▼' : '▲');
   else if (isSec)                 arr.textContent = '₂';
   else if (!PRIMARY && f === dd)  arr.textContent = '▼';   // dropdown solo (sin columna)
   else                            arr.textContent = '⇅';
  }
 });
}
function copyVisibleRows(){
 // copia filas visibles en formato: X Y Name Level (Distance)
 const rows = document.querySelectorAll('#rows tr');
 const lines = [];
 for (const tr of rows){
  const td = tr.querySelectorAll('td');
  if (td.length < 7) continue;
  const name = td[0].textContent.trim();
  const level = td[1].textContent.trim();
  const x = td[4].textContent.trim();
  const y = td[5].textContent.trim();
  const dist = td[6].textContent.trim();
  lines.push(dist && dist !== '-' ? `${x},${y} ${name} ${level} (${dist})` : `${x},${y} ${name} ${level}`);
 }
 if (!lines.length){ alert('No hay filas para copiar'); return; }
 const txt = lines.join('\\n');
 navigator.clipboard.writeText(txt).then(()=>{
  const m=document.getElementById('copyMsg');
  if(m){ m.style.display='inline'; clearTimeout(window.__copyMsgT); window.__copyMsgT=setTimeout(()=>{ m.style.display='none'; }, 5000); }
 }).catch(e=>alert('Copy failed: '+e));
}
async function load(keepPage){
 if(!keepPage) curPage=1;            // cualquier cambio de filtro vuelve a pagina 1
 const ids=[...SEL.keys()].join(',');
 const fams=[...FAM].join('||');
 const onlyS=document.getElementById('only_summons').checked;   // "Show only Summons"
 const sp=sortParams();             // {sort,order,sort2,order2} del modelo compuesto (AND)
 // type fijo a Monsters (2). Con "Show only Summons" se quita el type para incluir TODOS los
 // tipos de monstruo invocados (los summons abarcan type 2 y 5: Elite Barbary Pirate, Invader…).
 const q=new URLSearchParams({ids,fams,type:(onlyS?'':'2'),
  cx:document.getElementById('cx').value,cy:document.getElementById('cy').value,
  radius:document.getElementById('rad').value,
  sort:sp.sort,order:sp.order,sort2:sp.sort2,order2:sp.order2,
  limit:document.getElementById('lim').value,
  page:String(curPage)});
 if(onlyS) q.set('summon','1');
 updateSortArrows();
 const r=await fetch('/api/data?'+q+'&_='+Date.now(),{cache:'no-store'});const j=await r.json();
 const tb=document.getElementById('rows');tb.innerHTML='';
 // gate: si Evony aun no cargo su config del servidor, mostrar banner en
 // lugar de filas rotas (idN/0/Normal). Auto-recupera cuando cfg llega.
 if(!j.cfg_n){
  tb.innerHTML = `<tr class=cfgloading><td colspan="10">⌛ Waiting for Evony to load the game config…<br>
    <span class=hint>Enter the <b>world map</b> on both emulators (account #1 and #2). This resolves on its own once Evony has the config — no need to restart anything.</span></td></tr>`;
  document.getElementById('pager').style.display='none';
  document.getElementById('stat').textContent =
   `⌛ waiting for config | total map: ${j.all} | sweep ${swStr(j)} | cfg: 0`;
  return;
 }
 for(const x of j.rows){
  const tr=document.createElement('tr');
  const cls=[];
  if(x.fresh) cls.push('fresh');
  if(x.atk==='rally') cls.push('atk-active');
  if(cls.length) tr.className=cls.join(' ');
  const g=x.group||'Normal';
  const halfTag = x.half ? ` <span class=stat style="font-size:10px;color:#7dd3fc">(${x.half})</span>` : '';
  tr.innerHTML=`<td>${x.name}${x.summon?' <span class=summontag title="Invocado (owner_id confirmado)">SUMMON</span>':(x.summon_likely?' <span class=summontag-maybe title="Probable summon: apareció hace <25s y fue atacado">SUMMON?</span>':'')}</td><td class=lvl>${x.level}</td><td>${fmtPow(x.power)}</td>
   <td><span class="grp g-${grpClass(g)}">${g}</span></td>
   <td>${x.x}</td><td>${x.y}</td>
   <td>${x.dist!=null?(x.dist+' km'):'-'}</td><td><span class=cp onclick="navigator.clipboard.writeText('${x.x},${x.y}')">${x.x},${x.y} &#8682;</span></td>
   <td class=stat><span class=d-full>${fmtAgo(x.age)}</span><span class=d-short>${fmtAgoShort(x.age)}</span>${halfTag}</td>`;
  tb.appendChild(tr);
 }
 // paginacion
 const pg=document.getElementById('pager');
 if(j.limit<=0 || j.pages<=1){
   pg.style.display='none'; curPage=1;
 } else {
   pg.style.display='';
   curPage=j.page;
   document.getElementById('pginfo').textContent=`page ${j.page} of ${j.pages} · ${j.total} results`;
   document.getElementById('pg_first').disabled = (j.page<=1);
   document.getElementById('pg_prev').disabled  = (j.page<=1);
   document.getElementById('pg_next').disabled  = (j.page>=j.pages);
   document.getElementById('pg_last').disabled  = (j.page>=j.pages);
 }
 document.getElementById('stat').textContent=
   `${j.total} results | total map: ${j.all} | sweep ${swStr(j)} | cfg: ${j.cfg_n} | catalog: ${CAT.length}`;
}
let TAB='mon';
/* [cur-tab sin icono] labels solo-texto: TAB_NAMES alimenta unicamente el #cur_tab movil */
var QDEF_HX='458', QDEF_HY='567';   // centro de la alianza por defecto (Hive X/Y del menú de shortcuts)
var QPREFS={hx:QDEF_HX,hy:QDEF_HY,sort:'distance',n:40}; var QABSENT_OPEN=false; var _qLastTick=0; var QSEL={}; var _QFAMS=[]; var _QCOUNT={}; var _QLIST={fam:'',lv:0,shown:[],picks:[]};
const TAB_NAMES={quick:'Shortcuts',mon:'Monsters',pl:'Players',svs:'SVS',res:'Resources',farm:'Farm',rel:'Relics/Pyramids',arc:'Arctic Barbarians',sc:'Subcities',rs:'Respawns',rl:'Relocations',wl:'Watchlist'};
function switchTab(t){
 // TEMPORAL: Protocol y Server Stats deshabilitados (ahorro memoria/CPU). Reactivar quitando esta línea.
 if(t==='proto' || t==='intel') return;
 TAB=t;
 // móvil: mostrar el nombre del tab actual en el header + cerrar el burger menu
 const _lbl=document.getElementById('cur_tab'); if(_lbl) _lbl.textContent=TAB_NAMES[t]||t;
 const _h=document.getElementById('mainHeader');
 if(_h){ _h.classList.remove('menu-open'); _h.classList.add('menu-closed'); }
 const views={quick:'view_quick', mon:'view_mon', pl:'view_players', svs:'view_svs', res:'view_resources', farm:'view_farm',
              rel:'view_relics', arc:'view_arctic', sc:'view_subcities',
              rs:'view_respawns', rl:'view_relocations', wl:'view_watchlist',
              proto:'view_protocol', intel:'view_intel'};
 const tabs ={quick:'tab_quick', mon:'tab_mon', pl:'tab_pl', svs:'tab_svs', res:'tab_res', farm:'tab_farm',
              rel:'tab_rel', arc:'tab_arc', sc:'tab_sc',
              rs:'tab_rs', rl:'tab_rl', wl:'tab_wl', proto:'tab_proto', intel:'tab_intel'};
 for(const k in views){
   document.getElementById(views[k]).style.display = (k===t?'':'none');
   document.getElementById(tabs[k]).classList.toggle('on', k===t);
 }
 if(t==='pl') loadPlayers();
 else if(t==='svs') loadSvs();
 else if(t==='res') loadResources();
 else if(t==='farm') loadFarm();
 else if(t==='rel') loadRelics();
 else if(t==='arc') loadArctic();
 else if(t==='sc') loadSubcities();
 else if(t==='rs') loadRespawns();
 else if(t==='rl') loadRelocations();
 else if(t==='wl') loadWatchlistView();
 else if(t==='proto') loadProtoStats();
 else if(t==='intel'){ loadKingdomIntel(); loadPvpStats(); }
 else if(t==='quick') initQuick();
 else load();
}
// README: botón del header -> modal a pantalla completa con el tutorial en un iframe (aísla su CSS/JS).
// Carga perezosa: el iframe solo baja el tutorial la primera vez que se abre.
var _readmeLoaded=false;
function openReadme(){
  var m=document.getElementById('readme_modal'); if(!m) return;
  if(!_readmeLoaded){ _readmeLoaded=true; var f=document.getElementById('readme_frame'); if(f) f.src='/readme'; }
  m.style.display='flex'; document.body.style.overflow='hidden';
}
function closeReadme(){
  var m=document.getElementById('readme_modal'); if(m) m.style.display='none';
  document.body.style.overflow='';
}
document.addEventListener('keydown',function(e){ if(e.key==='Escape') closeReadme(); });

function loadQuickPrefs(){ try{ var st=JSON.parse(localStorage.getItem('quickPrefs')||'{}'); QPREFS=Object.assign(QPREFS,st); }catch(e){}
  if(QPREFS.hx===''||QPREFS.hx==null) QPREFS.hx=QDEF_HX; if(QPREFS.hy===''||QPREFS.hy==null) QPREFS.hy=QDEF_HY;   // hive vacío -> centro de alianza por defecto
  var hx=document.getElementById('q_hx'),hy=document.getElementById('q_hy'),so=document.getElementById('q_sort'),nn=document.getElementById('q_n');
  if(hx&&document.activeElement!==hx)hx.value=QPREFS.hx; if(hy&&document.activeElement!==hy)hy.value=QPREFS.hy; if(so)so.value=QPREFS.sort; if(nn&&document.activeElement!==nn)nn.value=QPREFS.n; }
function saveQuickPrefs(){ var hx=document.getElementById('q_hx'),hy=document.getElementById('q_hy'),so=document.getElementById('q_sort'),nn=document.getElementById('q_n');
  QPREFS={hx:(hx&&hx.value)||'',hy:(hy&&hy.value)||'',sort:(so&&so.value)||'distance',n:Math.max(1,Math.min(200,parseInt((nn&&nn.value)||'40')||40))};
  try{ localStorage.setItem('quickPrefs',JSON.stringify(QPREFS)); }catch(e){} saveQuickServer(); }
function _clip(txt){ try{ if(navigator.clipboard&&navigator.clipboard.writeText){ navigator.clipboard.writeText(txt); return true; } }catch(e){} try{ var ta=document.createElement('textarea'); ta.value=txt; ta.style.position='fixed'; ta.style.top='-1000px'; ta.style.opacity='0'; document.body.appendChild(ta); ta.focus(); ta.select(); var ok=document.execCommand('copy'); document.body.removeChild(ta); return ok; }catch(e){ return false; } }
function qcopy(x,y,btn){ _clip(x+' '+y); }
function qgrpHtml(f){
  const btns=f.levels.map(l=>{ const on=l.count>0; const key=f.name+'|'+l.lv; const sel=!!QSEL[key];
    return `<button class="${on?'on':'off'}${sel?' sel':''}" onclick="qToggleSel(&quot;${f.name}&quot;,${l.lv},this)" title="${on?l.count+' on map':'none on map'}${sel?' (selected)':''}">Lv${l.lv}${on?` <span style="opacity:.75;font-weight:400">&middot;${l.count}</span>`:''}</button>`; }).join('');
  return `<div class=qgrp><h4>${f.name} <span class=qgl>${f.group}${f.live>0?` &middot; total ${f.live}`:''}</span></h4><div class=qlv>${btns}</div></div>`;
}
var _qsvTimer=null;
function saveQuickServer(){ if(_qsvTimer)clearTimeout(_qsvTimer); _qsvTimer=setTimeout(function(){ try{ fetch('/api/quick_prefs',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({sel:QSEL, prefs:QPREFS})}); }catch(e){} }, 400); }
async function initQuick(){
  let sv=null;
  try{ const r=await fetch('/api/quick_prefs',{cache:'no-store'}); if(r.ok) sv=await r.json(); }catch(e){}
  if(sv && sv.saved){   // el usuario ya tiene prefs guardadas server-side: mandan sobre el localStorage
    try{ localStorage.setItem('quickSelected', JSON.stringify(sv.sel||{})); }catch(e){}
    try{ var lp=JSON.parse(localStorage.getItem('quickPrefs')||'{}'); localStorage.setItem('quickPrefs', JSON.stringify(Object.assign(lp, sv.prefs||{}))); }catch(e){}
  }
  loadQuickPrefs(); loadQuickSel();
  if(!(sv && sv.saved)) saveQuickServer();   // 1ª vez sin datos server -> migra el localStorage actual del usuario
  qmainHtml(); loadQuick();
}
function loadQuickSel(){ try{ var st=JSON.parse(localStorage.getItem('quickSelected')||'{}'); if(st&&typeof st==='object') QSEL=st; }catch(e){} }
function qToggleSel(fam,lv,btn){ const key=fam+'|'+lv; if(QSEL[key])delete QSEL[key]; else QSEL[key]=true; try{ localStorage.setItem('quickSelected',JSON.stringify(QSEL)); }catch(e){} if(btn)btn.classList.toggle('sel',!!QSEL[key]); qmainHtml(); saveQuickServer(); }
function qmainHtml(){ const box=document.getElementById('quick_main'); if(!box)return;
  const keys=Object.keys(QSEL).filter(k=>QSEL[k]);
  if(!keys.length){ box.innerHTML='<div style="color:#64748b;font-size:13px;padding:2px 0">No shortcuts yet &mdash; open <b style="color:#93c5fd">&#9776; Menu</b> and tap monster levels to add quick buttons.</div>'; return; }
  const byFam={}; keys.forEach(k=>{ const i=k.lastIndexOf('|'); const fam=k.slice(0,i), lv=parseInt(k.slice(i+1)); if(isNaN(lv))return; if((_QCOUNT[k]||0)<=0)return; (byFam[fam]=byFam[fam]||[]).push(lv); });
  if(!Object.keys(byFam).length){ box.innerHTML='<div style="color:#64748b;font-size:13px;padding:2px 0">None of your shortcuts are on the map right now.</div>'; return; }
  const grpOf={}; _QFAMS.forEach(f=>grpOf[f.name]=f.group);
  const GRPORD={event:0,boss:1,shadow:2,other:3,normal:4};
  const fams=Object.keys(byFam).sort((a,b)=>{ const ga=(GRPORD[grpOf[a]]!=null?GRPORD[grpOf[a]]:9),gb=(GRPORD[grpOf[b]]!=null?GRPORD[grpOf[b]]:9); if(ga!==gb)return ga-gb; return a.localeCompare(b); });
  let html='';
  fams.forEach(fam=>{ const lvls=byFam[fam].sort((a,b)=>a-b); const grp=grpOf[fam]||'';
    const btns=lvls.map(lv=>{ const c=_QCOUNT[fam+'|'+lv]||0; const on=c>0;
      return `<button class="${on?'on':'off'}" onclick="quickList(&quot;${fam}&quot;,${lv})" title="${on?c+' on map':'none on map'}">Lv${lv}${on?` <span style="opacity:.75;font-weight:400">&middot;${c}</span>`:''}</button>`; }).join('');
    html+=`<div class=qgrp><h4>${fam}${grp?` <span class=qgl>${grp}</span>`:''}</h4><div class=qlv>${btns}</div></div>`; });
  box.innerHTML=html;
}
// ── EventBoss: shortcut directo (lista fija de event boss rally-ables) -> niveles 1-7 -> lista agregada ──
var EBOSS_FAMS=['Golem','Lava Turtle','Witch','Warlord','Hydra','Sphinx','Ymir','Pan'];
var EBOSS_OPEN=false;
var PYR_ROWS=[]; var PYR_BY_LV={}; var PYR_OPEN=false;   // Pyramids = reliquias tipo 57 (/api/relics?kind=pyramid)
var ATTACKS_N=0;   // nº de ataques activos (para el contador del botón ActiveAttacks)
var AA_OPEN=false; // ActiveAttacks list abierta? (acordeón junto a EventBoss/Pyramids: abrir uno pliega los otros)
var MP_OPEN=false; // MyPresets list abierta? (mismo acordeón)
var BUB_OPEN=false; // Bubble (jugadores SIN bubble activo) list abierta? (mismo acordeón)
function ebossCount(lv){ let c=0; EBOSS_FAMS.forEach(f=>{ c+=(_QCOUNT[f+'|'+lv]||0); }); return c; }
function pyrCount(lv){ return PYR_BY_LV[lv]||0; }
// Shortcuts DIRECTOS: EventBoss (familias de monstruo) + Pyramids (reliquias). Cabeceras 'alado', niveles debajo por grupo.
function directScHtml(){ const box=document.getElementById('quick_eventboss'); if(!box)return;
  // EventBoss (1-7)
  let ebT=0; for(let lv=1;lv<=7;lv++) ebT+=ebossCount(lv);
  const ebHead=`<button class="qshowmore${EBOSS_OPEN?' on':''}" onclick="ebossToggle()" title="Locate all current event bosses (Golem, Lava Turtle, Witch, Warlord, Hydra, Sphinx, Ymir, Pan) by level">${EBOSS_OPEN?'&#9662; ':'&#9656; '}&#9889; EventBoss${ebT>0?` <span style="opacity:.7;font-weight:400">&middot; ${ebT} on map</span>`:''}</button>`;
  let ebLv=''; for(let lv=1;lv<=7;lv++){ const c=ebossCount(lv), on=c>0; ebLv+=`<button class="${on?'on':'off'}" onclick="eventBossList(${lv})" title="${on?c+' on map':'none on map'}">EventBoss ${lv}${on?` <span style="opacity:.75;font-weight:400">&middot;${c}</span>`:''}</button>`; }
  // Pyramids (1..max nivel visto, mínimo 5)
  const pMax=Math.max(5,0,...Object.keys(PYR_BY_LV).map(Number));
  let pT=0; for(let lv=1;lv<=pMax;lv++) pT+=pyrCount(lv);
  const pHead=`<button class="qshowmore${PYR_OPEN?' on':''}" onclick="pyrToggle()" title="Locate all Pyramid relics (Lv{N} Pyramid Ruins) by level">${PYR_OPEN?'&#9662; ':'&#9656; '}&#128314; Pyramids${pT>0?` <span style="opacity:.7;font-weight:400">&middot; ${pT} on map</span>`:''}</button>`;
  let pLv=''; for(let lv=1;lv<=pMax;lv++){ const c=pyrCount(lv), on=c>0; pLv+=`<button class="${on?'on':'off'}" onclick="pyramidList(${lv})" title="${on?c+' on map':'none on map'}">Pyramid ${lv}${on?` <span style="opacity:.75;font-weight:400">&middot;${c}</span>`:''}</button>`; }
  const aaHead=`<button class="qshowmore${AA_OPEN?' on':''}" onclick="aaToggle()" title="Active attacks on the map now (rallies + solos)">&#9876; ActiveAttacks${ATTACKS_N>0?` <span style="opacity:.7;font-weight:400">&middot; ${ATTACKS_N}</span>`:''}</button>`;
  const bubHead=`<button class="qshowmore${BUB_OPEN?' on':''}" onclick="bubToggle()" title="Players with NO active bubble (unshielded = attackable), recientes, por poder">🫧 Bubble</button>`;
  // MyPresets: total de monstruos seleccionados en el menú que están en el mapa (= chips del main screen, count>0)
  let mpT=0; Object.keys(QSEL).forEach(function(k){ if(QSEL[k] && (_QCOUNT[k]||0)>0) mpT+=(_QCOUNT[k]||0); });
  const mpHead=`<button class="qshowmore${MP_OPEN?' on':''}" onclick="myPresetsToggle()" title="All monsters currently selected in your menu (everything on the shortcuts main screen)">${MP_OPEN?'&#9662; ':''}&#9733; MyPresets${mpT>0?` <span style="opacity:.7;font-weight:400">&middot; ${mpT}</span>`:''}</button>`;
  box.innerHTML=`<div class=qgrp><div style="display:flex;flex-wrap:wrap;gap:12px">${mpHead}${aaHead}${bubHead}${ebHead}${pHead}</div>`+
    `<div style="display:${EBOSS_OPEN?'flex':'none'};flex-wrap:wrap;gap:7px;margin-top:10px">${ebLv}</div>`+
    `<div style="display:${PYR_OPEN?'flex':'none'};flex-wrap:wrap;gap:7px;margin-top:10px">${pLv}</div></div>`;
}
function aaHideList(){ AA_OPEN=false; MP_OPEN=false; BUB_OPEN=false; const b=document.getElementById('quick_list'); if(b) b.style.display='none'; }
function aaToggle(){ if(AA_OPEN){ aaHideList(); directScHtml(); } else { activeAttacksList(); } }   // toggle: si ya está abierta, la cierra
function ebossToggle(){ aaHideList(); const o=!EBOSS_OPEN; EBOSS_OPEN=o; if(o)PYR_OPEN=false; directScHtml(); }   // acordeón de 3: abrir uno pliega los otros DOS + colapsa la lista de ActiveAttacks
function pyrToggle(){ aaHideList(); const o=!PYR_OPEN; PYR_OPEN=o; if(o)EBOSS_OPEN=false; directScHtml(); }
function myPresetsToggle(){ if(MP_OPEN){ aaHideList(); directScHtml(); } else { myPresetsList(); } }   // toggle: si ya abierta, la cierra
async function myPresetsList(){
  loadQuickPrefs();
  MP_OPEN=true; AA_OPEN=false; BUB_OPEN=false; EBOSS_OPEN=false; PYR_OPEN=false; try{ directScHtml(); }catch(e){}   // acordeón: al abrir MyPresets, pliega los demás
  const box=document.getElementById('quick_list'); if(!box)return;
  const hx=parseFloat(QPREFS.hx), hy=parseFloat(QPREFS.hy), hasHive=!isNaN(hx)&&!isNaN(hy);
  box.style.display=''; box.innerHTML=`<div class=qlh><span>Loading my presets&hellip;</span></div>`;
  try{ box.scrollIntoView({behavior:'smooth',block:'start'}); }catch(e){}
  // familias -> niveles seleccionados (SOLO los que aparecen en el main screen: count>0)
  const byFam={};
  Object.keys(QSEL).forEach(function(k){ if(!QSEL[k])return; if((_QCOUNT[k]||0)<=0)return; const i=k.lastIndexOf('|'); const fam=k.slice(0,i), lv=parseInt(k.slice(i+1)); if(isNaN(lv))return; (byFam[fam]=byFam[fam]||[]).push(lv); });
  const fams=Object.keys(byFam);
  const acts0=`<span class=qlacts><button class=qlclose onclick="aaHideList();directScHtml()">&#10005;</button></span>`;
  if(!fams.length){ box.innerHTML=`<div class=qlh><span><span class=qlgrp>My Presets</span></span>${acts0}</div><div class=qlrow>No monsters selected in the menu.</div>`; return; }
  let rows=[];
  try{
    const parts=await Promise.all(fams.map(function(fam){
      let q=`fams=${encodeURIComponent(fam)}&max_seen=2700&limit=0`;
      if(hasHive) q+=`&cx=${hx}&cy=${hy}`;
      return fetch(`/api/data?${q}&_=${Date.now()}`,{cache:'no-store'}).then(function(r){return r.json();}).then(function(j){
        const want=byFam[fam]; return ((j&&j.rows)||[]).filter(function(o){ return want.indexOf(parseInt(o.level||0))>=0; });
      }).catch(function(){ return []; });
    }));
    parts.forEach(function(p){ rows=rows.concat(p); });
  }catch(e){ box.innerHTML='<div class=qlh>Error loading list</div>'; return; }
  rows.forEach(o=>{ o._d=hasHive?Math.round(Math.hypot((o.x||0)-hx,(o.y||0)-hy)):null; });
  if(QPREFS.sort==='power') rows.sort((a,b)=>(b.power||0)-(a.power||0));
  else if(hasHive) rows.sort((a,b)=>a._d-b._d);
  else rows.sort((a,b)=>String((a.name||'')+'|'+(a.level||0)).localeCompare(String((b.name||'')+'|'+(b.level||0))));
  const n=QPREFS.n||40, shown=rows.slice(0,n);
  const cnt=shown.length<rows.length?`nearest ${shown.length} of ${rows.length}`:`${rows.length} on map`;
  const picks=[...new Set(rows.map(o=>(o.name||'')+'|'+(o.level||0)))].map(k=>{ const i=k.lastIndexOf('|'); return {name:k.slice(0,i), level:parseInt(k.slice(i+1))||0}; }).filter(p=>p.name);
  _QLIST={fam:'MyPresets', lv:0, shown:shown, picks:picks, multi:true};
  const acts=`<span class=qlacts><button class=qlpill onclick="qlCopy(this)"${shown.length?'':' disabled'}>Copy</button><button class="qlpill snd" onclick="qlSend(this)"${shown.length?'':' disabled'}>Send</button><button class=qlclose onclick="aaHideList();directScHtml()">&#10005;</button></span>`;
  const head=`<div class=qlh><span><span class=qlgrp>My Presets</span><span class=qlsub>${fams.length} famil${fams.length===1?'y':'ies'} &middot; ${cnt}</span></span>${acts}</div>`;
  const body=shown.map(o=>`<div class=qlrow>Lv${o.level||0} ${svsEsc(o.name||'')} <span class=qlc onclick="qcopy(${o.x||0},${o.y||0})" title="click to copy coords">(xy: ${o.x||0} ${o.y||0})</span>${o._d!=null?` <span class=qld>${o._d}km</span>`:''}</div>`).join('');
  box.innerHTML=head+(body||'<div class=qlrow>None on map right now.</div>');
}
async function eventBossList(lv){
  loadQuickPrefs();
  const box=document.getElementById('quick_list'); if(!box)return;
  const hx=parseFloat(QPREFS.hx), hy=parseFloat(QPREFS.hy), hasHive=!isNaN(hx)&&!isNaN(hy);
  box.style.display=''; box.innerHTML=`<div class=qlh><span>Loading EventBoss Lv${lv}&hellip;</span></div>`;
  try{ box.scrollIntoView({behavior:'smooth',block:'start'}); }catch(e){}
  let q=`fams=${encodeURIComponent(EBOSS_FAMS.join('||'))}&max_seen=2700&limit=0`;
  if(hasHive) q+=`&cx=${hx}&cy=${hy}`;
  let rows=[];
  try{ const r=await fetch(`/api/data?${q}&_=${Date.now()}`,{cache:'no-store'}); const j=await r.json(); rows=(j&&j.rows)||[]; }catch(e){ box.innerHTML='<div class=qlh>Error loading list</div>'; return; }
  rows=rows.filter(o=>parseInt(o.level||0)===lv);
  // Opción 1 (agregado EventBoss): atribuye cada fila (ya filtrada a EBOSS_FAMS) a su familia por
  // NOMBRE y corrige _QCOUNT[fam|lv] para TODAS las event boss (0 incluido) -> el agregado y los
  // pills por-familia se sincronizan con lo que muestra la lista (limpia fantasmas al pulsar).
  (function(){ const cnt={}; EBOSS_FAMS.forEach(f=>cnt[f]=0);
    rows.forEach(o=>{ const nm=(o.name||'').toLowerCase(); for(const f of EBOSS_FAMS){ if(nm.includes(f.toLowerCase())){ cnt[f]++; break; } } });
    EBOSS_FAMS.forEach(f=>{ _QCOUNT[f+'|'+lv]=cnt[f]; });
    try{ qmainHtml(); directScHtml(); }catch(e){} })();
  rows.forEach(o=>{ o._d=hasHive?Math.round(Math.hypot((o.x||0)-hx,(o.y||0)-hy)):null; });
  if(QPREFS.sort==='power') rows.sort((a,b)=>(b.power||0)-(a.power||0));
  else if(hasHive) rows.sort((a,b)=>a._d-b._d);
  const n=QPREFS.n||40, shown=rows.slice(0,n);
  const cnt=shown.length<rows.length?`nearest ${shown.length} of ${rows.length}`:`${rows.length} on map`;
  const picks=[...new Set(rows.map(o=>(o.name||'')+'|'+(o.level||0)))].map(k=>{ const i=k.lastIndexOf('|'); return {name:k.slice(0,i), level:parseInt(k.slice(i+1))||0}; }).filter(p=>p.name);
  _QLIST={fam:'EventBoss', lv:lv, shown:shown, picks:picks, multi:true};
  const acts=`<span class=qlacts><button class=qlpill onclick="qlCopy(this)"${shown.length?'':' disabled'}>Copy</button><button class="qlpill snd" onclick="qlSend(this)"${shown.length?'':' disabled'}>Send</button><button class=qlclose onclick="document.getElementById('quick_list').style.display='none'">&#10005;</button></span>`;
  const head=`<div class=qlh><span><span class=qlgrp>Event Bosses</span><span class=qlsub>EventBoss Lv${lv} &middot; ${cnt}</span></span>${acts}</div>`;
  const body=shown.map(o=>`<div class=qlrow>Lv${o.level||lv} ${o.name||''} <span class=qlc onclick="qcopy(${o.x||0},${o.y||0})" title="click to copy coords">(xy: ${o.x||0} ${o.y||0})</span>${o._d!=null?` <span class=qld>${o._d}km</span>`:''}</div>`).join('');
  box.innerHTML=head+(body||'<div class=qlrow>None on map right now.</div>');
}
async function pyramidList(lv){
  loadQuickPrefs();
  const box=document.getElementById('quick_list'); if(!box)return;
  const hx=parseFloat(QPREFS.hx), hy=parseFloat(QPREFS.hy), hasHive=!isNaN(hx)&&!isNaN(hy);
  box.style.display=''; try{ box.scrollIntoView({behavior:'smooth',block:'start'}); }catch(e){}
  let rows=(PYR_ROWS||[]).filter(o=>parseInt(o.level||0)===lv);
  rows.forEach(o=>{ o._d=hasHive?Math.round(Math.hypot((o.x||0)-hx,(o.y||0)-hy)):((o.dist!=null)?o.dist:null); });
  if(QPREFS.sort==='power') rows.sort((a,b)=>(b.power||0)-(a.power||0)); else rows.sort((a,b)=>((a._d==null?9e9:a._d)-(b._d==null?9e9:b._d)));
  const n=QPREFS.n||40, shown=rows.slice(0,n);
  const cnt=shown.length<rows.length?`nearest ${shown.length} of ${rows.length}`:`${rows.length} on map`;
  const coords=shown.map(o=>({name:'Pyramid', level:lv, x:o.x||0, y:o.y||0}));
  _QLIST={fam:'Pyramids', lv:lv, shown:shown, coords:coords, multi:true};
  const acts=`<span class=qlacts><button class=qlpill onclick="qlCopy(this)"${shown.length?'':' disabled'}>Copy</button><button class="qlpill snd" onclick="qlSend(this)"${shown.length?'':' disabled'}>Send</button><button class=qlclose onclick="document.getElementById('quick_list').style.display='none'">&#10005;</button></span>`;
  const head=`<div class=qlh><span><span class=qlgrp>Pyramids</span><span class=qlsub>Pyramid Lv${lv} &middot; ${cnt}</span></span>${acts}</div>`;
  const body=shown.map(o=>{ const st=(o.occupy_status&&o.occupy_status!=='free')?` <span class=qld title="occupied">${o.occupy_status==='mine'?'ours':(o.occupy_tag?('['+o.occupy_tag+']'):'occ')}</span>`:''; return `<div class=qlrow>Lv${o.level||lv} Pyramid <span class=qlc onclick="qcopy(${o.x||0},${o.y||0})" title="click to copy coords">(xy: ${o.x||0} ${o.y||0})</span>${o._d!=null?` <span class=qld>${o._d}km</span>`:''}${st}</div>`; }).join('');
  box.innerHTML=head+(body||'<div class=qlrow>None on map right now.</div>');
}
var _AA_RAW=[]; var _AA_SHOWN=[]; var _AA_TS=0; var _AA_FETCH_AT=0;   // _AA_TS=epoch(s) del último fetch (para countdown en vivo); _AA_FETCH_AT=ms (re-fetch periódico)
function aaPhase(a){ return a.landed?'LANDED':(a.phase==='wait'?'RALLY':a.phase==='way'?'MARCH':a.phase==='combat'?'COMBAT':a.phase==='return'?'RETURN':a.phase==='scout'?'SCOUT':'SOLO'); }
function aaOwnGid(){ return (typeof LAN_GUILD_ID!=='undefined')?LAN_GUILD_ID:0; }
async function activeAttacksList(){
  loadQuickPrefs();
  AA_OPEN=true; MP_OPEN=false; BUB_OPEN=false; EBOSS_OPEN=false; PYR_OPEN=false; try{ directScHtml(); }catch(e){}   // acordeón: al abrir ActiveAttacks, pliega MyPresets/EventBoss/Pyramids
  const box=document.getElementById('quick_list'); if(!box)return;
  box.style.display=''; try{ box.scrollIntoView({behavior:'smooth',block:'start'}); }catch(e){}
  box.innerHTML=`<div class=qlh><span>Loading active attacks&hellip;</span></div>`;
  const ok=await aaFetchRaw();
  if(!ok){ box.innerHTML='<div class=qlh>Error loading attacks</div>'; return; }
  aaRender();
}
async function aaFetchRaw(){
  try{ const r=await fetch('/api/attacks?_='+Date.now(),{cache:'no-store'}); const j=await r.json(); _AA_RAW=(j&&j.attacks)||[]; _AA_TS=Date.now()/1000; _AA_FETCH_AT=Date.now(); return true; }catch(e){ return false; }
}
// ---- Bubble: jugadores SIN bubble activo (shield=no) — objetivos atacables. Usa /api/players?shield=no (ya soportado por query_players). ----
function bubMinPow(){ try{ const v=parseInt(localStorage.getItem('bub_minpow')); return (isNaN(v)||v<0)?120:v; }catch(e){ return 120; } }   // poder mínimo del monarca (M), default 120
function bubSetMinPow(inp){ let v=parseInt(inp&&inp.value); if(isNaN(v)||v<0)v=0; try{ localStorage.setItem('bub_minpow',String(v)); }catch(e){} bubbleList(); }
function bubToggle(){ if(BUB_OPEN){ aaHideList(); directScHtml(); } else { bubbleList(); } }   // toggle: si ya está abierta, la cierra
async function bubbleList(){
  loadQuickPrefs();
  BUB_OPEN=true; AA_OPEN=false; MP_OPEN=false; EBOSS_OPEN=false; PYR_OPEN=false; try{ directScHtml(); }catch(e){}   // acordeón: al abrir Bubble, pliega los demás
  const box=document.getElementById('quick_list'); if(!box)return;
  const hx=parseFloat(QPREFS.hx), hy=parseFloat(QPREFS.hy), hasHive=!isNaN(hx)&&!isNaN(hy);
  box.style.display=''; box.innerHTML=`<div class=qlh><span>Loading players without bubble&hellip;</span></div>`;
  try{ box.scrollIntoView({behavior:'smooth',block:'start'}); }catch(e){}
  let rows=[]; const minpow=bubMinPow();   // poder MÍNIMO del monarca (M); query_players filtra por pw_min (millones)
  try{ const r=await fetch(`/api/players?shield=no&sort=power&limit=300&max_seen=600&pw_min=${minpow}&_=${Date.now()}`,{cache:'no-store'}); const j=await r.json(); rows=(j&&j.rows)||[]; }catch(e){ box.innerHTML='<div class=qlh>Error loading list</div>'; return; }
  rows=rows.filter(p=>!(p.shield>0));   // defensivo: SOLO sin bubble (shield==0)
  rows.forEach(o=>{ o._d=hasHive?Math.round(Math.hypot((o.x||0)-hx,(o.y||0)-hy)):((o.dist!=null)?o.dist:null); });
  if(hasHive) rows.sort((a,b)=>((a._d==null?9e9:a._d)-(b._d==null?9e9:b._d)));
  else rows.sort((a,b)=>(b.power||0)-(a.power||0));
  const n=QPREFS.n||40, shown=rows.slice(0,n);
  const cnt=(shown.length<rows.length?`nearest ${shown.length} of ${rows.length}`:`${rows.length} without bubble`)+` &middot; power &ge;${minpow}M`;
  const coords=shown.map(o=>({name:(o.name||'player'), level:(o.level||0), x:o.x||0, y:o.y||0}));
  _QLIST={fam:'Bubble', lv:0, shown:shown, coords:coords, multi:true};
  const acts=`<span class=qlacts><button class=qlpill onclick="qlCopy(this)"${shown.length?'':' disabled'}>Copy</button><button class="qlpill snd" onclick="qlSend(this)"${shown.length?'':' disabled'}>Send</button><button class=qlclose onclick="aaHideList();directScHtml()">&#10005;</button></span>`;
  const head=`<div class=qlh><span><span class=qlgrp>No Bubble</span><span class=qlsub>${cnt}</span></span>${acts}</div>`;
  const body=shown.map(o=>{ const tg=o.tag?` <span class=qld>[${svsEsc(o.tag)}]</span>`:''; const pw=(o.power>0)?` <span class=qld>${fmtPow(o.power)}</span>`:''; return `<div class=qlrow>${svsEsc(o.name||'?')}${tg} <span class=qlc onclick="qcopy(${o.x||0},${o.y||0})" title="click to copy coords">(xy: ${o.x||0} ${o.y||0})</span>${o._d!=null?` <span class=qld>${o._d}km</span>`:''}${pw}</div>`; }).join('');
  const ctrls=`<div class=qlrow style="display:flex;align-items:center;gap:8px;font-size:12px;opacity:.9"><label style="display:flex;align-items:center;gap:6px">Min monarch power (M): <input type=number min=0 step=10 value=${minpow} onchange="bubSetMinPow(this)" onkeydown="if(event.key==='Enter'){event.preventDefault();bubSetMinPow(this);}" style="width:72px"></label></div>`;
  box.innerHTML=head+ctrls+(body||'<div class=qlrow>No unshielded players right now.</div>');
}
// mientras la lista esté abierta: countdown del ETA en vivo cada 1s (solo actualiza el texto, sin reconstruir) + re-fetch cada 12s (refresca el set: nuevos ataques / quita aterrizados)
function aaTick(){
  if(!AA_OPEN) return;
  const box=document.getElementById('quick_list'); if(!box || box.style.display==='none') return;
  if(Date.now()-_AA_FETCH_AT > 12000){ aaFetchRaw().then(function(ok){ if(ok && AA_OPEN) aaRender(); }); return; }
  const delta=Date.now()/1000 - _AA_TS;
  (_AA_SHOWN||[]).forEach(function(a,i){ const el=document.getElementById('aae'+i); if(!el) return; const e=Math.max(0,Math.round((a.eta||0)-delta)); el.textContent=(a.landed||e<=0)?'landed':(fmtETA(e)||'0s'); });
}
setInterval(aaTick, 1000);
function aaToggleOwn(cb){ try{ localStorage.setItem('aa_own', (cb&&cb.checked)?'1':'0'); }catch(e){} aaRender(); }
function aaRender(){
  const box=document.getElementById('quick_list'); if(!box)return;
  const showOwn=(localStorage.getItem('aa_own')||'0')==='1';
  const og=aaOwnGid();
  let atks=_AA_RAW.slice();
  if(!showOwn) atks=atks.filter(a=>(a.guild||0)!==og);
  const ownHidden=showOwn?0:(_AA_RAW.length-atks.length);
  const _delta=Date.now()/1000 - (_AA_TS||Date.now()/1000);
  atks.forEach(a=>a._eta=Math.max(0,Math.round((a.eta||0)-_delta)));
  atks.sort((a,b)=>(a._eta||0)-(b._eta||0));
  const n=QPREFS.n||40, shown=atks.slice(0,n);
  _AA_SHOWN=shown; _QLIST={fam:'ActiveAttacks', shown:shown, multi:false};
  const cnt=shown.length<atks.length?`nearest ${shown.length} of ${atks.length}`:`${atks.length} active`;
  const acts=`<span class=qlacts><button class=qlpill onclick="aaCopy(this)"${shown.length?'':' disabled'}>Copy</button><button class="qlpill snd" onclick="aaSend(this)"${shown.length?'':' disabled'}>Send</button><button class=qlclose onclick="aaHideList();directScHtml()">&#10005;</button></span>`;
  const head=`<div class=qlh><span><span class=qlgrp>Active Attacks</span><span class=qlsub>${cnt}</span></span>${acts}</div>`;
  const ctrls=`<div class=qlrow style="display:flex;align-items:center;gap:8px"><label style="display:flex;align-items:center;gap:6px;cursor:pointer;font-size:12px;opacity:.9"><input type=checkbox ${showOwn?'checked':''} onchange="aaToggleOwn(this)"> Show own alliance${(ownHidden>0)?` <span class=qld>(${ownHidden} hidden)</span>`:''}</label></div>`;
  const body=shown.map((a,i)=>{
    const who=svsEsc(a.name?((a.tag?('['+a.tag+'] '):'')+a.name):('u'+(a.uid||0)));
    const ph=aaPhase(a);
    const cw=(a.count>1)?` <span style="opacity:.6">&times;${a.count}</span>`:'';
    const tgt=svsEsc(a.tname?(a.tname+(a.tlevel?(' Lv'+a.tlevel):'')):('tile '+(a.tx||0)+','+(a.ty||0)));
    const eta=(a.landed||a._eta<=0)?'landed':(fmtETA(a._eta)||'0s');
    return `<div class=qlrow><b style="color:#93c5fd">${ph}</b>${cw} ${who} &rarr; ${tgt} <span class=qlc onclick="qcopy(${a.tx||0},${a.ty||0})" title="click to copy coords">(xy: ${a.tx||0} ${a.ty||0})</span> <span class=qld id="aae${i}">${eta}</span></div>`;
  }).join('');
  box.innerHTML=head+ctrls+(body||'<div class=qlrow>No active attacks right now.</div>');
}
function aaCopy(btn){
  const L=_AA_SHOWN||[]; if(!L.length)return;
  const txt=L.map(a=>{
    const who=a.name?((a.tag?('['+a.tag+'] '):'')+a.name):('u'+(a.uid||0));
    const ph=aaPhase(a); const cw=(a.count>1)?(' x'+a.count):'';
    const tgt=a.tname?(a.tname+(a.tlevel?(' Lv'+a.tlevel):'')):('tile '+(a.tx||0)+','+(a.ty||0));
    const eta=a.landed?'landed':(fmtETA(a._eta)||'0s');
    return `${ph}${cw} ${who} -> ${tgt} (${a.tx||0},${a.ty||0}) ${eta}`;
  }).join(String.fromCharCode(10));
  _clip(txt);
  if(btn){ const t=btn.textContent; btn.textContent='✓ Copied'; setTimeout(function(){ try{btn.textContent=t;}catch(e){} },1200); }
}
async function aaSend(btn){
  const L=_AA_SHOWN||[]; if(!L.length)return;
  const coords=L.map(function(a){ return {name:(a.tname||'target'), level:(a.tlevel||0), x:(a.tx||0), y:(a.ty||0)}; }).filter(function(c){ return c.x&&c.y; });
  if(!coords.length){ if(btn){ const t0=btn.textContent; btn.textContent='✗ no coords'; setTimeout(function(){try{btn.textContent=t0;}catch(e){}},2000);} return; }
  const t=btn?btn.textContent:''; if(btn){ btn.disabled=true; btn.textContent='⏳'; }
  try{
    const r=await fetch('/api/send_coords',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({coords:coords, recipient_uid:0, limit:coords.length})});
    let j={}; try{ j=await r.json(); }catch(e){}
    if(r.ok && j.ok){ if(btn) btn.textContent='✓ Sent '+j.count; }
    else { if(btn) btn.textContent='✗ '+String(j.detail||j.error||('HTTP '+r.status)).slice(0,22); }
  }catch(e){ if(btn) btn.textContent='✗ Error'; }
  finally{ if(btn){ setTimeout(function(){ try{ btn.disabled=false; btn.textContent=t; }catch(e){} }, 3500); } }
}
function qToggleMore(btn){ const d=btn.nextElementSibling; const open=(d.style.display==='none'); d.style.display=open?'':'none'; btn.classList.toggle('on',open); btn.innerHTML=(open?'&#9662; ':'&#9656; ')+btn.dataset.lbl; QABSENT_OPEN=open; }
async function toggleQuickMenu(){
  const menu=document.getElementById('quick_menu'), btn=document.getElementById('qmenu_btn'); if(!menu)return;
  if(menu.style.display!=='none'){ menu.style.display='none'; if(btn){btn.classList.remove('on'); btn.innerHTML='&#9776; Menu';} return; }
  if(btn){btn.classList.add('on'); btn.innerHTML='&#9776; Menu &#9650;';}
  menu.style.display=''; loadQuickPrefs(); await loadQuick();
}
async function loadQuick(){
  loadQuickPrefs(); loadQuickSel();
  const menu=document.getElementById('quick_menu');
  const menuOpen=!!(menu && menu.style.display!=='none');
  let fams=[];
  try{ const r=await fetch('/api/quick?_='+Date.now(),{cache:'no-store'}); fams=await r.json(); }catch(e){ const gr=document.getElementById('quick_grid'); if(gr&&menuOpen)gr.innerHTML='<div class=stat style="padding:14px">Error loading shortcuts</div>'; return; }
  if(!Array.isArray(fams))fams=[];
  _QFAMS=fams; _QCOUNT={}; fams.forEach(f=>{ (f.levels||[]).forEach(l=>{ _QCOUNT[f.name+'|'+l.lv]=l.count; }); });
  try{ const rp=await fetch('/api/relics?kind=pyramid&limit=0&_='+Date.now(),{cache:'no-store'}); const jp=await rp.json(); PYR_ROWS=(jp&&jp.rows)||[]; }catch(e){ PYR_ROWS=[]; }
  PYR_BY_LV={}; (PYR_ROWS||[]).forEach(p=>{ const lv=parseInt(p.level||0); if(lv>0) PYR_BY_LV[lv]=(PYR_BY_LV[lv]||0)+1; });
  try{ const ra=await fetch('/api/attacks?_='+Date.now(),{cache:'no-store'}); const ja=await ra.json(); let _aa=(ja&&Array.isArray(ja.attacks))?ja.attacks:[]; const _so=(localStorage.getItem('aa_own')||'0')==='1'; const _og=(typeof LAN_GUILD_ID!=='undefined')?LAN_GUILD_ID:0; if(!_so) _aa=_aa.filter(a=>(a.guild||0)!==_og); ATTACKS_N=_aa.length; }catch(e){}
  qmainHtml(); directScHtml();
  const grid=document.getElementById('quick_grid');
  if(grid && menuOpen){
    const GRPORD={event:0,boss:1,shadow:2,other:3,normal:4};
    const qsort=x=>x.sort((a,b)=>{ const ga=(GRPORD[a.group]!=null?GRPORD[a.group]:9),gb=(GRPORD[b.group]!=null?GRPORD[b.group]:9); if(ga!==gb)return ga-gb; if(b.live!==a.live)return b.live-a.live; return a.name.localeCompare(b.name); });
    const present=qsort(fams.filter(f=>f.live>0)), absent=qsort(fams.filter(f=>f.live<=0));
    const hint=document.getElementById('q_hint'); if(hint)hint.textContent=present.length+' families on map now';
    grid.innerHTML=present.map(qgrpHtml).join('')||'<div class=stat style="padding:14px">No families yet (scanner warming up).</div>';
    const abox=document.getElementById('quick_absent');
    if(abox){ if(absent.length){ const lbl=`Show ${absent.length} families with none on map right now`; const op=QABSENT_OPEN; abox.innerHTML=`<button class="qshowmore${op?' on':''}" data-lbl="${lbl}" onclick="qToggleMore(this)">${op?'&#9662; ':'&#9656; '}${lbl}</button><div style="display:${op?'':'none'};margin-top:12px">${absent.map(qgrpHtml).join('')}</div>`; } else abox.innerHTML=''; }
  }
  _qLastTick=Date.now();
}
async function quickList(fam,lv){
  loadQuickPrefs();
  MP_OPEN=false; AA_OPEN=false; EBOSS_OPEN=false; PYR_OPEN=false; try{ directScHtml(); }catch(e){}   // clic en un monstruo del panel superior -> colapsa MyPresets/ActiveAttacks/EventBoss/Pyramids
  const box=document.getElementById('quick_list'); if(!box)return;
  const hx=parseFloat(QPREFS.hx), hy=parseFloat(QPREFS.hy), hasHive=!isNaN(hx)&&!isNaN(hy);
  box.style.display=''; box.innerHTML=`<div class=qlh><span>Loading ${fam} Lv${lv}&hellip;</span></div>`;
  try{ box.scrollIntoView({behavior:'smooth',block:'start'}); }catch(e){}
  let q=`fams=${encodeURIComponent(fam)}&max_seen=2700&limit=0`;
  if(hasHive) q+=`&cx=${hx}&cy=${hy}`;
  let rows=[];
  try{ const r=await fetch(`/api/data?${q}&_=${Date.now()}`,{cache:'no-store'}); const j=await r.json(); rows=(j&&j.rows)||[]; }catch(e){ box.innerHTML='<div class=qlh>Error loading list</div>'; return; }
  rows=rows.filter(o=>parseInt(o.level||0)===lv);
  // Opción 1: el contador del pill se AUTO-CORRIGE a lo que realmente devuelve la lista fresca.
  // Evita contadores fantasma: cuando un evento termina, _QCOUNT queda cacheado con la cuenta vieja
  // (solo se refresca cada ~20s con auto ON en la pestaña Shortcuts) mientras la lista ya da 0.
  // Al pulsar, sincronizamos _QCOUNT y re-pintamos los pills que lo leen (shortcuts + EventBoss).
  _QCOUNT[fam+'|'+lv]=rows.length;
  try{ qmainHtml(); directScHtml(); }catch(e){}
  rows.forEach(o=>{ o._d=hasHive?Math.round(Math.hypot((o.x||0)-hx,(o.y||0)-hy)):null; });
  if(QPREFS.sort==='power') rows.sort((a,b)=>(b.power||0)-(a.power||0));
  else if(hasHive) rows.sort((a,b)=>a._d-b._d);
  const n=QPREFS.n||40, shown=rows.slice(0,n);
  const grp=((_QFAMS||[]).find(f=>f.name===fam)||{}).group||'';
  const glbl={boss:'Bosses',event:'Event Monsters',shadow:'Shadow Monsters',other:'Other',normal:'Monsters'}[grp]||'Monsters';
  const cnt=shown.length<rows.length?`nearest ${shown.length} of ${rows.length}`:`${rows.length} on map`;
  const picks=[...new Set(rows.map(o=>(o.name||'')+'|'+(o.level||0)))].map(k=>{ const i=k.lastIndexOf('|'); return {name:k.slice(0,i), level:parseInt(k.slice(i+1))||0}; }).filter(p=>p.name);
  _QLIST={fam:fam, lv:lv, shown:shown, picks:picks};
  const acts=`<span class=qlacts><button class=qlpill onclick="qlCopy(this)"${shown.length?'':' disabled'}>Copy</button><button class="qlpill snd" onclick="qlSend(this)"${shown.length?'':' disabled'}>Send</button><button class=qlclose onclick="document.getElementById('quick_list').style.display='none'">&#10005;</button></span>`;
  const head=`<div class=qlh><span><span class=qlgrp>${glbl}</span><span class=qlsub>${fam} Lv${lv} &middot; ${cnt}</span></span>${acts}</div>`;
  const body=shown.map(o=>`<div class=qlrow>Lv${lv} ${fam} <span class=qlc onclick="qcopy(${o.x||0},${o.y||0})" title="click to copy coords">(xy: ${o.x||0} ${o.y||0})</span>${o._d!=null?` <span class=qld>${o._d}km</span>`:''}</div>`).join('');
  box.innerHTML=head+(body||'<div class=qlrow>None on map right now.</div>');
}
function qlCopy(btn){ const L=(_QLIST&&_QLIST.shown)||[]; if(!L.length)return; const co=(_QLIST&&_QLIST.coords)||null; const multi=!!(_QLIST&&_QLIST.multi); const txt=L.map((o,i)=>{ const nm=co?((co[i]&&co[i].name)||''):(multi?(o.name||''):_QLIST.fam); const lv=(multi?(o.level||_QLIST.lv):_QLIST.lv); return `Lv${lv} ${nm} (xy: ${o.x||0} ${o.y||0})${o._d!=null?' '+o._d+'km':''}`; }).join(String.fromCharCode(10)); _clip(txt); if(btn){ const t=btn.textContent; btn.textContent='✓ Copied'; setTimeout(function(){ try{btn.textContent=t;}catch(e){} },1200); } }
async function qlSend(btn){ const hasCo=!!(_QLIST&&_QLIST.coords&&_QLIST.coords.length); const picks=(_QLIST&&_QLIST.picks)||[]; if(!hasCo && !picks.length)return; const lim=(_QLIST&&_QLIST.shown?_QLIST.shown.length:20); const payload=hasCo?{coords:_QLIST.coords, recipient_uid:0, limit:lim}:{picks:picks, recipient_uid:0, limit:lim}; const t=btn?btn.textContent:''; if(btn){ btn.disabled=true; btn.textContent='⏳'; } try{ const r=await fetch('/api/send_coords',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)}); let j={}; try{ j=await r.json(); }catch(e){} if(r.ok && j.ok){ if(btn) btn.textContent='✓ Sent '+j.count; } else { if(btn) btn.textContent='✗ '+String(j.detail||j.error||('HTTP '+r.status)).slice(0,22); } }catch(e){ if(btn) btn.textContent='✗ Error'; } finally{ if(btn){ setTimeout(function(){ try{ btn.disabled=false; btn.textContent=t; }catch(e){} }, 3500); } } }
function fmtAge(s){s=Math.abs(+s||0); if(s<60)return s+'s'; if(s<3600)return Math.round(s/60)+'m'; if(s<86400)return Math.round(s/3600)+'h'; return Math.round(s/86400)+'d';}
function fmtETA(s){s=+s||0; const sign=s<0?'-':''; s=Math.abs(s); if(s<60)return sign+s+'s'; if(s<3600)return sign+Math.floor(s/60)+'m'+(s%60)+'s'; return sign+Math.floor(s/3600)+'h'+Math.floor((s%3600)/60)+'m';}

// ── SVS: config de enemigo + Hit-list ────────────────────────────────────────
function svsEsc(s){ return String(s==null?'':s).replace(/[&<>"]/g, c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c])); }
// Indicador online/offline. Prioriza lastseen AUTORITATIVO (__lastseen del perfil /
// member-list: epoch unix, >1) cuando está disponible; si no, cae a la actividad
// INFERIDA (marchas/relocations/burbujas/PvP). Nota: el user_summary del mapa NO
// trae lastseen para enemigos (siempre 0) -> para ellos se usa el inferido.
function svsOnlineDot(la, lastseen, now){
 if(lastseen && lastseen>1 && now){
   const ago = now - lastseen;
   if(ago < 300)  return '<span title="Online (authoritative, <5 min)" style="color:#22c55e;margin-right:5px">●</span>';
   if(ago < 1800) return '<span title="Recently online (authoritative) — '+fmtAge(ago)+' ago" style="color:#fbbf24;margin-right:5px">●</span>';
   return '<span title="Offline (authoritative) — last online '+fmtAge(ago)+' ago" style="color:#6b7280;margin-right:5px">●</span>';
 }
 if(la==null) return '<span title="No activity observed yet" style="color:#6b7280;margin-right:5px">○</span>';
 if(la < 300)  return '<span title="Online — acted <5 min ago (inferred)" style="color:#22c55e;margin-right:5px">●</span>';
 if(la < 1800) return '<span title="Recently active — '+fmtAge(la)+' ago (inferred)" style="color:#fbbf24;margin-right:5px">●</span>';
 return '<span title="Offline — last action '+fmtAge(la)+' ago (inferred)" style="color:#6b7280;margin-right:5px">●</span>';
}
// Presence badge: where is this enemy right now? On their own server, or crossed
// over to OUR server (= invading us, critical), or gone/offline.
// Ventana de ataque óptima (horas en que el enemigo suele estar offline).
function svsOffWindow(o){
 if(!o) return '<span class=stat title="Not enough activity data yet">—</span>';
 const hh=h=>(h<10?'0':'')+h+':00';
 const lbl=hh(o.start)+'–'+hh(o.end);
 if(o.now_in) return `<span class=tag title="Likely OFFLINE right now (${o.confidence} confidence) — good time to hit" style="color:#34d399;border-color:#34d39966">✅ ${lbl}</span>`;
 const col=o.confidence==='low'?'#94a3b8':'#93c5fd';
 return `<span title="Usually offline ${lbl} server-local (${o.confidence} confidence)" style="color:${col};font-size:11px">${lbl}</span>`;
}
function svsPresence(x, j){
 const now=(j&&j.now)||Math.floor(Date.now()/1000);
 const ours=(j&&j.our_server)||0, enemySv=(j&&j.effective_enemy)||0;
 const p=x.presence||'stale';
 const bdg=(txt,c,tip)=>`<span class=tag title="${tip}" style="color:${c};border-color:${c}66">${txt}</span>`;
 if(p==='field'){
   const ss=x.seen_srv||0;
   if(ours && ss===ours)
     return bdg('⚠ ON OUR SERVER','#f87171','Seen on OUR server (#'+ours+') '+fmtAge(x.age)+' ago — this enemy crossed over to us. Imminent threat.');
   if(enemySv && ss===enemySv)
     return bdg('on their server','#34d399','Seen on their own server (#'+enemySv+') '+fmtAge(x.age)+' ago — on the SVS battlefield where we scan.');
   return bdg('ON FIELD','#34d399','Physically seen on the map '+fmtAge(x.age)+' ago'+(ss?(' (server #'+ss+')'):''));
 }
 if(p==='online') return bdg('Online','#fbbf24','Member-list reports ONLINE, but not seen on the map recently ('+fmtAge(x.age)+'). Likely on their own server.');
 if(p==='gone'){ const ago=(x.presence_since&&now)?(fmtAge(now-x.presence_since)+' ago'):''; return bdg('Gone','#9ca3af','OFFLINE per member-list'+(x.presence_since?(' since '+fmtAge(now-x.presence_since)+' ago'):'')+' and no recent sighting — likely went back to their server')+`<span style="color:#6b7280;font-size:10px;margin-left:4px">${ago}</span>`; }
 return bdg('?','#6b7280','No fresh data — last position seen '+fmtAge(x.age)+' ago');
}
async function loadEnemyConfig(){
 try{
  const r = await fetch('/api/enemy_config?_='+Date.now(),{cache:'no-store'});
  if(!r.ok) return;
  const j = await r.json();
  const os=document.getElementById('svs_our_server'); if(os) os.textContent=j.our_server;
  const es=document.getElementById('svs_enemy_server'); if(es && document.activeElement!==es) es.value=j.enemy_server||'';
  const et=document.getElementById('svs_enemy_tags'); if(et && document.activeElement!==et) et.value=(j.enemy_tags||[]).join(', ');
  const fs=j.foreign_servers||[];
  const fdiv=document.getElementById('svs_foreign');
  if(fdiv){
    if(j.svs_active || j.enemy_server>0){
      fdiv.innerHTML = (fs.length
        ? ('Foreign servers now: ' + fs.map(f=>`<b style="cursor:pointer;color:#fbbf24" title="Set as enemy" onclick="document.getElementById('svs_enemy_server').value=${f.server}">${f.server}</b> (${f.count})`).join(' · ') + ' · <span style="opacity:.6">click to set</span>')
        : '')
        + (j.effective_enemy ? ` &nbsp; <span style="color:#f87171">Active enemy: <b>#${j.effective_enemy}</b></span>` : '');
    } else {
      fdiv.innerHTML = `<span style="opacity:.75">No active SVS: no rival server detected (needs a foreign server with ≥${j.min_players||20} players). When SVS starts, the enemy shows up here automatically — or set a server/tag manually to start now.</span>`;
    }
  }
 }catch(e){}
}
async function saveEnemyConfig(){
 const server=parseInt(document.getElementById('svs_enemy_server').value)||0;
 const tags=document.getElementById('svs_enemy_tags').value.split(',').map(s=>s.trim()).filter(Boolean);
 const msg=document.getElementById('svs_cfg_msg');
 try{
  const r=await fetch('/api/enemy_config',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({enemy_server:server,enemy_tags:tags})});
  const j=await r.json();
  if(r.ok && j.ok){ msg.textContent='✓ saved ('+j.mode+(j.enemy_server?(' #'+j.enemy_server):'')+')'; msg.style.color='#34d399'; loadSvs(); }
  else { msg.textContent='⚠ '+(j.detail||j.error||('HTTP '+r.status)); msg.style.color='#f87171'; }
 }catch(e){ msg.textContent='error: '+e; msg.style.color='#f87171'; }
}
// Los toggles del tab SVS delegan en las prefs POR USUARIO (server-side), igual que ⚙ Settings.
function svsToggleAlerts(){ notifSet('enemy', !!document.getElementById('svs_alerts_on').checked); }
function notifEnabled(){ return !!NOTIF_PREFS.browser; }
function svsToggleNotif(){ const cb=document.getElementById('svs_notif_toggle'); if(cb) notifSetBrowser(cb); }
function svsSyncAlertUI(){
 const cb=document.getElementById('svs_alerts_on'); if(cb) cb.checked = (NOTIF_PREFS.enemy!==false);
 const pm=document.getElementById('svs_pre_min'); if(pm && document.activeElement!==pm) pm.value = svsPreMin();
 const nt=document.getElementById('svs_notif_toggle');
 if(nt) nt.checked = notifEnabled() && !!window.Notification && Notification.permission==='granted';
}
async function loadSvs(){
 svsSyncAlertUI();
 loadEnemyConfig();
 const hg=document.getElementById('svs_hide_gone');
 if(hg && document.activeElement!==hg) hg.checked = localStorage.getItem('svs_hide_gone')==='1';
 const st=document.getElementById('svs_state').value;
 const cx=document.getElementById('svs_cx').value, cy=document.getElementById('svs_cy').value;
 const sort=document.getElementById('svs_sort').value;
 let q='sort='+encodeURIComponent(sort)+'&limit=150';
 if(st) q+='&state='+st;
 if(cx!==''&&cy!=='') q+='&cx='+(+cx)+'&cy='+(+cy);
 let j;
 try{ const r=await fetch('/api/svs_hitlist?'+q+'&_='+Date.now(),{cache:'no-store'}); j=await r.json(); }catch(e){ return; }
 const tb=document.getElementById('svs_rows'); if(!tb) return; tb.innerHTML='';
 if(!j.svs_active && !(j.rows||[]).length){
   tb.innerHTML='<tr><td colspan=12 style="text-align:center;color:#94a3b8;padding:18px">No enemy defined. During an SVS, enemies appear here automatically; or set an enemy server/tag above.</td></tr>';
   const s0=document.getElementById('stat'); if(s0) s0.textContent='SVS: no active enemy';
   return;
 }
 const STL={open:['OPEN','#34d399'],drops_soon:['DROPPING SOON','#fbbf24'],shielded:['SHIELDED','#60a5fa']};
 const hideGone = document.getElementById('svs_hide_gone') && document.getElementById('svs_hide_gone').checked;
 let rows=(j.rows||[]); let hidden=0;
 if(hideGone){ const before=rows.length; rows=rows.filter(x=>x.presence!=='gone'&&x.presence!=='stale'); hidden=before-rows.length; }
 rows.forEach((x,i)=>{
  const [lbl,col]=STL[x.state]||['?','#cbd5e1'];
  const bub = x.shield_eta>0 ? fmtETA(x.shield_eta) : (x.shield_tier>0?('tier'+x.shield_tier):'—');
  const act = (x.last_active==null)?'—':fmtAge(x.last_active);
  const coords = (x.x||x.y) ? (x.x+','+x.y) : '';
  const cp = coords ? `<span class=cp onclick="navigator.clipboard.writeText('${x.x},${x.y}')">${coords} &#8682;</span>` : '—';
  const tr=document.createElement('tr');
  // Destacar la fila según el estado de burbuja (el objetivo del SVS): sin escudo
  // = atacable AHORA (verde), a punto de caer (ámbar, además parpadea).
  if(x.state==='open') tr.style.background='rgba(52,211,153,0.12)';
  else if(x.state==='drops_soon'){ tr.style.background='rgba(251,191,36,0.16)'; tr.classList.add('eta-imminent'); }
  const ally = x.tag ? ('['+svsEsc(x.tag)+']') : '';
  const pwTxt = x.powerM>0 ? (x.powerM+'M') : '?';
  let relocBadge='';
  if(x.reloc){ const c=x.reloc.crossed; relocBadge=` <span title="Relocated ${fmtAge(x.reloc.age)} ago → ${x.reloc.to[0]},${x.reloc.to[1]}${c?' (jumped to OUR server)':''}" style="font-size:10px;color:${c?'#fca5a5':'#93c5fd'};border:1px solid ${c?'#f8717188':'#93c5fd66'};border-radius:3px;padding:0 3px">↗${c?'!':''}</span>`; }
  tr.innerHTML=`<td>${i+1}</td>`
   +`<td>${svsOnlineDot(x.last_active, x.lastseen, j.now)}<span class=plink title="Activity / best attack window" onclick="openPlayerActivity(${x.uid})">${svsEsc(x.name)||'?'}</span>${relocBadge}</td>`
   +`<td>${ally}</td>`
   +`<td>${svsPresence(x, j)}</td>`
   +`<td><span class=tag style="color:${col};border-color:${col}66">${lbl}</span></td>`
   +`<td>${bub}</td>`
   +`<td>${pwTxt}</td><td>${cp}</td>`
   +`<td>${x.dist==null?'—':x.dist}</td><td>${act}</td>`
   +`<td>${svsOffWindow(x.off_window)}</td>`
   +`<td><b>${x.score}</b></td>`;
  tb.appendChild(tr);
 });
 const stEl=document.getElementById('stat');
 if(stEl) stEl.textContent=(j.total||0)+' enemies · mode '+(j.mode)+(j.enemy_server?(' #'+j.enemy_server):'')+(hidden?(' · '+hidden+' hidden (gone)'):'');
}
let SVS_SUB='hitlist';
function svsSubView(which){
 SVS_SUB=which;
 document.getElementById('svs_sub_hitlist').style.display=(which==='hitlist'?'':'none');
 document.getElementById('svs_sub_defense').style.display=(which==='defense'?'':'none');
 document.getElementById('svs_sub_reinforce').style.display=(which==='reinforce'?'':'none');
 document.getElementById('svs_sub_hl_btn').classList.toggle('on', which==='hitlist');
 document.getElementById('svs_sub_def_btn').classList.toggle('on', which==='defense');
 document.getElementById('svs_sub_reinf_btn').classList.toggle('on', which==='reinforce');
 svsRefresh();
}
function svsRefresh(){ if(SVS_SUB==='defense') loadDefense(); else if(SVS_SUB==='reinforce') loadReinforce(); else loadSvs(); }
async function loadReinforce(){
 let j;
 try{ const r=await fetch('/api/svs_reinforce?_='+Date.now(),{cache:'no-store'}); j=await r.json(); }catch(e){ return; }
 const tb=document.getElementById('svs_reinf_rows'); if(!tb) return; tb.innerHTML='';
 if(!(j.rows||[]).length){
   tb.innerHTML='<tr><td colspan=6 style="text-align:center;color:#94a3b8;padding:16px">No enemy reinforcements detected right now.</td></tr>';
 } else {
   j.rows.forEach(x=>{
     const ally=x.tgt_tag?('['+svsEsc(x.tgt_tag)+']'):'';
     const pw=x.tgt_powerM>0?(x.tgt_powerM+'M'):'?';
     const tr=document.createElement('tr');
     tr.innerHTML=`<td><span class=plink title="Activity / best attack window" onclick="openPlayerActivity(${x.tgt_uid})">${svsEsc(x.tgt_name)||('u'+x.tgt_uid)}</span></td>`
       +`<td>${ally}</td><td>${pw}</td>`
       +`<td><span class=cp onclick="navigator.clipboard.writeText('${x.x},${x.y}')">${x.x},${x.y} &#8682;</span></td>`
       +`<td>${x.count}</td><td>${fmtETA(x.eta)}</td>`;
     tb.appendChild(tr);
   });
 }
 const badge=document.getElementById('svs_reinf_count'); if(badge) badge.textContent=(j.total||0)?('('+j.total+')'):'';
 const stEl=document.getElementById('stat'); if(stEl && SVS_SUB==='reinforce') stEl.textContent=(j.total||0)+' enemy targets being reinforced';
}
async function sendSvsShare(){
 const btn=document.getElementById('svs_share_btn'); const orig=btn?btn.textContent:'';
 const msg=document.getElementById('svs_share_msg');
 if(btn){ btn.disabled=true; btn.textContent='⏳ Sending...'; }
 if(msg){ msg.textContent=''; }
 try{
  const r=await fetch('/api/svs_share',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({limit:10})});
  const j=await r.json();
  if(r.ok && j.ok){ if(msg){ msg.style.color='#34d399'; msg.textContent='Shared!'; } }
  else { if(msg){ msg.style.color='#f87171'; msg.textContent=(j.detail||j.error||('HTTP '+r.status)); } }
 }catch(e){ if(msg){ msg.style.color='#f87171'; msg.textContent='Error'; } }
 finally{
  if(btn){ btn.disabled=false; btn.textContent=orig; }
  if(msg){ setTimeout(()=>{ if(msg) msg.textContent=''; }, 5000); }
 }
}
async function loadDefense(){
 let j;
 try{ const r=await fetch('/api/svs_defense?_='+Date.now(),{cache:'no-store'}); j=await r.json(); }catch(e){ return; }
 const tb=document.getElementById('svs_def_rows'); if(!tb) return; tb.innerHTML='';
 if(!(j.rows||[]).length){
   tb.innerHTML='<tr><td colspan=7 style="text-align:center;color:#94a3b8;padding:16px">No incoming attacks on your alliance right now.</td></tr>';
 } else {
   j.rows.forEach(x=>{
     const who=(x.atk_tag?`[${x.atk_tag}] `:'')+(svsEsc(x.atk_name)||('u'+x.atk_uid));
     const tgt=(x.tgt_tag?`[${x.tgt_tag}] `:'')+(svsEsc(x.tgt_name)||('u'+x.tgt_uid));
     const en = x.atk_enemy ? ' <span style="color:#f87171;font-size:10px;border:1px solid #f8717166;border-radius:3px;padding:0 3px">ENEMY</span>' : '';
     const etaCol = x.eta<=60 ? 'color:#fca5a5;font-weight:700' : (x.eta<=300?'color:#fbbf24':'');
     const tr=document.createElement('tr');
     tr.innerHTML=`<td style="${etaCol}">${fmtETA(x.eta)}</td><td>${tgt}</td>`
      +`<td><span class=cp onclick="navigator.clipboard.writeText('${x.x},${x.y}')">${x.x},${x.y} &#8682;</span></td>`
      +`<td><span class=plink title="Activity / best attack window" onclick="openPlayerActivity(${x.atk_uid})">${who}</span>${en}</td><td>${x.atk_powerM}M</td><td>${x.phase}</td><td>${x.count}</td>`;
     tb.appendChild(tr);
   });
 }
 const badge=document.getElementById('svs_def_count'); if(badge) badge.textContent=(j.total||0)?('('+j.total+')'):'';
 const stEl=document.getElementById('stat'); if(stEl && SVS_SUB==='defense') stEl.textContent=(j.total||0)+' incoming attacks on alliance';
}

// ── Respawns: filtros estilo Monsters (pills por familia + multi-select por id) ─
const SEL_RS = new Map();   // id -> label de monstruos especificos
const FAM_RS = new Set();   // nombres de familias activas
const msbox_rs = document.getElementById('msbox_rs');
const msin_rs  = document.getElementById('msin_rs');
const dd_rs    = document.getElementById('dd_rs');

async function loadFamiliesRs(){
 // Reusa /api/families (sin filtro de max_seen — aquí queremos TODAS las familias
 // que existen en la config, aunque no haya ninguna viva ahora mismo).
 try{
  const r = await fetch('/api/families?_='+Date.now(),{cache:'no-store'});
  const j = await r.json();
  const cols={boss:document.getElementById('fams_boss_rs'),event:document.getElementById('fams_event_rs'),
              shadow:document.getElementById('fams_shadow_rs'),other:document.getElementById('fams_other_rs')};
  for(const k in cols) if(cols[k]) cols[k].innerHTML='';
  for(const f of j){
   const b = document.createElement('span');
   b.className = 'fam'+(FAM_RS.has(f.name)?' on':'')+(f.live?'':' z');
   b.textContent = f.name+' ('+f.live+')';
   b.title = f.variants+' tipos en config · '+f.live+' en el mapa ahora';
   b.onclick = () => {
     FAM_RS.has(f.name) ? FAM_RS.delete(f.name) : FAM_RS.add(f.name);
     b.classList.toggle('on');
     loadRespawns();
   };
   (cols[f.group]||cols.event).appendChild(b);
  }
 }catch(e){}
}
function renderChipsRs(){
 [...msbox_rs.querySelectorAll('.chip')].forEach(c=>c.remove());
 for(const [id,lab] of SEL_RS){
  const c = document.createElement('span'); c.className='chip';
  c.innerHTML = lab+' <b>&times;</b>';
  c.querySelector('b').onclick = () => { SEL_RS.delete(id); renderChipsRs(); loadRespawns(); };
  msbox_rs.insertBefore(c, msin_rs);
 }
}
function renderDDRs(){
 const q = msin_rs.value.trim().toLowerCase();
 const list = CAT.filter(o => !SEL_RS.has(o.id) && (!q || o.label.toLowerCase().includes(q))).slice(0,60);
 dd_rs.innerHTML='';
 if(!list.length){ dd_rs.classList.remove('open'); return; }
 for(const o of list){
  const d = document.createElement('div'); d.className='opt';
  d.innerHTML = `${o.name} <small>Lv${o.level} · ${fmtPow(o.power)}</small>`;
  d.onclick = () => { SEL_RS.set(o.id, o.label); msin_rs.value=''; renderChipsRs(); renderDDRs(); loadRespawns(); msin_rs.focus(); };
  dd_rs.appendChild(d);
 }
 dd_rs.classList.add('open');
}
msin_rs.addEventListener('focus', renderDDRs);
msin_rs.addEventListener('input', renderDDRs);
document.addEventListener('click', e => { if(!document.getElementById('ms_rs').contains(e.target)) dd_rs.classList.remove('open'); });
msbox_rs.addEventListener('click', () => msin_rs.focus());

// ---- Sort columnas Respawns ----
let RS_SORT_COL = null;
let RS_SORT_DIR = 'desc';
let _lastRsRows = [];
function setRsSort(col){
 if(RS_SORT_COL === col){
   if(RS_SORT_DIR === 'desc') RS_SORT_DIR = 'asc';
   else { RS_SORT_COL = null; RS_SORT_DIR = 'desc'; }
 } else {
   RS_SORT_COL = col; RS_SORT_DIR = 'desc';
 }
 updateRsSortArrows();
 if(_lastRsRows.length) renderRespawnRows(_lastRsRows);
}
function updateRsSortArrows(){
 for(const c of ['level','last','cycle','predicted','reliability']){
   const el = document.getElementById('rs_sorth_'+c);
   if(!el) continue;
   if(c === RS_SORT_COL){
     el.textContent = RS_SORT_DIR === 'desc' ? '▼' : '▲';
     el.classList.add('active');
   } else {
     el.textContent = '⇅';
     el.classList.remove('active');
   }
 }
}
function getRsRowSortKey(row, col){
 if(col === 'level')      return +row.lv || 0;
 if(col === 'last')       return +row.last_spawn_age_s || 0;     // ASC = recientes primero
 if(col === 'cycle')      return +row.median_cycle_s || 0;
 if(col === 'predicted')  return +row.predicted_in_s || 0;       // ASC = los que respawnean pronto primero (overdue negativos al principio)
 if(col === 'reliability'){
   // high > medium > low
   return row.confidence === 'high' ? 3 : (row.confidence === 'medium' ? 2 : 1);
 }
 return 0;
}
function applyRsClientSort(rows){
 if(!RS_SORT_COL) return rows;
 const dir = RS_SORT_DIR === 'asc' ? 1 : -1;
 return rows.slice().sort((a,b)=>(getRsRowSortKey(a,RS_SORT_COL) - getRsRowSortKey(b,RS_SORT_COL)) * dir);
}
function renderRespawnRows(rowsIn){
 updateRsSortArrows();
 const rows = applyRsClientSort(rowsIn);
 const tb = document.getElementById('rs_rows'); tb.innerHTML='';
 for(const x of rows){
   const tr = document.createElement('tr');
   const overdue = x.predicted_in_s <= 0;
   if(overdue) tr.style.background='rgba(180,80,80,0.20)';   // ya debería haber respawneado
   else if(x.predicted_in_s < 120) tr.style.background='rgba(120,180,80,0.18)';  // <2min
   if(overdue || x.predicted_in_s < 120) tr.classList.add('rs-hot');   // fila resaltada → divisor blanco
   const confClass = x.confidence==='high'?'g-boss':(x.confidence==='medium'?'g-event':'g-normal');
   const g = x.group || 'Normal';
   tr.innerHTML = `<td>${x.name} <span class="grp g-${grpClass(g)}">${g}</span>${x.alive?' <span class=stat>(alive)</span>':''}</td>
    <td class=lvl>${x.lv}</td>
    <td>${x.x}</td><td>${x.y}</td>
    <td>${fmtAge(x.last_spawn_age_s)} ago</td>
    <td>${fmtETA(x.median_cycle_s)}</td>
    <td><b>${overdue?'⏰ overdue ':''}${fmtETA(x.predicted_in_s)}</b></td>
    <td>${x.samples}</td>
    <td><span class="grp ${confClass}">${x.confidence}</span></td>
    <td><span class=cp onclick="navigator.clipboard.writeText('${x.x},${x.y}')">${x.x},${x.y} &#8682;</span></td>`;
   tb.appendChild(tr);
 }
}
async function loadRespawns(){
 try{
  const ms = document.getElementById('rs_ms').value;
  const st = document.getElementById('rs_state').value;
  const an = document.getElementById('rs_normal').value;
  const ids  = [...SEL_RS.keys()].join(',');
  const fams = [...FAM_RS].join('||');
  const q = new URLSearchParams({
    min_samples: ms,
    only_empty:  st==='empty'?'1':'0',
    allow_normal: an,
    ids, fams,
    limit: '500',
  });
  const r = await fetch('/api/respawns?'+q+'&_='+Date.now(),{cache:'no-store'});
  const j = await r.json();
  _lastRsRows = j.respawns || [];
  renderRespawnRows(_lastRsRows);
  document.getElementById('rs_stat').textContent = `${j.n} tiles with prediction`;
 }catch(e){ document.getElementById('rs_stat').textContent='err: '+e; }
}

async function loadRelocations(){
 try{
  const age = document.getElementById('rl_age').value;
  const cx = document.getElementById('rloc_cx').value;
  const cy = document.getElementById('rloc_cy').value;
  const rad = document.getElementById('rloc_rad').value;
  const p = new URLSearchParams({max_age_h: age, limit:'300'});
  if(cx && cy && rad){ p.set('cx',cx); p.set('cy',cy); p.set('radius',rad); }
  const r = await fetch('/api/relocations?'+p+'&_='+Date.now(),{cache:'no-store'});
  const j = await r.json();
  const tb = document.getElementById('rl_rows'); tb.innerHTML='';
  for(const x of j.relocations){
   const tr = document.createElement('tr');
   const nm = x.name ? csEsc(x.name) : `uid${x.uid}`;
   const ally = x.tag ? `[${csEsc(x.tag)}]` : (x.gid?`g${x.gid}`:'');
   tr.innerHTML = `<td>${fmtAge(x.age_s)} ago</td>
    <td>${nm}</td>
    <td>${ally}</td>
    <td>(${x.from[0]},${x.from[1]}) → <b>(${x.to[0]},${x.to[1]})</b></td>
    <td>${x.hop_dist}</td>
    <td>${x.hops_total}</td>
    <td><span class=cp onclick="navigator.clipboard.writeText('${x.to[0]},${x.to[1]}')">${x.to[0]},${x.to[1]} &#8682;</span></td>`;
   tb.appendChild(tr);
  }
  document.getElementById('rl_stat').textContent = `${j.n} reubicaciones`;
 }catch(e){ document.getElementById('rl_stat').textContent='err: '+e; }
}
let SCANNER_PAUSED = false;
async function refreshScannerBtn(){
 try {
   const r = await fetch('/api/scanner', {cache:'no-store'});
   const j = await r.json();
   SCANNER_PAUSED = !!j.paused;
   updateScannerBtn();
   // V3: badge dinámico de server auto-detectado
   const b = document.getElementById('server_badge');
   if (b) {
     const sid = j.server_id || 0;
     if (sid > 0) {
       const w = j.server_id_W || 0, e = j.server_id_E || 0;
       const tip = (w && e && w !== e) ? `W=${w} E=${e} (mismatch)` : `detectado de gameplay broadcast`;
       b.textContent = 'Server #' + sid;
       b.title = tip;
       b.style.display = 'inline-block';
       b.style.color = (w && e && w !== e) ? '#ef4444' : '#fbbf24';
     } else {
       b.textContent = 'Server detectando…';
       b.style.color = '#94a3b8';
       b.style.display = 'inline-block';
     }
   }
 } catch {}
}
function updateScannerBtn(){
 const b = document.getElementById('btn_scan');
 if (!b) return;
 if (SCANNER_PAUSED){
   b.textContent = '▶ Resume Scanner';
   b.style.background = '#1f6b2a';
   b.style.color = '#e6e6e6';
   b.style.borderColor = '#2a8a3a';
 } else {
   b.textContent = '⏸ Pause Scanner';
   b.style.background = '';
   b.style.color = '';
   b.style.borderColor = '';
 }
}
// Mobile: toggle hamburger menu para mostrar/ocultar los botones reset
function toggleMobileMenu(){
 const h = document.getElementById('mainHeader');
 if (!h) return;
 if (h.classList.contains('menu-open')) {
   h.classList.remove('menu-open'); h.classList.add('menu-closed');
 } else {
   h.classList.remove('menu-closed'); h.classList.add('menu-open');
 }
}
// Auto-cerrar el menú móvil al tap fuera de él
document.addEventListener('click', e => {
 const h = document.getElementById('mainHeader');
 if (!h || !h.classList.contains('menu-open')) return;
 if (!h.contains(e.target)) {
   h.classList.remove('menu-open'); h.classList.add('menu-closed');
 }
});

async function toggleScanner(){
 const action = SCANNER_PAUSED ? 'resume' : 'pause';
 const b = document.getElementById('btn_scan');
 if (b) b.disabled = true;
 try {
   const r = await fetch('/api/scanner', {
     method: 'POST',
     headers: {'Content-Type': 'application/json'},
     body: JSON.stringify({action})
   });
   const j = await r.json();
   SCANNER_PAUSED = !!j.paused;
   updateScannerBtn();
 } catch (e) {
   alert('Scanner toggle failed: ' + e);
 } finally {
   if (b) b.disabled = false;
 }
}
// ── Estado de los escáneres W/E en la CABECERA (solo SuperAdmin) ────────────────────
// Reutiliza /api/scanner (el mismo que pinta Settings) para no añadir carga al backend.
// Verde = escaneando · ámbar = recuperándose/pausado · rojo = parado o sin objetos.
async function hdrScanRefresh(){
 const box = document.getElementById('hdr_scan_box');
 if (!box || box.style.display === 'none') return;
 try {
   const r = await fetch('/api/scanner', {cache:'no-store'});
   if (!r.ok) return;
   const j = await r.json();
   for (const h of ['W','E']){
     const el = document.getElementById('hdr_sc_'+h); if(!el) continue;
     const st = ((j.scanners || {})[h] || {});   // ⚠️ el estado va en j.scanners[h], no en j[h]
     const sw = String(st.sweep || '?');
     const age = (st.obj_age === null || st.obj_age === undefined) ? null : Number(st.obj_age);
     let col, bg, txt;
     if (j.paused)                                   { col='#fbbf24'; bg='#3a2f0a'; txt='pausado'; }
     else if (st.stalled)                            { col='#f87171'; bg='#3f1d1d'; txt='sin objetos'; }
     else if (/reinicio|hard|reconect|nuclear|warm/i.test(sw)) { col='#fbbf24'; bg='#3a2f0a'; txt='recuperándose'; }
     else if (/running|pasada/i.test(sw))            { col='#4ade80'; bg='#052e16'; txt='escaneando'; }
     else                                            { col='#f87171'; bg='#3f1d1d'; txt='parado'; }
     el.style.color = col; el.style.background = bg; el.style.borderColor = col;
     el.textContent = h;
     el.title = 'Scanner '+h+': '+txt+' · sweep='+sw
              + (age !== null ? ' · último objeto hace '+age+'s' : '');
   }
 } catch(e){ /* la cabecera no debe romper la página si falla el fetch */ }
}

// ── Incidencias del escáner (Settings): el POR QUÉ de cada parada ───────────────────
let INC_TIMER = null;
function incColor(k){
 const s = (k||'').toLowerCase();
 if (s.includes('recuperado') || s.includes('attach ok')) return '#4ade80';           // verde
 if (s.includes('reinicio') || s.includes('hard-reset')) return '#fbbf24';            // ámbar
 if (s.includes('blanco') || s.includes('imposible') || s.includes('error')) return '#f87171'; // rojo
 return '#93c5fd';                                                                    // azul (info)
}
function incAge(s){
 if (s < 60) return s + 's';
 if (s < 3600) return Math.floor(s/60) + 'm';
 return Math.floor(s/3600) + 'h' + Math.floor((s%3600)/60) + 'm';
}
async function loadIncidents(){
 try {
   const r = await fetch('/api/incidents?limit=80', {cache:'no-store'});
   if (!r.ok) { document.getElementById('inc_list').innerHTML =
     '<div style="padding:8px;color:#f87171;font-size:12px">No se pudo leer /api/incidents (HTTP '+r.status+')</div>'; return; }
   const j = await r.json();
   document.getElementById('inc_after').textContent = j.restart_after_s || 90;
   // resumen: si una mitad está fuera del mapa AHORA, cuánto lleva y cuándo se reinicia
   const parts = [];
   for (const h of ['W','E']){
     const st = (j.stuck||{})[h]|0, ep = (j.episodes||{})[h]|0;
     if (st > 0) parts.push('<b style="color:#fbbf24">'+h+'</b> fuera del mapa '+st+'s (reinicio limpio a los '+(j.restart_after_s||90)+'s'+(ep?', episodio '+ep:'')+')');
     else parts.push('<b style="color:#4ade80">'+h+'</b> ok');
   }
   document.getElementById('inc_summary').innerHTML = parts.join(' · ');
   if (!j.incidents || !j.incidents.length){
     document.getElementById('inc_list').innerHTML =
       '<div style="padding:8px;color:#64748b;font-size:12px">Sin incidencias registradas (arranque limpio).</div>';
     return;
   }
   document.getElementById('inc_list').innerHTML = j.incidents.map(i =>
     '<div style="display:flex;gap:8px;padding:5px 8px;border-bottom:1px solid #1a2130;font-size:11.5px;align-items:flex-start">'
     + '<span style="color:#64748b;min-width:42px;text-align:right">-'+incAge(i.age)+'</span>'
     + '<span style="min-width:18px;font-weight:700;color:'+(i.half==='W'?'#a78bfa':(i.half==='E'?'#38bdf8':'#64748b'))+'">'+i.half+'</span>'
     + '<span style="min-width:150px;font-weight:600;color:'+incColor(i.kind)+'">'+i.kind
     + (i.src==='log' ? ' <span style="font-weight:400;color:#475569;font-size:9px" title="reconstruido del log del backend">log</span>' : '')
     + '</span>'
     + '<span style="color:#cbd5e1;flex:1">'+(i.detail||'').replace(/</g,'&lt;')+'</span>'
     + '</div>').join('');
 } catch(e){
   document.getElementById('inc_list').innerHTML =
     '<div style="padding:8px;color:#f87171;font-size:12px">Error: '+e+'</div>';
 }
}
function toggleIncAuto(){
 const on = document.getElementById('inc_auto').checked;
 if (INC_TIMER){ clearInterval(INC_TIMER); INC_TIMER = null; }
 if (on) INC_TIMER = setInterval(loadIncidents, 10000);
}
async function restartScanners(){
 if(!confirm('Closes Evony on BOTH Pixel scanners and launches it again (fresh frida-server + reattach).\\n\\nTakes ~1-1.5 min, with no scanning meanwhile. The backend and this page stay up.\\n\\nContinue?')) return;
 const b = document.getElementById('btn_restart_scanners');
 if (b) b.disabled = true;
 try {
   const r = await fetch('/api/scanner', {
     method: 'POST', headers: {'Content-Type':'application/json'},
     body: JSON.stringify({action:'full_restart'})
   });
   const j = await r.json().catch(()=>({}));
   // NO tragarse el error: el bug de 2026-08-10 fue justo eso (catch vacío + alert de éxito fijo),
   // así que el botón parecía funcionar mientras el backend devolvía 500.
   if (!r.ok || j.error) { alert('✗ Restart FAILED: ' + (j.detail || j.error || ('HTTP ' + r.status))); return; }
   // Decir EXACTAMENTE qué mitades se reinician ahora y cuáles quedan en cola (tenían un
   // reset del watchdog en curso). El bug del 2026-08-10 fue prometer ambas y tocar solo W.
   const now_ = (j.restarting||[]), q_ = (j.queued||[]);
   alert('↻ Restarting ' + (now_.length ? now_.join('+') : '—')
       + (q_.length ? '\\n\\n⏳ In queue (a watchdog reset was already running): ' + q_.join('+')
                      + ' — it restarts as soon as that one finishes.' : '')
       + '\\n\\nEvony closes and relaunches on each Pixel and reattaches on its own in ~1-1.5 min. No need to reload this page.');
 } catch(e) {
   alert('✗ Restart FAILED: ' + e);
 } finally {
   if (b) b.disabled = false;
 }
}
// ── Scanner profiles (region + server per scanner; applied live, no recompile) ──
function scanProfDesc(p){
 if(!p) return '';
 const fmt=h=>{const c=p[h]||{};return (h==='W'?'6000':'6002')+':'+(c.region||'?')+'/'+((c.server|0)>0?c.server:'auto');};
 return fmt('W')+' · '+fmt('E');
}
async function loadScanProfile(){
 const sel=document.getElementById('scanprof_sel');
 if(!sel) return;
 try{
   const r=await fetch('/api/scanner_profile?_='+Date.now(),{cache:'no-store'}); if(!r.ok) return; const j=await r.json();
   sel.innerHTML='';
   Object.keys(j.profiles||{}).forEach(name=>{
     const o=document.createElement('option'); o.value=name;
     o.textContent=name+' ('+scanProfDesc(j.profiles[name])+')';
     if(name===j.active) o.selected=true;
     sel.appendChild(o);
   });
   const msg=document.getElementById('scanprof_msg');
   let txt='live W='+(j.live&&j.live.W?j.live.W.region+'/'+((j.live.W.server|0)||'auto'):'?')
          +' E='+(j.live&&j.live.E?j.live.E.region+'/'+((j.live.E.server|0)||'auto'):'?');
   if(j.revert_at>0 && j.now){ const left=Math.max(0,j.revert_at-j.now); txt+=' · ⏰→'+(j.revert_to||'home')+' in '+fmtAge(left); }
   if(msg) msg.textContent=txt;
 }catch(e){}
}
async function applyScanProfile(){
 const sel=document.getElementById('scanprof_sel'); const msg=document.getElementById('scanprof_msg');
 if(!sel) return; const name=sel.value;
 if(name==='svs_split' && !confirm('Apply "svs_split"?\\n\\n• 6000 → FULL scan of OUR server (1939)\\n• 6002 → FULL scan of the ENEMY server (1954)\\n\\nApplied live (no restart). Switch back with "home".')) return;
 if(msg){ msg.style.color='#94a3b8'; msg.textContent='applying…'; }
 try{
   const r=await fetch('/api/scanner_profile',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({name})});
   const j=await r.json();
   if(r.ok&&j.ok){ if(msg){msg.style.color='#34d399'; msg.textContent='✓ '+j.active;} setTimeout(loadScanProfile,800); }
   else { if(msg){msg.style.color='#f87171'; msg.textContent=(j.error||('HTTP '+r.status));} }
 }catch(e){ if(msg){msg.style.color='#f87171'; msg.textContent='error';} }
}
// ── Send Coords: selector de monstruos por NOMBRE+NIVEL, persistente por usuario ──
const SC_SEL = new Map();   // "namelevel" -> {name, level, label}
let SC_CAT = [];            // [{name, level, count, label}] de /api/sc_monsters (niveles reales)
// default para usuario nuevo: name+level
const SC_DEFAULT = [["Junior Cerberus",1],["Junior Knight Bayard",1],["Junior Hydra",1],
                    ["Warlord",1],["Warlord",2],["Ymir",1],["Ymir",2],
                    ["Normal Barbary Pirate",1],["Elite Barbary Pirate",2]];
function scK(name,level){ return name+''+level; }
function scLabel(name,level){ return level ? (name+' Lv'+level) : name; }
function scKey(){ return 'sc_picks_'+(CURRENT_USER||'_'); }
function scSaveSel(){
 try{ localStorage.setItem(scKey(), JSON.stringify([...SC_SEL.values()].map(v=>({name:v.name,level:v.level})))); }catch(e){}
}
function scLoadSel(){
 let picks=null;
 try{ const raw=localStorage.getItem(scKey()); if(raw) picks=JSON.parse(raw); }catch(e){}
 if(!Array.isArray(picks)) picks=SC_DEFAULT.map(([n,l])=>({name:n,level:l}));  // default
 SC_SEL.clear();
 for(const p of picks){ const nm=p.name, lv=(p.level|0); if(nm) SC_SEL.set(scK(nm,lv),{name:nm,level:lv,label:scLabel(nm,lv)}); }
 scRenderChips();
}
async function scLoadCat(){
 try{ const r=await fetch('/api/sc_monsters?_='+Date.now(),{cache:'no-store'}); SC_CAT=await r.json(); }catch(e){ SC_CAT=[]; }
}
function esc2(s){ return String(s==null?'':s).replace(/[&<>"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c])); }
function scRenderChips(){
 const box=document.getElementById('msbox_sc'), inp=document.getElementById('msin_sc');
 if(!box) return;
 [...box.querySelectorAll('.chip')].forEach(c=>c.remove());
 for(const [k,v] of SC_SEL){
  const c=document.createElement('span'); c.className='chip';
  c.innerHTML=esc2(v.label)+' <b>&times;</b>';
  c.querySelector('b').onclick=()=>{ SC_SEL.delete(k); scRenderChips(); scSaveSel(); };
  box.insertBefore(c, inp);
 }
}
function scRenderDD(){
 const inp=document.getElementById('msin_sc'), dd=document.getElementById('dd_sc');
 if(!dd) return;
 const q=(inp.value||'').trim().toLowerCase();
 const list=(SC_CAT||[]).filter(o=>!SC_SEL.has(scK(o.name,o.level)) && (!q||o.name.toLowerCase().includes(q))).slice(0,80);
 dd.innerHTML='';
 if(!list.length){ dd.classList.remove('open'); return; }
 for(const o of list){
  const d=document.createElement('div'); d.className='opt';
  d.innerHTML=`${esc2(o.name)} <small>Lv${o.level} · ${o.count} on map</small>`;
  d.onclick=()=>{ SC_SEL.set(scK(o.name,o.level),{name:o.name,level:o.level,label:scLabel(o.name,o.level)}); inp.value=''; scRenderChips(); scRenderDD(); scSaveSel(); inp.focus(); };
  dd.appendChild(d);
 }
 dd.classList.add('open');
}
(function(){
 const inp=document.getElementById('msin_sc'), box=document.getElementById('msbox_sc');
 if(inp){ inp.addEventListener('focus',()=>{ scLoadCat().then(scRenderDD); }); inp.addEventListener('input',scRenderDD); }
 if(box){ box.addEventListener('click',()=>inp.focus()); }
 document.addEventListener('click',e=>{ const w=document.getElementById('ms_sc'); if(w && !w.contains(e.target)){ const dd=document.getElementById('dd_sc'); if(dd) dd.classList.remove('open'); } });
})();
// Carga los miembros de [LAN] en el selector de destinatario (solo superadmin).
async function loadAllianceMembers(){
 const sel=document.getElementById('sc_recipient'); if(!sel) return;
 try{
   const r=await fetch('/api/alliance_members?_='+Date.now(),{cache:'no-store'}); if(!r.ok) return;
   const j=await r.json();
   const prev=sel.value;
   const tag=j.tag||'LAN';
   sel.innerHTML='<option value="">📨 Me ('+tag+')</option>';
   (j.members||[]).forEach(m=>{
     const o=document.createElement('option'); o.value=m.uid;
     o.textContent=m.name+(m.power>0?(' ('+Math.round(m.power/1e6)+'M)'):'');
     sel.appendChild(o);
   });
   if(prev) sel.value=prev;   // conserva la selección al refrescar
   sel.style.display='';
 }catch(e){}
}
async function sendCoords(){
 const picks=[...SC_SEL.values()].map(v=>({name:v.name, level:v.level}));
 const msg=document.getElementById('sc_msg');
 if(!picks.length){ if(msg){ msg.style.color='#f87171'; msg.textContent='Pick at least one monster'; } return; }
 const rsel=document.getElementById('sc_recipient');
 const recipient_uid=(rsel && rsel.value)?(parseInt(rsel.value)||0):0;   // solo superadmin; 0 = a mí mismo
 const btn=document.getElementById('btn_sc_send'); const orig=btn?btn.textContent:'';
 if(btn){ btn.disabled=true; btn.textContent='⏳ Sending...'; }
 if(msg){ msg.textContent=''; }
 try {
   const r = await fetch('/api/send_coords', {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({picks, recipient_uid})});
   const j = await r.json();
   if(r.ok && j.ok){ if(msg){ msg.style.color='#34d399'; msg.textContent='✓ Sent '+j.count+(j.recipient?(' → '+j.recipient):'')+' (whisper)'; } }
   else { if(msg){ msg.style.color='#f87171'; msg.textContent=(j.detail||j.error||('HTTP '+r.status)); } }
 } catch(e) { if(msg){ msg.style.color='#f87171'; msg.textContent='Error: '+e; } }
 finally {
   if(btn){ btn.disabled=false; btn.textContent=orig; }
   if(msg){ setTimeout(()=>{ if(msg && msg.style.color==='rgb(52, 211, 153)') msg.textContent=''; }, 6000); }
 }
}
async function restartHalf(half){
 const label = half || 'AMBOS';
 if (!confirm(`Force reattach for ${label} agent? Watchdog will detect in ~5s and reconnect.`)) return;
 try {
   const r = await fetch('/api/scanner', {
     method: 'POST', headers: {'Content-Type':'application/json'},
     body: JSON.stringify({action:'restart', half})
   });
   const j = await r.json();
   alert(`Restart enviado: ${(j.restarting||[]).join('+')}. El agent reattach en ~5s.`);
 } catch(e) {
   alert('Restart failed: ' + e);
 }
}
async function hardReset(half){
 const label = half || 'AMBOS';
 // Ya NO mata frida-server (2026-08-10: hacerlo triplicaba los fallos de inyección porque
 // dejaba el agente huérfano dentro del juego). Reinicia el JUEGO, que es lo que cura.
 if (!confirm(`HARD RESET ${label}? This will:\\n- force-stop Evony and relaunch it\\n- wait for the screen to render, then reattach\\n(frida-server is NOT touched)\\n\\nTakes ~1.5 min. Use it if a half stops producing objects.`)) return;
 try {
   const r = await fetch('/api/scanner', {
     method: 'POST', headers: {'Content-Type':'application/json'},
     body: JSON.stringify({action:'hard_reset', half})
   });
   const j = await r.json();
   alert(`Hard reset enviado: ${(j.hard_resetting||[]).join('+')}. Espera ~40s.`);
 } catch(e) {
   alert('Hard reset failed: ' + e);
 }
}
async function nuclearReset(half){
 const label = half || 'AMBOS';
 if (!confirm(`☠ NUCLEAR RESET ${label}?\\n\\nThis will KILL the qemu process and relaunch the entire emulator.\\n\\nTakes ~3-4 minutes. LAST RESORT when emulator is totally frozen.\\n\\nContinue?`)) return;
 try {
   const r = await fetch('/api/scanner', {
     method: 'POST', headers: {'Content-Type':'application/json'},
     body: JSON.stringify({action:'nuclear_reset', half})
   });
   const j = await r.json();
   alert(`☠ Nuclear reset fired on: ${(j.nuclear_resetting||[]).join('+')}\\n\\nWait ~3-4 minutes. The emulator will fully restart.`);
 } catch(e) {
   alert('Nuclear reset failed: ' + e);
 }
}
// Si CUALQUIER llamada responde 401 (sesion invalidada: p.ej. el mismo usuario
// inicio sesion en otro dispositivo, o expiro), volvemos al login al instante.
(function(){
 const _f = window.fetch;
 window.fetch = async function(...args){
   const resp = await _f.apply(this, args);
   try{
     if(resp && resp.status===401){
       let url = args[0]; if(url && url.url) url = url.url;
       if(typeof url==='string' && url.indexOf('/api/login')<0 && url.indexOf('/api/logout')<0){
         location.href='/login';
       }
     }
   }catch(e){}
   return resp;
 };
})();
refreshScannerBtn();

// ── ⚙ Settings modal (status + profile + notifications) ──────────────────────
function openSettings(){
 const m=document.getElementById('settingsModal'); if(!m) return;
 m.style.display='flex';
 loadNotifPrefs();   // recarga las prefs del usuario desde el servidor + pinta los toggles
 refreshScannerStatus();
 try{ loadIncidents(); toggleIncAuto(); }catch(e){}   // por qué se paró cada mitad
 try{ loadFocus(); }catch(e){}   // Focus zone (global) movido a Scanner Settings
 if(!openSettings._t) openSettings._t=setInterval(()=>{ const mm=document.getElementById('settingsModal'); if(mm && mm.style.display==='flex') refreshScannerStatus(); }, 4000);
}
function closeSettings(){ const m=document.getElementById('settingsModal'); if(m) m.style.display='none';
 if(INC_TIMER){ clearInterval(INC_TIMER); INC_TIMER=null; } }   // no seguir sondeando con el panel cerrado
document.addEventListener('keydown', e=>{ if(e.key==='Escape'){ const m=document.getElementById('settingsModal'); if(m && m.style.display==='flex') closeSettings(); } });
// Render del estado de cada escáner (W/E) en el panel
async function refreshScannerStatus(){
 const box=document.getElementById('set_scanstatus'); if(!box) return;
 try{
   const r=await fetch('/api/scanner',{cache:'no-store'}); const j=await r.json();
   SCANNER_PAUSED=!!j.paused; updateScannerBtn();
   const srv=j.server_id||0; const sl=document.getElementById('set_serverline');
   if(sl){
     const mism=(j.server_id_W&&j.server_id_E&&j.server_id_W!==j.server_id_E);
     sl.innerHTML = srv>0 ? ('Server <b style="color:#fbbf24">#'+srv+'</b>'+(mism?(' <span style="color:#ef4444">(W#'+j.server_id_W+' E#'+j.server_id_E+' mismatch)</span>'):'')) : 'Server <span style="color:#94a3b8">detecting…</span>';
   }
   const sc=j.scanners||{};
   box.innerHTML = scStatusRow('W','6000',sc.W,j.self_W,j.paused)+scStatusRow('E','6002',sc.E,j.self_E,j.paused);
   // Objects on map (contador global) desde /api/scan_stats (endpoint ya existente). try/catch
   // propio: si scan_stats falla, no tumba el resto del panel (filas W/E ya pintadas arriba).
   const stl=document.getElementById('set_statline');
   if(stl){
     try{
       const rs=await fetch('/api/scan_stats',{cache:'no-store'}); const js=await rs.json();
       const objs=(js.combined||{}).pool_objs;
       stl.innerHTML = '<span class=dim>Objects on map:</span> <b>'+((objs!=null)?objs.toLocaleString('en-US'):'—')+'</b>';
     }catch(e){ stl.innerHTML=''; }
   }
 }catch(e){ box.innerHTML='<div class=set-sub>status unavailable</div>'; }
}
function scStatusRow(half, port, s, self, paused){
 s=s||{}; const sweep=(s.sweep||'?'); const hb=s.hb_age;
 const dead=(hb==null)||(hb>120);
 let cls='st-unk', label=sweep, dot='#9ca3af';
 if(paused){ cls='st-pause'; label='paused'; dot='#fbbf24'; }
 else if(dead){ cls='st-off'; label='offline'; dot='#ef4444'; }
 else if(sweep==='frozen'){ cls='st-recon'; label='frozen — reattaching'; dot='#f97316'; }
 else if(s.stalled && (sweep==='running'||/^pasada/.test(sweep))){ cls='st-stall'; label='stalled '+Math.round(s.obj_age||0)+'s'; dot='#f59e0b'; }
 else if(sweep==='running'||/^pasada/.test(sweep)){ cls='st-run'; label='running'; dot='#22c55e'; }
 else if(sweep==='warmup'){ cls='st-warm'; label='warming up'; dot='#3b82f6'; }
 else if(sweep==='reattach'||sweep==='reconectando'||sweep==='esperando-adb'||sweep==='hard-reset'||sweep==='nuclear-reset'){ cls='st-recon'; label='reconnecting'; dot='#f97316'; }
 else if(sweep==='done'){ cls='st-run'; label='done'; dot='#22c55e'; }
 const lap = (s.num||0);
 const head = '<div class=scst><span class=dot style="color:'+dot+';background:'+dot+'"></span>'
   +'<span class=who>'+half+' · '+port+'</span>'
   +'<span class="badge '+cls+'">'+label+'</span>'
   +'<span class=dim style="margin-left:auto;font-size:11px">lap '+(lap+1)+'</span></div>';
 const fresh = (s.obj_age!=null) ? (Math.round(s.obj_age)+'s') : '—';
 const hbs   = (hb!=null)        ? (Math.round(hb)+'s') : '—';
 const meta = '<div class=set-sub style="margin:2px 0 8px 16px;font-size:11px">freshness '+fresh+' · hb '+hbs+'</div>';
 return head + meta;
}
// ── Notifications: prefs POR USUARIO (server-side /api/notif_prefs). Cada usuario decide las suyas. ──
let NOTIF_PREFS = {master:true, rally:true, solo:true, bubbles:true, spawns:true, enemy:true, ares:true, sound:true, browser:false, send_coords:true};
async function loadNotifPrefs(){
 try{ const r=await fetch('/api/notif_prefs',{cache:'no-store'}); if(r.ok){ const j=await r.json(); if(j&&typeof j==='object') NOTIF_PREFS=Object.assign(NOTIF_PREFS, j); } }catch(e){}
 notifSyncUI(); try{ svsSyncAlertUI(); }catch(e){}
 applySendCoordsVisibility();
}
function persistNotif(upd){ try{ fetch('/api/notif_prefs',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({prefs:upd})}); }catch(e){} }
function notifOn(type){
 if(!NOTIF_PREFS.master) return false;
 return NOTIF_PREFS[type]!==false;
}
function notifSet(type, on){
 on=!!on; NOTIF_PREFS[type]=on; persistNotif({[type]:on});
 if(type==='enemy'){ const c=document.getElementById('svs_alerts_on'); if(c) c.checked=on; }
 if(type==='master') notifSyncUI();
}
function _syncBrowserToggles(){
 const v=!!NOTIF_PREFS.browser, granted=!!window.Notification && Notification.permission==='granted';
 const a=document.getElementById('ntf_browser'); if(a) a.checked=v;
 const b=document.getElementById('svs_notif_toggle'); if(b) b.checked=v && granted;
}
function notifSetBrowser(cb){
 if(cb.checked){
   if(!window.Notification){ alert('This browser does not support notifications.'); cb.checked=false; return; }
   if(Notification.permission==='granted'){ NOTIF_PREFS.browser=true; persistNotif({browser:true}); _syncBrowserToggles(); }
   else if(Notification.permission==='denied'){ alert('Notifications are blocked in your browser settings. Enable them there first.'); cb.checked=false; _syncBrowserToggles(); return; }
   else { Notification.requestPermission().then(p=>{ if(p==='granted'){ NOTIF_PREFS.browser=true; persistNotif({browser:true}); new Notification('Evony Scout',{body:'Notifications enabled'}); } else { NOTIF_PREFS.browser=false; persistNotif({browser:false}); } _syncBrowserToggles(); }); return; }
 } else { NOTIF_PREFS.browser=false; persistNotif({browser:false}); _syncBrowserToggles(); }
}
function notifSyncUI(){
 const set=(id,on)=>{ const c=document.getElementById(id); if(c) c.checked=on; };
 const master = NOTIF_PREFS.master!==false;
 set('ntf_master', master);
 set('ntf_rally', NOTIF_PREFS.rally!==false);
 set('ntf_solo',  NOTIF_PREFS.solo!==false);
 set('ntf_bubbles', NOTIF_PREFS.bubbles!==false);
 set('ntf_spawns', NOTIF_PREFS.spawns!==false);
 set('ntf_enemy', NOTIF_PREFS.enemy!==false);
 set('ntf_ares',  NOTIF_PREFS.ares!==false);
 set('ntf_sound', NOTIF_PREFS.sound!==false);
 set('ntf_browser', !!NOTIF_PREFS.browser);
 set('set_sc_vis', NOTIF_PREFS.send_coords===true);
 const g=document.getElementById('ntf_group'); if(g){ g.style.opacity=master?'':'0.4'; g.style.pointerEvents=master?'':'none'; }
}
// Visibilidad del panel 📍 SEND COORDS (preferencia POR USUARIO; OFF por defecto).
function applySendCoordsVisibility(){
 const el=document.getElementById('send_coords_section');
 if(el) el.style.display = (NOTIF_PREFS.send_coords===true) ? '' : 'none';
}
function scVisSet(on){ notifSet('send_coords', !!on); applySendCoordsVisibility(); }

// ---- Auth: rol + logout ----
let CURRENT_ROLE = '';
let CURRENT_USER = '';
async function applyRole(){
 try{
   const r = await fetch('/api/me', {cache:'no-store'});
   if(!r.ok){ location.href='/login'; return; }
   const j = await r.json();
   CURRENT_ROLE = j.role || '';
   CURRENT_USER = j.user || '';
   scLoadSel();   // cargar selección de Send Coords persistida de este usuario
   loadPillsState();          // restaurar pills (FAM/SEL) marcados de este usuario
   renderChips(); loadFamilies(); load();   // re-render con la selección restaurada (abre acordeones)
   loadNotifPrefs();   // prefs de notificaciones DE ESTE usuario (server-side)
   const roleLabel = (j.role==='superadmin'?'SuperAdmin':'Admin');
   const ub = document.getElementById('user_box');
   const ul = document.getElementById('user_label');
   if(ub && ul){ ul.textContent = j.user + ' · ' + roleLabel; ub.style.display='inline-flex'; }
   // etiqueta de usuario dentro del burger (móvil)
   const mu = document.getElementById('mobile_user');
   if(mu) mu.textContent = j.user + ' · ' + roleLabel;
   // Admin: ocultar Pause Scanner (solo superadmin). Restart Scanners SÍ visible
   // para admin + superadmin (gate del backend permite full_restart a admin).
   loadAllianceMembers();   // selector de destinatario de Send Coords (TODOS los admins)
   if(CURRENT_ROLE !== 'superadmin'){
     const ps = document.getElementById('btn_scan');
     if(ps) ps.style.display='none';
     // btn_restart_scanners se queda visible para admin
   } else {
     // SuperAdmin: mostrar el botón de sesiones activas + selector de perfil de escáner.
     const bs = document.getElementById('btn_sessions');
     if(bs) bs.style.display='';
     const sp = document.getElementById('set_profile_sec');   // sección Profile dentro de ⚙ Settings
     if(sp){ sp.style.display=''; loadScanProfile(); }
     const ish = document.getElementById('set_incidents_sec');   // 🩺 Incidencias: SOLO superadmin
     if(ish) ish.style.display='';
     const hsb = document.getElementById('hdr_scan_box');        // estado W/E + ↻ en cabecera: SOLO superadmin
     if(hsb){ hsb.style.display='inline-flex'; hdrScanRefresh(); if(!hdrScanRefresh._t) hdrScanRefresh._t=setInterval(hdrScanRefresh, 15000); }
     const cs = document.getElementById('wl_sub_cheat_btn');   // subtab Cheat scan: solo superadmin
     if(cs) cs.style.display='';
     const rs2 = document.getElementById('wl_sub_steal_btn');  // subtab Rally steals: solo superadmin
     if(rs2) rs2.style.display='';
   }
 }catch(e){ /* sin red: dejar la UI como esta */ }
}
async function doLogout(){
 try{ await fetch('/api/logout', {method:'POST'}); }catch(e){}
 location.href='/login';
}
applyRole();

// ---- Panel de sesiones activas (solo SuperAdmin) ----
let SESSIONS_OPEN=false, SESSIONS_TIMER=null;
function _fmtDur(s){
 if(s==null) return '—';
 s=Math.max(0,s|0);
 const d=Math.floor(s/86400), h=Math.floor(s%86400/3600), m=Math.floor(s%3600/60), ss=s%60;
 if(d>0) return d+'d '+h+'h';
 if(h>0) return h+'h '+m+'m';
 if(m>0) return m+'m '+ss+'s';
 return ss+'s';
}
function _fmtWhen(ts){
 if(!ts) return '—';
 try{ return new Date(ts*1000).toLocaleString(); }catch(e){ return '—'; }
}
function _esc(t){ const d=document.createElement('div'); d.textContent=(t==null?'':String(t)); return d.innerHTML; }
async function refreshSessions(){
 const body=document.getElementById('sessions_body');
 try{
   const r=await fetch('/api/sessions',{cache:'no-store'});
   if(!r.ok){ body.innerHTML='<span style="color:#f87171">No autorizado o error ('+r.status+')</span>'; return; }
   const j=await r.json();
   const ss=j.sessions||[];
   const online=ss.filter(x=>x.online).length;
   const withSess=ss.filter(x=>x.has_session).length;
   if(!ss.length){ body.innerHTML='<span>No hay usuarios.</span>'; return; }
   let h='<div style="margin-bottom:8px;color:#94a3b8">'+online+' conectado(s) ahora · '+withSess+' con sesión abierta · '+ss.length+' usuario(s) <span style="color:#64748b">(online = actividad &lt; '+j.online_window_s+'s)</span></div>';
   h+='<table style="width:100%;border-collapse:collapse;font-size:12.5px">';
   h+='<thead><tr style="color:#94a3b8;text-align:left">'
     +'<th style="padding:4px 6px">Usuario</th><th style="padding:4px 6px">Estado</th>'
     +'<th style="padding:4px 6px">Último acceso</th><th style="padding:4px 6px">Sesión</th>'
     +'<th style="padding:4px 6px">Inactivo</th><th style="padding:4px 6px">IP</th>'
     +'<th style="padding:4px 6px"></th></tr></thead><tbody>';
   for(const x of ss){
     let dot;
     if(x.online) dot='<span style="color:#34d399">●</span> online';
     else if(x.has_session) dot='<span style="color:#fbbf24">●</span> sesión';
     else dot='<span style="color:#64748b">●</span> offline';
     const role = x.role==='superadmin' ? ' <span style="color:#fbbf24">★</span>' : '';
     const ipShow = x.ip || x.last_ip || '';
     // Último acceso = actividad más reciente (online -> ahora; si no, su última señal).
     const accessTs = x.last_activity || x.last_login || x.login_at;
     const kickBtn = x.has_session
       ? '<button class=sec data-user="'+_esc(x.user)+'" onclick="doKick(this)" title="Cerrar la sesión de este usuario" style="padding:2px 8px;background:#7f1d1d;border-color:#b91c1c;color:#fff">Expulsar</button>'
       : '';
     h+='<tr style="border-top:1px solid #1e293b">'
       +'<td style="padding:5px 6px"><b>'+_esc(x.user)+'</b>'+role+'</td>'
       +'<td style="padding:5px 6px">'+dot+'</td>'
       +'<td style="padding:5px 6px">'+_esc(_fmtWhen(accessTs))+'</td>'
       +'<td style="padding:5px 6px">'+(x.has_session?_fmtDur(x.session_age_s)+(((x.sessions_count||1)>1)?' <span style="color:#fbbf24">· '+x.sessions_count+' disp.</span>':''):'—')+'</td>'
       +'<td style="padding:5px 6px">'+(x.online?'<span style="color:#34d399">activo</span>':(x.has_session?_fmtDur(x.idle_s):'—'))+'</td>'
       +'<td style="padding:5px 6px;color:#94a3b8">'+_esc(ipShow||'—')+'</td>'
       +'<td style="padding:5px 6px">'+kickBtn+'</td>'
       +'</tr>';
   }
   h+='</tbody></table>';
   body.innerHTML=h;
 }catch(e){ body.innerHTML='<span style="color:#f87171">Error de red</span>'; }
}
async function doKick(arg){
 const uname = (typeof arg==='string') ? arg : (arg && arg.getAttribute('data-user')) || '';
 if(!uname) return;
 if(!confirm('¿Cerrar la sesión de '+uname+'? Tendrá que volver a iniciar sesión.')) return;
 try{
   const r=await fetch('/api/kick',{method:'POST',headers:{'Content-Type':'application/json'},
     body:JSON.stringify({user:uname})});
   if(!r.ok){ alert('No se pudo expulsar ('+r.status+')'); return; }
 }catch(e){ alert('Error de red al expulsar'); return; }
 refreshSessions();
}
function toggleSessionsPanel(ev){
 if(ev) ev.stopPropagation();
 SESSIONS_OPEN=!SESSIONS_OPEN;
 document.getElementById('sessions_panel').style.display = SESSIONS_OPEN ? '' : 'none';
 if(SESSIONS_OPEN){ refreshSessions(); SESSIONS_TIMER=setInterval(refreshSessions,5000); }
 else if(SESSIONS_TIMER){ clearInterval(SESSIONS_TIMER); SESSIONS_TIMER=null; }
}

// ---- Shield ETA indicator + panel ----
let SHIELD_LAST_TOTAL = 0;
let SHIELD_PANEL_OPEN = false;
function toggleShieldPanel(ev){
 if(ev) ev.stopPropagation();   // evita que el click-fuera handler lo cierre inmediatamente
 SHIELD_PANEL_OPEN = !SHIELD_PANEL_OPEN;
 document.getElementById('shield_panel').style.display = SHIELD_PANEL_OPEN ? '' : 'none';
 if (SHIELD_PANEL_OPEN) refreshShields();
}
// Desde el popup ETAs → ir a la pestaña Players y filtrar por ese uid
function gotoPlayerFromShield(uid){
 // 1. cerrar el panel de ETAs
 SHIELD_PANEL_OPEN = false;
 const panel = document.getElementById('shield_panel');
 if (panel) panel.style.display = 'none';
 // 2. cambiar a la pestaña Players
 switchTab('pl');
 // 3. poner el uid en el buscador y disparar la búsqueda
 const inp = document.getElementById('p_search');
 if (inp) { inp.value = String(uid); }
 loadPlayers();
 // 4. tras cargar, resaltar la fila del jugador brevemente
 setTimeout(() => {
   try {
     const rows = document.querySelectorAll('#view_players tbody tr');
     for (const tr of rows) {
       if (tr.textContent.includes(String(uid))) {
         tr.scrollIntoView({behavior:'smooth', block:'center'});
         tr.style.transition = 'background .3s';
         const orig = tr.style.background;
         tr.style.background = 'rgba(96,165,250,.35)';
         setTimeout(() => { tr.style.background = orig; }, 1800);
         break;
       }
     }
   } catch(e){}
 }, 500);
}
// click fuera del panel ETAs lo cierra
document.addEventListener('click', (ev) => {
 if(!SHIELD_PANEL_OPEN) return;
 const panel = document.getElementById('shield_panel');
 const indicator = document.getElementById('shield_indicator');
 if(!panel || !indicator) return;
 if(panel.contains(ev.target) || indicator.contains(ev.target)) return;   // click dentro = ignora
 SHIELD_PANEL_OPEN = false;
 panel.style.display = 'none';
});
function fmtLeft(s){
 if (s>=86400) return Math.floor(s/86400)+'d '+Math.floor((s%86400)/3600)+'h';
 if (s>=3600)  return Math.floor(s/3600)+'h '+Math.floor((s%3600)/60)+'m';
 if (s>=60)    return Math.floor(s/60)+'m '+(s%60)+'s';
 return s+'s';
}
function playBeep(){
 if(!notifOn('sound')) return;   // toggle de sonido (Settings → Notifications)
 // sonido procedural corto (no necesita asset). Si el usuario aun no ha interactuado
 // con la pagina, AudioContext puede estar bloqueado por autoplay policy: lo dejamos pasar.
 try {
   const ac = playBeep._ac || (playBeep._ac = new (window.AudioContext||window.webkitAudioContext)());
   const o = ac.createOscillator(), g = ac.createGain();
   o.type='sine'; o.frequency.value=880;
   g.gain.setValueAtTime(0.001, ac.currentTime);
   g.gain.exponentialRampToValueAtTime(0.18, ac.currentTime+0.02);
   g.gain.exponentialRampToValueAtTime(0.001, ac.currentTime+0.35);
   o.connect(g); g.connect(ac.destination);
   o.start(); o.stop(ac.currentTime+0.4);
 } catch {}
}
// Toasts de rally/burbuja: al hacer click se copian las coords del objetivo (X,Y).
function toastCopyCoords(ev, x, y){
 ev.stopPropagation();
 const div = ev.currentTarget;
 const xy = x + ',' + y;
 navigator.clipboard.writeText(xy).then(()=>{
   let f = div.querySelector('.copied');
   if(!f){ f = document.createElement('div'); f.className='copied'; div.appendChild(f); }
   f.textContent = '✓ copied ' + xy;
 }).catch(()=>{});
}
function showShieldAlert(a){
 const div = document.createElement('div');
 div.className = 'toast expiring';
 const txt = fmtLeft(a.left);
 const who = (a.tag?`[${csEsc(a.tag)}] `:'') + csEsc(a.name||'?');   // XSS: nombre/tag los controla el jugador enemigo -> escapar SIEMPRE (toast auto-disparado)
 const hasXY = (a.wx||a.wy);
 if(hasXY){ div.style.cursor='pointer'; div.title='Click to copy coordinates'; div.onclick=(e)=>toastCopyCoords(e,a.wx,a.wy); }
 div.innerHTML = `<button class=close onclick="event.stopPropagation();this.parentElement.remove()">×</button>
   <div class=title>🛡 Bubble expiring soon</div>
   <div><b>${who}</b> · uid=${a.uid}</div>
   <div class=meta>${txt} left · source: ${csEsc(a.src)}${hasXY?` · (${a.wx},${a.wy})`:''}</div>`;
 document.getElementById('toasts').appendChild(div);
 // auto-dismiss tras 4s
 setTimeout(()=>div.remove(), 4000);
 playBeep();
}
async function refreshShields(){
 try {
   const r = await fetch('/api/shields', {cache:'no-store'});
   const j = await r.json();
   const ind = document.getElementById('shield_indicator');
   const cnt = document.getElementById('sh_count');
   cnt.textContent = j.total;
   ind.classList.toggle('has', j.total > 0);
   // flash si subio (capturamos algo nuevo)
   if (j.total > SHIELD_LAST_TOTAL && SHIELD_LAST_TOTAL > 0) {
     ind.classList.remove('flash');
     void ind.offsetWidth;   // reflow para reiniciar animacion
     ind.classList.add('flash');
   }
   SHIELD_LAST_TOTAL = j.total;
   // toasts: backend devuelve solo alerts NUEVOS (no repite), un toast por cada uno
   if (Array.isArray(j.alerts) && j.alerts.length > 0 && notifOn('bubbles')) {
     for (const a of j.alerts) showShieldAlert(a);
   }
   // pintar panel si esta abierto
   if (SHIELD_PANEL_OPEN) {
     const ph = document.getElementById('sh_phead');
     ph.textContent = `(${j.active_uid} matched · ${j.pending_coord} pending uid match)`;
     const list = document.getElementById('sh_list');
     const al = j.active_list || [];
     if (al.length === 0) {
       list.innerHTML = '<div class=shempty>No active shield ETAs.<br>Bubble ETAs appear here when:<br>• A player activates shield (tier 0→1) — inferred ETA<br>• An exact ETA arrives via mail/scout/sub-city</div>';
     } else {
       // Confidence badge color helper
       const confBadge = (c) => {
         if (c === 'exact') return '<span class=sh title="ground truth">exact</span>';
         if (c === 'inferred_sku') return '<span class=sh_inf title="player\\'s usual shield SKU">inferred-sku</span>';
         if (c === 'inferred_history') return '<span class=sh_inf title="median of past durations">inferred-h</span>';
         if (c === 'inferred_default') return '<span class=sh_inf title="default 8h assumed">inferred-d</span>';
         if (c === 'inferred_undershot') return '<span class=sh_under title="undershot extension">undershot</span>';
         if (c === 'newbie_inferred') return '<span class=sh_newb title="newbie max bound">newbie</span>';
         if (c === 'pending_coord_match') return '<span class=sh_unk title="captured by coord, waiting for uid match">pending</span>';
         return `<span class=stat>${c||'?'}</span>`;
       };
       list.innerHTML = al.map(x => {
         const who = (x.tag?`[${x.tag}] `:'') + (x.name||'?');
         const uidStr = x.uid ? `uid=${x.uid}` : 'no uid';
         const coords = (x.wx||x.wy) ? `@(${x.wx},${x.wy})` : '';
         const castle = x.castle ? `C${x.castle}` : '';
         // clickable solo si tenemos uid (para poder buscarlo en Players)
         const clickAttr = x.uid ? `onclick="gotoPlayerFromShield(${x.uid})" style="cursor:pointer"
             title="Click → ver en pestaña Players"` : '';
         const arrow = x.uid ? '<span style="float:right;color:#60a5fa;font-size:11px;margin-right:6px">→ Players</span>' : '';
         return `<div class=shitem ${clickAttr}>
           <span class=left>${fmtLeft(x.left)}</span>
           <span class=nm>${who}</span>
           ${confBadge(x.confidence)}${arrow}
           <div class=meta>${coords} ${castle} · ${uidStr} · src=${x.src}</div>
         </div>`;
       }).join('');
     }
   }
 } catch (e) {}
}
refreshShields();
setInterval(refreshShields, 3000);

// ── Alertas de RALLY: toast + beep cuando se convoca un nuevo rally (mty 19/20/21) ──
// Detecta rallies nuevos por tile-objetivo contra /api/attacks (robusto ante filtros/paginacion
// de la vista principal). Baseline en la 1a carga para no spamear con los ya activos.
function showRallyAlert(a){
 const div = document.createElement('div');
 div.className = 'toast';
 div.style.cursor='pointer'; div.title='Click to copy coordinates';
 div.onclick=(e)=>toastCopyCoords(e,a.tx,a.ty);
 const who = (a.tag?`[${csEsc(a.tag)}] `:'') + csEsc(a.name||'?');   // XSS: nombre/tag los controla el jugador enemigo -> escapar SIEMPRE (toast auto-disparado)
 const tgt = a.tname ? csEsc(a.tname) : `(${a.tx},${a.ty})`;   // XSS: tname puede ser nombre de jugador -> escapar
 div.innerHTML = `<button class=close onclick="event.stopPropagation();this.parentElement.remove()">×</button>
   <div class=title style="font-size:16px;letter-spacing:.6px;color:#fdba74;text-transform:uppercase;text-shadow:0 1px 3px rgba(0,0,0,.5)">⚔️ Rally called</div>
   <div><b>${who}</b> → ${tgt}</div>
   <div class=meta>(${a.tx},${a.ty}) · ${csEsc(a.phase||'rally')} · ${a.count||1} marches · ETA ${fmtLeft(a.eta)}</div>`;
 document.getElementById('toasts').appendChild(div);
 setTimeout(()=>div.remove(), 4000);   // auto-dismiss 4s
 playBeep();
}
function showSoloAlert(a){
 const div = document.createElement('div');
 div.className = 'toast toast-solo';
 div.style.cursor='pointer'; div.title='Click to copy coordinates';
 div.onclick=(e)=>toastCopyCoords(e,a.tx,a.ty);
 const who = (a.tag?`[${csEsc(a.tag)}] `:'') + csEsc(a.name||'?');   // XSS: nombre/tag los controla el jugador enemigo -> escapar SIEMPRE (toast auto-disparado)
 const tgt = a.tname ? csEsc(a.tname) : `(${a.tx},${a.ty})`;   // XSS: tname puede ser nombre de jugador -> escapar
 div.innerHTML = `<button class=close onclick="event.stopPropagation();this.parentElement.remove()">×</button>
   <div class=title style="font-size:16px;letter-spacing:.6px;color:#93c5fd;text-transform:uppercase;text-shadow:0 1px 3px rgba(0,0,0,.5)">🗡️ Solo attack</div>
   <div><b>${who}</b> → ${tgt}</div>
   <div class=meta>(${a.tx},${a.ty}) · ETA ${fmtLeft(a.eta)}</div>`;
 document.getElementById('toasts').appendChild(div);
 setTimeout(()=>div.remove(), 4000);   // auto-dismiss 4s
}
let RALLY_ALERTED = {};          // tileKey -> firstSeenMs (tracking p/ purga; NO dispara parpadeo)
let RALLY_TOAST_AT = {};         // tileKey -> ms del toast mostrado (dispara el parpadeo rojo 10s)
let SOLO_ALERTED = {};           // key (tile+uid) -> firstSeenMs (dedup de ataques SOLO)
let SOLO_TOAST_AT = {};          // key (tile+uid) -> ms del toast mostrado (dispara el blink azul 10s)
let _rallyAlertsInit = false;    // true tras la 1a carga (baseline silencioso)
const RALLY_NEW_MS = 10000;      // ventana en que un rally cuenta como "recien convocado" (parpadeo rojo, 10s)
const SOLO_NEW_MS  = 10000;      // ventana en que un SOLO recien notificado parpadea (azul, 10s)
const LAN_GUILD_ID = 765975;     // nuestra alianza LAN (W/E): NO notificar sus rallies
async function checkRallyAlerts(){
 try{
   const r = await fetch('/api/attacks?_='+Date.now(), {cache:'no-store'});
   const j = await r.json();
   const rallies = (j.attacks||[]).filter(a => (a.mty===19 || a.mty===20 || a.mty===21) && !a.landed);
   const cur = new Set();
   for(const a of rallies){
     const key = a.tx+','+a.ty;
     if(cur.has(key)) continue;     // un toast por tile, no por cada fase/march
     cur.add(key);
     if(RALLY_ALERTED[key] == null){
       RALLY_ALERTED[key] = Date.now();
       // Notificar SOLO cuando se convoca un rally nuevo. Excluir:
       //  - 1a carga (baseline silencioso)
       //  - marchas de vuelta (phase 'return'): no es una convocatoria
       //  - rallies de nuestra propia alianza LAN
       const isReturn = (a.phase === 'return');
       const isLan    = (a.guild === LAN_GUILD_ID);
       if(_rallyAlertsInit && !isReturn && !isLan && notifOn('rally')){
         showRallyAlert(a);
         RALLY_TOAST_AT[key] = Date.now();   // arranca el parpadeo rojo (10s) para este tile
       }
     }
   }
   // purga: si el rally ya no esta activo, lo olvidamos (re-alerta si vuelve a ese tile)
   for(const k of Object.keys(RALLY_ALERTED)){ if(!cur.has(k)){ delete RALLY_ALERTED[k]; delete RALLY_TOAST_AT[k]; } }

   // ── Ataques SOLO: toast cuando se envia uno nuevo (un solo jugador, ni rally ni scout) ──
   // Excluye marchas de vuelta (return) y los de nuestra alianza LAN. Dedup por tile+uid.
   const solos = (j.attacks||[]).filter(a => a.kind==='solo' && a.phase!=='return');
   const curSolo = new Set();
   for(const a of solos){
     const sk = a.tx+','+a.ty+','+a.uid;
     if(curSolo.has(sk)) continue;
     curSolo.add(sk);
     if(SOLO_ALERTED[sk] == null){
       SOLO_ALERTED[sk] = Date.now();
       // solo notificar si ETA > 15s (ignora ataques ya inminentes/llegando)
       if(_rallyAlertsInit && a.guild !== LAN_GUILD_ID && a.eta > 15 && notifOn('solo')){
         showSoloAlert(a);
         SOLO_TOAST_AT[sk] = Date.now();   // arranca el blink azul (10s) para esta fila
       }
     }
   }
   for(const k of Object.keys(SOLO_ALERTED)){ if(!curSolo.has(k)){ delete SOLO_ALERTED[k]; delete SOLO_TOAST_AT[k]; } }

   _rallyAlertsInit = true;
 }catch(e){}
}
checkRallyAlerts();
setInterval(checkRallyAlerts, 2000);   // VELOCIDAD: alertas de rally/ataque cada 2s (antes 5s)
// VELOCIDAD: refresco rápido de la tabla de Active Attacks (cada 2s) cuando está visible.
setInterval(()=>{ if(typeof SHOWATK!=='undefined' && SHOWATK) loadAttacks(); }, 2000);

// ── SVS: avisos de burbuja enemiga (cae pronto / se ha abierto) ──────────────
// Poller global (independiente de la pestaña). Estado por uid para detectar
// transiciones. Frescura del backend (~6 min) evita falsos "abierto" por stale.
const SVS_BUBBLE_STATE = new Map();   // uid -> {state, alertedPre, alertedDrop}
let _svsAlertsInit = false;
function svsAlertsOn(){ return notifOn('enemy'); }   // master + svs_bubble_alerts (Settings → Notifications)
function svsPreMin(){ const v = parseInt(localStorage.getItem('svs_pre_min')); return (v>0?v:5); }   // pre-aviso 5 min
function notifyDesktop(title, body){
 try{ if(notifOn('browser') && window.Notification && Notification.permission==='granted'){ new Notification(title,{body, tag:title+body, renotify:false}); } }catch(e){}
}
function showSvsBubbleAlert(kind, e){
 const div = document.createElement('div');
 div.className = 'toast ' + (kind==='open' ? 'toast-solo' : 'expiring');
 const who = (e.tag?`[${e.tag}] `:'') + (e.name||'?');
 const hasXY = (e.x||e.y);
 if(hasXY){ div.style.cursor='pointer'; div.title='Click para copiar coordenadas'; div.onclick=(ev)=>toastCopyCoords(ev,e.x,e.y); }
 const title = (kind==='open') ? '🔓 ENEMY OPEN' : '⏰ Enemy bubble dropping soon';
 const sub = (kind==='open') ? 'no shield — attackable' : ('drops in ' + fmtETA(e.shield_eta));
 div.innerHTML = `<button class=close onclick="event.stopPropagation();this.parentElement.remove()">×</button>
   <div class=title style="text-transform:uppercase;color:${kind==='open'?'#fca5a5':'#fbbf24'}">${title}</div>
   <div><b>${svsEsc(who)}</b> · ${e.powerM}M</div>
   <div class=meta>${sub}${hasXY?` · (${e.x},${e.y})`:''}</div>`;
 document.getElementById('toasts').appendChild(div);
 setTimeout(()=>div.remove(), 6000);
 playBeep();
 notifyDesktop(title, who + (hasXY?` (${e.x},${e.y})`:''));
}
async function checkSvsBubbleAlerts(data){
 if(!svsAlertsOn()) return;
 let j=data;
 if(!j){ try{ const r = await fetch('/api/svs_shields?_='+Date.now(),{cache:'no-store'}); if(!r.ok) return; j = await r.json(); }catch(e){ return; } }
 if(!j.svs_active){ SVS_BUBBLE_STATE.clear(); _svsAlertsInit = true; return; }
 const preS = svsPreMin()*60;
 const seen = new Set();
 for(const e of (j.rows||[])){
   seen.add(e.uid);
   const prev = SVS_BUBBLE_STATE.get(e.uid) || {state:null, alertedPre:false, alertedDrop:false};
   // ABIERTO: estaba con burbuja y ahora open (fresco) -> avisar 1 vez
   if(e.state==='open'){
     if(_svsAlertsInit && prev.state && prev.state!=='open' && e.age < 150 && !prev.alertedDrop){
       showSvsBubbleAlert('open', e); prev.alertedDrop = true;
     }
   } else { prev.alertedDrop = false; }
   // CAE PRONTO: eta dentro del umbral -> avisar 1 vez
   if(e.shield_eta>0 && e.shield_eta<=preS){
     if(_svsAlertsInit && !prev.alertedPre){ showSvsBubbleAlert('soon', e); prev.alertedPre = true; }
   } else if(e.shield_eta>preS){ prev.alertedPre = false; }
   prev.state = e.state;
   SVS_BUBBLE_STATE.set(e.uid, prev);
 }
 // purga uids que ya no se ven (salieron de frescura)
 for(const uid of Array.from(SVS_BUBBLE_STATE.keys())){ if(!seen.has(uid)) SVS_BUBBLE_STATE.delete(uid); }
 _svsAlertsInit = true;
}
// (los pollers de burbuja/reloc/ares se disparan desde masterPoll — 1 sola petición)

// ── SVS: relocations de enemigos (teleports de castillo) — aviso + push ───────
const SVS_RELO_SEEN = new Set();   // "uid:ts" ya notificado
let _svsReloInit = false;
function showSvsReloAlert(e){
 const div=document.createElement('div'); div.className='toast '+(e.crossed_to_us?'toast-solo':'expiring');
 const who=(e.tag?`[${e.tag}] `:'')+(e.name||('u'+e.uid));
 if(e.to&&(e.to[0]||e.to[1])){ div.style.cursor='pointer'; div.title='Click to copy destination'; div.onclick=(ev)=>toastCopyCoords(ev,e.to[0],e.to[1]); }
 const title = e.crossed_to_us ? '⚠ ENEMY JUMPED TO OUR SERVER' : '↗ Enemy relocated';
 const f=e.from||[0,0], t=e.to||[0,0];
 const route = (f[0]||f[1]) ? (f[0]+','+f[1]+(e.from_srv?(' #'+e.from_srv):'')+' → '+t[0]+','+t[1]+(e.to_srv?(' #'+e.to_srv):'')) : (t[0]+','+t[1]);
 div.innerHTML = `<button class=close onclick="event.stopPropagation();this.parentElement.remove()">×</button>
   <div class=title style="text-transform:uppercase;color:${e.crossed_to_us?'#fca5a5':'#93c5fd'}">${title}</div>
   <div><b>${svsEsc(who)}</b>${e.powerM>0?(' · '+e.powerM+'M'):''}</div>
   <div class=meta>${route}${e.hop?(' · '+e.hop+' tiles'):''}</div>`;
 document.getElementById('toasts').appendChild(div);
 setTimeout(()=>div.remove(), 7000);
 playBeep();
 notifyDesktop(title, who+' → '+t[0]+','+t[1]);
}
async function checkSvsReloAlerts(data){
 if(!svsAlertsOn()) return;
 let j=data;
 if(!j){ try{ const r=await fetch('/api/svs_relocations?max_age_m=10&_='+Date.now(),{cache:'no-store'}); if(!r.ok) return; j=await r.json(); }catch(e){ return; } }
 if(!j.svs_active){ _svsReloInit=true; return; }
 for(const e of (j.rows||[])){
   const key=e.uid+':'+e.ts;
   if(SVS_RELO_SEEN.has(key)) continue;
   SVS_RELO_SEEN.add(key);
   if(_svsReloInit && e.age < 120) showSvsReloAlert(e);   // solo eventos nuevos y recientes
 }
 if(SVS_RELO_SEEN.size>5000) SVS_RELO_SEEN.clear();
 _svsReloInit=true;
}

// ── Aviso: SPAWN de boss/event NUEVO en el mapa (summons de cualquiera en zona escaneada) ──
// Dedup por tile+nombre; baseline silencioso en el 1er fetch; purga -> re-avisa si reaparece.
const SPAWN_SEEN = new Set(); let _spawnInit=false;
function showSpawnAlert(r){
 const div=document.createElement('div'); div.className='toast';
 const xy=(r.x||r.y)?(r.x+','+r.y):'';
 if(xy){ div.style.cursor='pointer'; div.title='Click to copy coords'; div.onclick=(ev)=>toastCopyCoords(ev,r.x,r.y); }
 const by = r.owner_label || ('#'+(r.owner_uid||'?'));
 div.innerHTML=`<button class=close onclick="event.stopPropagation();this.parentElement.remove()">×</button>
   <div class=title style="text-transform:uppercase;color:#fcd34d">✨ Monster summoned</div>
   <div><b>${svsEsc(r.name||'?')}${r.level?(' Lv'+r.level):''}</b></div>
   <div class=meta>by ${svsEsc(by)}${xy?(' · ('+xy+')'):''}</div>`;
 document.getElementById('toasts').appendChild(div);
 setTimeout(()=>div.remove(), 12000);
 playBeep();
 notifyDesktop('✨ Monster summoned', (r.name||'?')+(xy?(' ('+xy+')'):''));
}
async function checkSpawnAlerts(data){
 if(!notifOn('spawns')) return;
 let j=data;
 if(!j){ try{ const r=await fetch('/api/pulse?_='+Date.now(),{cache:'no-store'}); if(!r.ok) return; j=(await r.json()).spawns; }catch(e){ return; } }
 if(!j) return;
 const seen=new Set();
 for(const r of (j.rows||[])){
   const k=r.x+','+r.y+','+(r.name||'');
   seen.add(k);
   if(SPAWN_SEEN.has(k)) continue;
   SPAWN_SEEN.add(k);
   if(_spawnInit) showSpawnAlert(r);
 }
 for(const k of Array.from(SPAWN_SEEN)){ if(!seen.has(k)) SPAWN_SEEN.delete(k); }
 _spawnInit=true;
}

// ── Aviso: aparición de Ares Statue en el mapa (solo NUESTRO server) ──────────
// Ares Statue es un objeto de evento valioso. Poller cada 15s sobre /api/data
// (ya filtrado a nuestro server); dedup por tile; baseline en el 1er fetch para
// no avisar de las ya presentes; purga al desaparecer -> re-avisa si reaparece.
const ARES_SEEN = new Set(); let _aresInit=false;
function aresAlertsOn(){ return notifOn('ares'); }   // master + ares_alerts (Settings → Notifications)
function showAresAlert(r){
 const div=document.createElement('div'); div.className='toast expiring';
 const xy=(r.x||r.y)?(r.x+','+r.y):'';
 if(xy){ div.style.cursor='pointer'; div.title='Click to copy coords'; div.onclick=(ev)=>toastCopyCoords(ev,r.x,r.y); }
 div.innerHTML=`<button class=close onclick="event.stopPropagation();this.parentElement.remove()">×</button>
   <div class=title style="text-transform:uppercase;color:#c4b5fd">🏛️ Ares Statue spotted</div>
   <div><b>${svsEsc(r.name||'Ares Statue')}${r.level?(' Lv'+r.level):''}</b></div>
   <div class=meta>${xy}</div>`;
 document.getElementById('toasts').appendChild(div);
 setTimeout(()=>div.remove(), 12000);
 playBeep();
 notifyDesktop('🏛️ Ares Statue spotted', (r.name||'Ares Statue')+(xy?(' ('+xy+')'):''));
}
async function checkAresAlerts(data){
 if(!aresAlertsOn()) return;
 let j=data;
 if(!j){ try{ const r=await fetch('/api/data?fams='+encodeURIComponent('Ares Statue')+'&max_seen=180&limit=80&_='+Date.now(),{cache:'no-store'}); if(!r.ok) return; j=await r.json(); }catch(e){ return; } }
 const seen=new Set();
 for(const r of (j.rows||[])){
   const k=r.x+','+r.y; seen.add(k);
   if(ARES_SEEN.has(k)) continue;
   ARES_SEEN.add(k);
   if(_aresInit) showAresAlert(r);
 }
 for(const k of Array.from(ARES_SEEN)){ if(!seen.has(k)) ARES_SEEN.delete(k); }
 _aresInit=true;
}
// ── POLLER ÚNICO: una sola petición /api/pulse cada 6s alimenta los 3 avisos de
// solo-lectura (burbujas + relocations + Ares) -> menos carga de backend y móvil.
async function masterPoll(){
 let p;
 try{ const r=await fetch('/api/pulse?_='+Date.now(),{cache:'no-store'}); if(!r.ok) return; p=await r.json(); }
 catch(e){ return; }
 try{ await checkSvsBubbleAlerts(p.shields); }catch(e){}
 try{ await checkSvsReloAlerts(p.relocations); }catch(e){}
 try{ await checkAresAlerts(p.ares); }catch(e){}
 try{ await checkSpawnAlerts(p.spawns); }catch(e){}
}
masterPoll();
setInterval(masterPoll, 6000);

// ── SVS: alertas de DEFENSA (ataques entrantes a nuestra alianza) ────────────
const DEF_ALERTED = {};
let _defAlertsInit = false;
function showDefenseAlert(x){
 const div=document.createElement('div'); div.className='toast expiring';
 const who=(x.atk_tag?`[${x.atk_tag}] `:'')+(x.atk_name||('u'+x.atk_uid));
 const tgt=(x.tgt_tag?`[${x.tgt_tag}] `:'')+(x.tgt_name||('u'+x.tgt_uid));
 if(x.x||x.y){ div.style.cursor='pointer'; div.title='Click para copiar coordenadas'; div.onclick=(e)=>toastCopyCoords(e,x.x,x.y); }
 div.innerHTML=`<button class=close onclick="event.stopPropagation();this.parentElement.remove()">×</button>
   <div class=title style="text-transform:uppercase;color:#fca5a5">🛡️ DEFENSE — incoming attack</div>
   <div><b>${svsEsc(who)}</b> → ${svsEsc(tgt)}</div>
   <div class=meta>(${x.x},${x.y}) · ${x.phase} · ${x.count} march(es) · ETA ${fmtETA(x.eta)}</div>`;
 document.getElementById('toasts').appendChild(div);
 setTimeout(()=>div.remove(),6000); playBeep();
 notifyDesktop('🛡️ DEFENSE: incoming attack', who+' → '+tgt+' ('+x.x+','+x.y+')');
}
async function checkDefenseAlerts(){
 let j;
 try{ const r=await fetch('/api/svs_defense?_='+Date.now(),{cache:'no-store'}); if(!r.ok) return; j=await r.json(); }catch(e){ return; }
 const badge=document.getElementById('svs_def_count'); if(badge) badge.textContent=(j.total||0)?('('+j.total+')'):'';
 const cur=new Set();
 for(const x of (j.rows||[])){
   const key=x.x+','+x.y+'@'+x.atk_uid;
   cur.add(key);
   if(DEF_ALERTED[key]==null){
     DEF_ALERTED[key]=Date.now();
     if(_defAlertsInit && svsAlertsOn()) showDefenseAlert(x);   // baseline silencioso en 1a carga
   }
 }
 for(const k of Object.keys(DEF_ALERTED)){ if(!cur.has(k)) delete DEF_ALERTED[k]; }
 _defAlertsInit=true;
}
checkDefenseAlerts();
setInterval(checkDefenseAlerts, 6000);

let _playerSearchTimer = null;
function onPlayerSearchInput(){
 // debounce 350ms: filtra mientras escribes sin disparar requests por cada tecla
 if(_playerSearchTimer) clearTimeout(_playerSearchTimer);
 _playerSearchTimer = setTimeout(loadPlayers, 350);
}

// ---- Sort interactivo de columnas en pestaña Players (client-side sobre rows ya cargados) ----
// PLAYER_SORT_COL = 'tag' | 'level' | 'castle' | 'power' | 'bubble' | 'dist' | null (server-sort)
// PLAYER_SORT_DIR = 'asc' | 'desc'
let PLAYER_SORT_COL = null;
let PLAYER_SORT_DIR = 'desc';
let _lastPlayerRows = [];

function setPlayerSort(col){
 // 1er click: desc. 2o click misma col: toggle asc. 3er click misma: limpia (vuelve a server-sort).
 if(PLAYER_SORT_COL === col){
   if(PLAYER_SORT_DIR === 'desc') PLAYER_SORT_DIR = 'asc';
   else { PLAYER_SORT_COL = null; PLAYER_SORT_DIR = 'desc'; }
 } else {
   PLAYER_SORT_COL = col; PLAYER_SORT_DIR = 'desc';
 }
 updatePlayerSortArrows();
 // re-render rows ya cargados (sin pedir al server)
 if(_lastPlayerRows.length) renderPlayerRows(_lastPlayerRows);
}

function updatePlayerSortArrows(){
 const cols = ['tag','level','castle','power','bubble','dist'];
 for(const c of cols){
   const el = document.getElementById('p_sorth_'+c);
   if(!el) continue;
   if(c === PLAYER_SORT_COL){
     el.textContent = PLAYER_SORT_DIR === 'desc' ? '▼' : '▲';
     el.classList.add('active');
   } else {
     el.textContent = '⇅';   // caret neutro indica "ordenable"
     el.classList.remove('active');
   }
 }
}

function getRowSortKey(row, col){
 if(col === 'tag')    return (row.tag||'').toLowerCase();
 if(col === 'level')  return +row.level || 0;
 if(col === 'castle') return +row.castle || 0;
 if(col === 'power')  return +row.power || 0;
 if(col === 'bubble') {
   // criterio: ETA real >> tier 1 (item) >> tier 2 (newbie) >> sin shield
   // ASC = sin shield primero, los que mas tiempo quedan al final
   // Numero compuesto: si tiene eta>0 -> usa eta + 1000000 (para que pase delante de tiers).
   // Si solo tier -> usa tier*100. Si nada -> 0.
   const e = +row.shield_eta || 0;
   const t = +row.shield || 0;
   if(e > 0) return e + 1e9;   // los con ETA real al final cuando es DESC
   if(t > 0) return t * 1000;
   return 0;
 }
 if(col === 'dist')   return row.dist != null ? +row.dist : Number.MAX_SAFE_INTEGER;
 return 0;
}

function applyClientSort(rows){
 if(!PLAYER_SORT_COL) return rows;
 const dir = PLAYER_SORT_DIR === 'asc' ? 1 : -1;
 return rows.slice().sort((a,b)=>{
   const av = getRowSortKey(a, PLAYER_SORT_COL);
   const bv = getRowSortKey(b, PLAYER_SORT_COL);
   if(typeof av === 'string') return av.localeCompare(bv) * dir;
   return (av - bv) * dir;
 });
}
async function loadPlayers(){
 const g=id=>document.getElementById(id).value;
 const ma=g('p_maxage');
 const q=new URLSearchParams({lv_min:g('p_lvmin'),lv_max:g('p_lvmax'),
  pw_min:g('p_pwmin'),pw_max:g('p_pwmax'),shield:g('p_shield'),tag:g('p_tag'),
  q:g('p_search'),
  cx:g('p_cx'),cy:g('p_cy'),radius:g('p_rad'),sort:g('p_sort'),limit:g('p_lim')});
 if(ma) q.set('max_seen', String(Math.round(parseFloat(ma)*60)));
 const r=await fetch('/api/players?'+q+'&_='+Date.now(),{cache:'no-store'});const j=await r.json();
 _lastPlayerRows = j.rows || [];
 renderPlayerRows(_lastPlayerRows);
 document.getElementById('stat').textContent=
   `${j.total} players | total captured: ${j.all} | sweep ${swStr(j)}`;
}

function renderPlayerRows(rowsIn){
 // Aplica sort client-side si hay columna activa, sino usa el orden del server.
 updatePlayerSortArrows();   // garantiza que el caret neutro ⇅ aparece desde el primer render
 const rows = applyClientSort(rowsIn);
 const tb = document.getElementById('prows'); tb.innerHTML='';
 for(const x of rows){
  const tr=document.createElement('tr');
  // Bubble (confidence-aware):
  //  - confidence='exact'              -> verde "YES · 4h 23m" (ground truth: scout/mail/subcity/self)
  //  - confidence='inferred_history'   -> ámbar "~7h 45m" tooltip "median of N history samples"
  //  - confidence='inferred_default'   -> ámbar "~7h 45m" tooltip "assumed default 8h (no history)"
  //  - confidence='newbie_inferred'    -> gris  "NEWBIE ≤Xd Yh" tooltip "newbie max bound"
  //  - tier=1 sin ETA                  -> azul  "BUBBLE (?)" tooltip "tier=1 but no transition detected yet"
  //  - tier=2 sin ETA                  -> gris  "NEWBIE"
  //  - tier=0                          -> gris  "no"
  let sh;
  if (x.shield_eta && x.shield_eta>0) {
    const s = x.shield_eta;
    const txt = fmtLeft(s);
    const c = x.shield_confidence || '';
    if (c === 'exact') {
      sh = `<span class=sh title="Exact ETA captured via ${x.shield_src||'?'}">YES · ${txt}</span>`;
    } else if (c === 'inferred_sku') {
      sh = `<span class=sh_inf title="Inferred from this player's USUAL shield SKU (the duration they habitually buy, learned from past observed cycles). Activated unix ${x.shield_activation_ts}. More precise than a blind default. Validation: counts as 'exact' if later confirmed by scout/mail.">~${txt}</span>`;
    } else if (c === 'inferred_history') {
      const n = x.shield_history_n || 0;
      sh = `<span class=sh_inf title="Inferred from median of ${n} past shield durations (activation detected on tier 0→1 transition at unix ${x.shield_activation_ts}). Less precise than scout ETA. Validation: counts as 'exact' if later confirmed by scout/mail.">~${txt}</span>`;
    } else if (c === 'inferred_default') {
      sh = `<span class=sh_inf title="Inferred (default 8h assumed). Activated unix ${x.shield_activation_ts}. No prior history for this uid. Mark watchlist + scout when CL19+ for exact ETA.">~${txt}</span>`;
    } else if (c === 'inferred_undershot') {
      sh = `<span class=sh_under title="UNDERSHOT: previous inference expired but shield broadcast still active. Re-extended using this player's usual shield SKU when known, else +50% over last observed duration.">~${txt} ⚠</span>`;
    } else if (c === 'newbie_inferred') {
      sh = `<span class=sh_newb title="Newbie shield (tier=2). Activation detected at unix ${x.shield_activation_ts}; max 7d from then. Real expiry tied to account creation date.">NEWBIE ≤${txt}</span>`;
    } else {
      sh = `<span class=sh title="ETA captured (src=${x.shield_src||'?'})">YES · ${txt}</span>`;
    }
  } else if (x.shield === 2) {
    sh = `<span class=sh_newb title="Newbie/starter shield (7d from account creation). No transition seen yet — exact ETA requires scout.">NEWBIE</span>`;
  } else if (x.shield === 1) {
    sh = `<span class=sh_unk title="Item shield active (tier=1). No activation transition seen — scanner started after shield was already up. Mark watchlist to track future expiry.">BUBBLE (?)</span>`;
  } else {
    sh = '<span class=nosh>no</span>';
  }
  // ⭐ Watchlist toggle button (necesita comillas en class por las DOS clases)
  const star = (WATCHLIST_UIDS.has(x.uid))
    ? `<span class="wstar wstar-on" title="Remove from watchlist" onclick="toggleWatchlist(${x.uid}, this)">★</span>`
    : `<span class="wstar" title="Add to watchlist (alerts on shield expiry)" onclick="toggleWatchlist(${x.uid}, this)">☆</span>`;
  const tg = x.tag ? `<span class=tag>${csEsc(x.tag)}</span>` : '<span class=stat>—</span>';
  let pw;
  if (+x.power > 0) {
    let tip, stale=false;
    if (x.power_src==='power_rank') {
      const age = (x.power_ts>0) ? Math.max(0, Math.floor(Date.now()/1000 - x.power_ts)) : null;
      stale = (age!=null && age > 172800);   // >2 días = probablemente desactualizado
      tip = 'Captured from Power Rankings' + (age!=null ? (' '+pactAgo(age)+' ago') : '')
          + ' — persists globally until you reopen Rankings → Power in-game';
    } else { tip = 'From broadcast'; }
    pw = `<span title="${tip}"${stale?' style="opacity:.6"':''}>${fmtPow(x.power)}${stale?' <span class=stat style="font-size:10px">old</span>':''}</span>`;
  } else {
    pw = `<span class=stat title="Not in broadcast. Open Rankings → Power Ranking in-game so the client requests the list and we cache power per uid">—</span>`;
  }
  const _ls=x.member_lastseen;
  let dot='';
  if(_ls!=null){
   if(_ls<=1) dot=`<span title="Online (alliance member-list)" style="color:#34d399;font-size:11px;margin-right:3px">●</span>`;
   else { const _ag=Math.max(0,Math.floor(Date.now()/1000)-_ls); dot=`<span title="Offline — last seen ${pactAgo(_ag)} ago (alliance member-list)" style="color:#6b7280;font-size:11px;margin-right:3px">●</span>`; }
  }
  tr.innerHTML=`<td>${star} ${dot}<span class=plink title="Click for activity / estimated online hours" onclick="openPlayerActivity(${x.uid})">${csEsc(x.name||'?')}</span></td><td>${tg}</td><td class=lvl>${x.level}</td><td>${x.castle}</td>
   <td>${pw}</td><td>${sh}</td><td>${x.x}</td><td>${x.y}</td>
   <td>${x.dist!=null?(x.dist+' km'):'-'}</td><td><span class=cp onclick="navigator.clipboard.writeText('${x.x},${x.y}')">${x.x},${x.y} &#8682;</span></td>`;
  tb.appendChild(tr);
 }
}

// ===== Player Activity popup (Fase 1+2) =====
const PACT_TYPE_LABEL={march:'Marches',relocate:'Relocations',shield:'Shield activations',pvp:'PvP attacks'};
const PACT_DOW=['Mon','Tue','Wed','Thu','Fri','Sat','Sun'];
function pactAgo(s){ s=Math.max(0,Math.floor(s));
 if(s<60)return s+'s';
 if(s<3600)return Math.floor(s/60)+'m';
 if(s<86400)return Math.floor(s/3600)+'h'+Math.floor((s%3600)/60)+'m';
 return Math.floor(s/86400)+'d'+Math.floor((s%86400)/3600)+'h'; }
function pactWhen(ts){ try{return new Date(ts*1000).toLocaleString();}catch(e){return '—';} }
async function openPlayerActivity(uid, forceCheat){
 const m=document.getElementById('pactModal');
 m.style.display='flex';
 document.getElementById('pactTitle').textContent='Player #'+uid;
 document.getElementById('pactBody').innerHTML='<div class=stat>Loading…</div>';
 try{
  const r=await fetch('/api/player_activity?uid='+uid+(forceCheat?'&cheat=1':'')+'&_='+Date.now(),{cache:'no-store'});
  const j=await r.json();
  renderPlayerActivity(j);
 }catch(e){ document.getElementById('pactBody').innerHTML='<div class=stat>Error: '+e+'</div>'; }
}
function closePlayerActivity(){ document.getElementById('pactModal').style.display='none'; }
function pactRealStatus(j){
 // Status REAL de la member-list de la alianza (campo __lastseen de user_summary).
 // null/undefined = no es miembro de una alianza que sondeamos. <=1 = online ahora.
 const ls=j.member_lastseen;
 if(ls===null||ls===undefined) return '';
 if(ls<=1){
  return `<div class=pact-sec style="border-left:3px solid #22c55e;padding-left:10px">
   <h4>Alliance status (real)</h4>
   <div style="font-size:16px;color:#34d399;font-weight:700">🟢 Online</div>
   <div class=stat>Authoritative — from the alliance member-list (refreshed ~every 10 min).</div></div>`;
 }
 return `<div class=pact-sec style="border-left:3px solid #6b7280;padding-left:10px">
  <h4>Alliance status (real)</h4>
  <div style="font-size:16px;color:#cbd5e1;font-weight:700">⚪ Offline · last seen <b>${pactAgo(j.now-ls)}</b> ago</div>
  <div class=stat>${pactWhen(ls)} — authoritative, from the alliance member-list.</div></div>`;
}
// Sección de anomalías conductuales (anti-cheat) en el modal del jugador.
function cheatSection(j){
 const c=j.cheat; const rs=j.rally_steal;
 const hasC=c&&(c.flags||[]).length, hasR=rs&&rs.count;
 if(!hasC && !hasR) return '';
 const col=(c&&c.level==='high')?'#f87171':((c&&c.level==='medium')?'#fbbf24':'#94a3b8');
 const lbl=c?c.level.toUpperCase():'—';
 const flags=(c&&(c.flags||[]).length)?(c.flags.map(f=>`<li style="margin:3px 0">${svsEsc(f.text)}</li>`).join('')):'';
 const rally=hasR?`<li style="margin:3px 0">Rally contention: hit the same monster as our alliance ${rs.count}× (${rs.steals} at/after us — sniping).</li>`:'';
 const meta=c?`<div class=stat style="font-size:11px">Window ~${c.span_days}d · ${c.total} actions (${c.per_day}/day) · active ${c.hours_covered}/24 h-of-day · longest quiet gap ${c.longest_quiet_h}h</div>`:'';
 return `<div class=pact-sec style="border:1px solid ${col}66;border-radius:8px;padding:10px;background:${col}14">
   <h4 style="color:${col}">🚩 Behavioral report · ${lbl}${c?(' <span class=stat>(score '+c.score+')</span>'):''}</h4>
   <ul style="margin:6px 0;padding-left:18px;font-size:12.5px">${flags}${rally}</ul>
   ${meta}
   <button class=sec onclick="copyCheatReport()" style="margin-top:8px;padding:4px 9px">📋 Copy report for Evony</button> <span id=cheatcopy class=stat style="font-size:11px"></span></div>`;
}
function _ciso(ts){ try{ return ts?new Date(ts*1000).toISOString().replace('T',' ').slice(0,19)+' UTC':'—'; }catch(e){ return '—'; } }
function buildCheatReport(j){
 const c=j.cheat; const rs=j.rally_steal;
 // generamos report si hay anomalías de comportamiento O contención de rallies
 if((!c||!(c.flags||[]).length) && !(rs&&rs.count)) return '';
 const who=(j.tag?('['+j.tag+'] '):'')+(j.name||('u'+j.uid));
 const L=[];
 L.push('========================================================');
 L.push('  PLAYER REPORT — SUSPECTED CHEATING / GAME AUTOMATION');
 L.push('========================================================');
 L.push('Player name : '+who);
 if(j.sv) L.push('Server      : #'+j.sv);
 L.push('');
 L.push('WHY WE ARE REPORTING THIS PLAYER');
 L.push('  Over the last weeks the fair-play experience on our server has degraded noticeably.');
 L.push('  Several alliances raised concerns about specific players who appear to play non-stop');
 L.push('  and out-compete everyone in a way that does not match normal human play. We therefore');
 L.push('  began a continuous, thorough watch of this player\\'s public in-game activity and kept a');
 L.push('  detailed log of what they do and when. The findings below are the result of that watch.');
 if(c){
   L.push('');
   L.push('OBSERVATION PERIOD');
   L.push('  From : '+_ciso(c.first));
   L.push('  To   : '+_ciso(c.last)+'   (~'+c.span_days+' days of continuous monitoring)');
   L.push('  Recorded player actions: '+c.total+'   (average '+c.per_day+' per day)');
   const bd=Object.keys(c.by_type||{}).map(k=>'      - '+k+': '+c.by_type[k]).join('\\n'); if(bd) L.push('  Breakdown by action type:\\n'+bd);
   L.push('');
   L.push('KEY FINDINGS');
   (c.flags||[]).forEach((f,i)=>L.push('  '+(i+1)+'. '+f.text));
   if(!(c.flags||[]).length) L.push('  (no automation-specific flags; see rally contention below)');
   L.push('');
   L.push('DAILY ACTIVITY PROFILE (server-local time)');
   L.push('  Active during '+c.hours_covered+' of the 24 hours of the day; '+c.week_hours+' of 168 weekly hours.');
   L.push('  Hours of day with recorded activity: '+(c.active_hours||[]).map(h=>(h<10?'0':'')+h+':00').join(', '));
   L.push('  Longest uninterrupted daily idle window: '+c.longest_quiet_h+'h (normal human players rest 6-8h/day).');
   if(c.timing && c.timing.periodic){
     L.push('');
     L.push('LAUNCH-TIMING ANALYSIS (strong automation indicator)');
     L.push('  '+Math.round(c.timing.dominant_frac*100)+'% of '+c.timing.samples+' recorded troop launches are spaced almost exactly');
     L.push('  ~'+c.timing.dominant_interval+'s apart (interval variability '+c.timing.cv+'). A human cannot reproduce a fixed');
     L.push('  cadence at this consistency — this is the signature of a scripted timer.');
   }
   if(c.max_speed && (c.speed_samples||[]).length){
     L.push('');
     L.push('MARCH-SPEED ANALYSIS (speed manipulation)');
     L.push('  Peak observed march speed: ~'+c.max_speed+' tiles/s, above what is reachable with all');
     L.push('  march-speed buffs stacked. Concrete marches (distance / time / implied speed):');
     c.speed_samples.slice(0,8).forEach(s=>L.push('      '+s.dist+' tiles in '+s.dur+'s = '+s.sp+' tiles/s  -> '+s.tx+','+s.ty+'  ['+_ciso(s.ts)+']'));
   }
 }
 // contención / robo de rallies
 if(rs && rs.count){
   L.push('');
   L.push('RALLY CONTENTION / SNIPING AGAINST OTHER PLAYERS');
   L.push('  On '+rs.count+' occasions (last '+(rs.examples&&rs.examples[0]?'~'+rs.window_m+'min window':'')+') this player launched a rally on the SAME monster');
   L.push('  target as our alliance within minutes; '+rs.steals+' of those at the same time or right after us');
   L.push('  (repeatedly contesting / sniping targets others had already engaged).');
   if((rs.examples||[]).length){
     L.push('  Concrete examples (target · timing vs our rally · timestamp):');
     rs.examples.slice(0,10).forEach(e=>L.push('      '+(e.tname||(e.x+','+e.y))+(e.lv?(' L'+e.lv):'')+' @'+e.x+','+e.y+'  '+(e.gap>=0?('+'+e.gap+'s after us'):((-e.gap)+'s before us'))+'  ['+_ciso(e.ts)+']'));
   }
 }
 // muestras concretas de teleports
 if((j.relocations||[]).length){
   L.push('');
   L.push('SAMPLE CASTLE TELEPORTS (timestamped):');
   j.relocations.slice(0,12).forEach(r=>L.push('      '+_ciso(r.ts)+'   '+r.from[0]+','+r.from[1]+'  ->  '+r.to[0]+','+r.to[1]));
 }
 L.push('');
 L.push('CONCLUSION');
 L.push('  The combination of '+[
    (c&&c.flags&&c.flags.some(f=>f.key==='no_sleep'))?'no daily rest window':null,
    (c&&c.flags&&c.flags.some(f=>f.key==='timing'))?'machine-fixed launch cadence':null,
    (c&&c.flags&&c.flags.some(f=>f.key==='volume'))?'super-human action volume':null,
    (rs&&rs.count)?'systematic rally sniping':null
   ].filter(Boolean).join(', ')||'the recorded behavior patterns');
 L.push('  is not consistent with manual human play and strongly suggests the use of automation /');
 L.push('  third-party tools. We respectfully ask that this account be reviewed against its');
 L.push('  server-side action logs and action taken per the game\\'s rules. Detailed timestamps');
 L.push('  above are provided so the activity can be cross-checked directly.');
 return L.join('\\n');
}
function copyCheatReport(){ try{ navigator.clipboard.writeText(window.__lastCheatReport||''); const s=document.getElementById('cheatcopy'); if(s){ s.textContent='copied ✓'; setTimeout(()=>{ if(s) s.textContent=''; },2500); } }catch(e){} }
// Ficha de combate (intel PvP): scout (tropas/general/muro) + historial de batallas.
function _gpow(n){ n=+n||0; return n>=1e6?(Math.round(n/1e5)/10+'M'):(n>=1e3?(Math.round(n/100)/10+'k'):n); }
function pvpIntelSection(j){
 const p=j.pvp; if(!p) return '';
 let html='<div class=pact-sec><h4>⚔️ Combat intel</h4>';
 const sc=p.scout;
 if(sc){
   const dg=sc.def_general, da=sc.def_assistant;
   html+=`<div style="font-size:12.5px;line-height:1.6">
     <b>Last scout</b> <span class=stat>(${sc.ts?pactAgo(j.now-sc.ts)+' ago':'—'})</span><br>
     Army: <b>${_gpow(sc.total_army)}</b> · Wall: <b>${_gpow(sc.total_wall)}</b>${sc.archertower?(' · ArcherTower L'+sc.archertower):''}<br>
     Def general: <b>${dg?svsEsc(dg.label)+(dg.star?(' '+dg.star+'★'):''):'—'}</b>${da&&da.label&&da.label!=='?'?(' + '+svsEsc(da.label)):''}
   </div>`;
 }
 if((p.battles||[]).length){
   html+='<div style="margin-top:6px"><b style="font-size:12.5px">Recent battles</b>';
   p.battles.forEach(b=>{
     const win=b.their_lost>b.my_lost;
     const col=win?'#34d399':(b.my_lost>b.their_lost?'#f87171':'#94a3b8');
     html+=`<div class=actrow style="font-size:11.5px"><span class=stat>${pactAgo(j.now-b.ts)} ago</span> `
       +`<span style="color:${col}">${b.role==='attacker'?'ATK':'DEF'}</span> vs ${svsEsc(b.vs||'?')}${b.vs_tag?(' ['+svsEsc(b.vs_tag)+']'):''} `
       +`· lost <b style="color:#f87171">${_gpow(b.my_lost)}</b> / dealt <b style="color:#34d399">${_gpow(b.their_lost)}</b>${b.gen&&b.gen.label!=='?'?(' · '+svsEsc(b.gen.label)):''}</div>`;
   });
   html+='</div>';
 }
 if(!sc && !(p.battles||[]).length) return '';
 html+='<div class=pact-note style="font-size:10.5px">Only available for players we have scouted or fought (reports seen by our accounts).</div></div>';
 return html;
}
function renderPlayerActivity(j){
 document.getElementById('pactTitle').textContent=(j.tag?('['+j.tag+'] '):'')+(j.name||'?')+' · #'+j.uid;
 const b=document.getElementById('pactBody');
 if(!j.total_events){
  b.innerHTML=pactRealStatus(j)+`<div class=pact-sec><p class=stat style="font-size:13px">No inferred activity observed yet for this player.</p></div>
   <div class=pact-note>We only log actions we can observe — marches, relocations, shield activations, PvP. This player hasn't acted within our scan coverage yet.${j.scanner_last_seen?(' Their castle was last seen on the map '+pactAgo(j.now-j.scanner_last_seen)+' ago (map coverage, NOT a login).'):''}</div>`;
  return;
 }
 const la=j.last_activity?pactAgo(j.now-j.last_activity):'—';
 const fa=j.first_activity?pactAgo(j.now-j.first_activity):'—';
 const peak=(j.peak_hours||[]).map(h=>String(h).padStart(2,'0')+':00').join(', ')||'—';
 const bt=Object.keys(j.by_type||{}).map(k=>`${PACT_TYPE_LABEL[k]||k}: <b>${j.by_type[k]}</b>`).join(' · ')||'—';
 let max=0; for(const row of (j.heatmap||[])) for(const c of row) if(c>max)max=c;
 let hm='<div class=hm><div></div>';
 for(let h=0;h<24;h++) hm+=`<div class=hmh>${h%6===0?h:''}</div>`;
 for(let d=0;d<7;d++){
  hm+=`<div class=hmlab>${PACT_DOW[d]}</div>`;
  for(let h=0;h<24;h++){
   const c=(j.heatmap&&j.heatmap[d]&&j.heatmap[d][h])||0;
   const al=(max>0&&c>0)?(0.15+0.85*(c/max)):0;
   const bg=c>0?`rgba(56,189,248,${al.toFixed(3)})`:'#161b24';
   hm+=`<div class=cell title="${PACT_DOW[d]} ${String(h).padStart(2,'0')}:00 — ${c} events" style="background:${bg}"></div>`;
  }
 }
 hm+='</div>';
 const rec=(j.recent||[]).slice(0,15).map(e=>`<div class=actrow><span class=stat>${pactAgo(j.now-e.ts)} ago</span> <span class=tag>${PACT_TYPE_LABEL[e.type]||e.type}</span></div>`).join('')||'<div class=stat>—</div>';
 window.__lastCheatReport = buildCheatReport(j);
 b.innerHTML=pactRealStatus(j)+cheatSection(j)+`
  <div class=pact-sec><h4>Last activity seen</h4>
   <div style="font-size:15px"><b>${la} ago</b> <span class=stat>(${j.last_activity?pactWhen(j.last_activity):'—'})</span></div>
   <div class=stat>First observed ${fa} ago · ${j.total_events} events total</div></div>
  <div class=pact-sec><h4>Estimated active hours (peak)</h4>
   <div style="font-size:14px"><b>${peak}</b> <span class=stat>· server local time</span></div>
   ${j.off_window?('<div style="font-size:13px;margin-top:4px">🎯 Best attack window (usually offline): <b style="color:'+(j.off_window.now_in?'#34d399':'#93c5fd')+'">'+((j.off_window.start<10?'0':'')+j.off_window.start)+':00–'+((j.off_window.end<10?'0':'')+j.off_window.end)+':00</b> '+(j.off_window.now_in?'✅ likely offline NOW':'')+' <span class=stat>('+j.off_window.confidence+' confidence)</span></div>'):''}</div>
  <div class=pact-sec><h4>Activity heatmap — day × hour (local)</h4>${hm}</div>
  <div class=pact-sec><h4>Events by type</h4><div>${bt}</div></div>
  ${pvpIntelSection(j)}
  <div class=pact-sec><h4>Recent events</h4>${rec}</div>
  <div class=pact-note>⚠ The heatmap, peak hours and "last activity" are inferred from observed actions (marches, relocations, shields, PvP) — distinct from the authoritative alliance status shown above. Coverage is biased toward the scan area, so quiet/distant players may show little. The heatmap sharpens over days as more events accumulate.</div>`;
}

// ----- Helpers para los 4 nuevos tabs (Resources/Relics/Arctic/Subcities) -----
function buildQ(prefix, fields){
 const q = new URLSearchParams();
 for(const [src,dst] of Object.entries(fields)){
   const v = document.getElementById(prefix+src);
   if(v && v.value!=='' && v.value!=null) q.set(dst, v.value);
 }
 return q;
}
function renderPager(prefix, j, gotoFn){
 const pg = document.getElementById(prefix+'pginfo');
 const fst=document.getElementById(prefix+'pg_first');
 const prv=document.getElementById(prefix+'pg_prev');
 const nxt=document.getElementById(prefix+'pg_next');
 const lst=document.getElementById(prefix+'pg_last');
 if(!pg) return;
 pg.textContent = `${(j.page-1)*j.limit+1}–${Math.min(j.page*j.limit,j.total)} of ${j.total} (page ${j.page}/${j.pages})`;
 fst.disabled = prv.disabled = j.page<=1;
 nxt.disabled = lst.disabled = j.page>=j.pages;
}

let rCurPage=1, rlCurPage=1, arCurPage=1, scCurPage=1;
function gotoPageRes(p){ rCurPage = Math.max(1,p); loadResources(true); }
function gotoPageRel(p){ rlCurPage = Math.max(1,p); loadRelics(true); }
function gotoPageArc(p){ arCurPage = Math.max(1,p); loadArctic(true); }
function gotoPageSc(p){ scCurPage = Math.max(1,p); loadSubcities(true); }

function fmtAmount(n){
 n = +n||0;
 if(n>=1e9) return (n/1e9).toFixed(2)+'B';
 if(n>=1e6) return (n/1e6).toFixed(2)+'M';
 if(n>=1e3) return (n/1e3).toFixed(0)+'K';
 return n.toString();
}
function fmtOccupied(x){
 if(x.occupy_status==='free') return '<span class=nosh>Free</span>';
 const who = x.occupy_name ? csEsc(x.occupy_name) : ('u'+x.occupy_uid);
 const tag = x.occupy_tag ? `[${csEsc(x.occupy_tag)}] ` : '';
 const cls = x.occupy_status==='mine' ? 'sh' : 'tag';
 const lbl = x.occupy_status==='mine' ? ' · MINE' : '';
 return `<span class=${cls}>${tag}${who}${lbl}</span>`;
}
// ---- Sort interactivo Resources (client-side, columnas Level/Available/Dist/Discovered) ----
let RES_SORT_COL = null;
let RES_SORT_DIR = 'desc';
let _lastResRows = [];
let _lastResMeta = null;

function setResSort(col){
 if(RES_SORT_COL === col){
   if(RES_SORT_DIR === 'desc') RES_SORT_DIR = 'asc';
   else { RES_SORT_COL = null; RES_SORT_DIR = 'desc'; }
 } else {
   RES_SORT_COL = col; RES_SORT_DIR = 'desc';
 }
 updateResSortArrows();
 if(_lastResRows.length) renderResRows(_lastResRows, _lastResMeta);
}
function updateResSortArrows(){
 for(const c of ['level','available','dist','discovered']){
   const el = document.getElementById('r_sorth_'+c);
   if(!el) continue;
   if(c === RES_SORT_COL){
     el.textContent = RES_SORT_DIR === 'desc' ? '▼' : '▲';
     el.classList.add('active');
   } else {
     el.textContent = '⇅';
     el.classList.remove('active');
   }
 }
}
function getResRowSortKey(row, col){
 if(col === 'level')      return +row.level || 0;
 if(col === 'available')  return +row.available || 0;
 if(col === 'dist')       return row.dist != null ? +row.dist : Number.MAX_SAFE_INTEGER;
 if(col === 'discovered') return +row.age || 0;   // age: segundos desde captura. ASC = recientes primero
 return 0;
}
function applyResClientSort(rows){
 if(!RES_SORT_COL) return rows;
 const dir = RES_SORT_DIR === 'asc' ? 1 : -1;
 return rows.slice().sort((a,b)=>(getResRowSortKey(a,RES_SORT_COL) - getResRowSortKey(b,RES_SORT_COL)) * dir);
}
function renderResRows(rowsIn, j){
 updateResSortArrows();
 const rows = applyResClientSort(rowsIn);
 const tb = document.getElementById('rrows'); tb.innerHTML='';
 for(const x of rows){
  const tr=document.createElement('tr');
  tr.innerHTML=`<td>${x.name||'?'}</td>
   <td class=lvl>${x.level}</td>
   <td>${fmtAmount(x.available)}</td>
   <td>${fmtOccupied(x)}</td>
   <td>${x.x}</td><td>${x.y}</td>
   <td>${x.dist!=null?(x.dist+' km'):'-'}</td>
   <td><span class=cp onclick="navigator.clipboard.writeText('${x.x},${x.y}')">${x.x},${x.y} &#8682;</span></td>
   <td class=stat>${fmtAgo(x.age)}</td>`;
  tb.appendChild(tr);
 }
 if(j) renderPager('r', j, gotoPageRes);
}
async function loadResources(keepPage){
 if(!keepPage) rCurPage=1;
 const q = buildQ('r_', {type:'rtype', occupied:'occupied', alliance:'alliance',
   lvmin:'lv_min', lvmax:'lv_max', cx:'cx', cy:'cy', rad:'radius', sort:'sort', lim:'limit'});
 q.set('page', rCurPage);
 const r = await fetch('/api/resources?'+q+'&_='+Date.now(),{cache:'no-store'});
 const j = await r.json();
 rCurPage = j.page;
 _lastResRows = j.rows || [];
 _lastResMeta = j;
 renderResRows(_lastResRows, j);
 document.getElementById('stat').textContent = `${j.total} resources captured`;
}

async function loadRelics(keepPage){
 if(!keepPage) rlCurPage=1;
 const q = buildQ('rl_', {kind:'kind', occupied:'occupied', alliance:'alliance',
   lvmin:'lv_min', lvmax:'lv_max', cx:'cx', cy:'cy', rad:'radius', sort:'sort', lim:'limit'});
 q.set('page', rlCurPage);
 const r = await fetch('/api/relics?'+q+'&_='+Date.now(),{cache:'no-store'});
 const j = await r.json();
 rlCurPage = j.page;
 const tb = document.getElementById('rlrows'); tb.innerHTML='';
 for(const x of j.rows){
  const tr=document.createElement('tr');
  tr.innerHTML=`<td>${x.name||'?'}</td>
   <td><span class=tag>${x.kind}</span></td>
   <td class=lvl>${x.level}</td>
   <td>${fmtPow(x.power)}</td>
   <td>${fmtOccupied(x)}</td>
   <td>${x.x}</td><td>${x.y}</td>
   <td>${x.dist!=null?(x.dist+' km'):'-'}</td>
   <td><span class=cp onclick="navigator.clipboard.writeText('${x.x},${x.y}')">${x.x},${x.y} &#8682;</span></td>
   <td class=stat>${fmtAgo(x.age)}</td>`;
  tb.appendChild(tr);
 }
 renderPager('rl', j, gotoPageRel);
 document.getElementById('stat').textContent = `${j.total} relics/pyramids captured`;
}

let _lastFarmRows = [];
let _farmRendered = [];     // filas en el orden actualmente renderizado (para el popup)
let FARM_ITEM_NAMES = {};   // id -> nombre legible (resuelto por backend desde ITEMCFG)
function itemName(id){ return FARM_ITEM_NAMES[id] || FARM_ITEM_NAMES[String(id)] || ('#'+id); }
// ---- Sort interactivo Farm (client-side, columnas Level/Power/Dist/Score) ----
let FARM_SORT_COL = null;
let FARM_SORT_DIR = 'desc';
// Direccion natural al primer click de cada columna
const FARM_NAT_DIR = {level:'desc', power:'desc', dist:'asc', score:'desc', reward:'desc', vps:'desc'};
function setFarmSort(col){
 if(FARM_SORT_COL === col){
   if(FARM_SORT_DIR === (FARM_NAT_DIR[col]==='desc'?'desc':'asc')) FARM_SORT_DIR = (FARM_NAT_DIR[col]==='desc'?'asc':'desc');
   else { FARM_SORT_COL = null; FARM_SORT_DIR = 'desc'; }
 } else {
   FARM_SORT_COL = col; FARM_SORT_DIR = FARM_NAT_DIR[col] || 'desc';
 }
 updateFarmSortArrows();
 renderFarmRows(_lastFarmRows);
}
function updateFarmSortArrows(){
 for(const c of ['level','power','dist','score','reward','vps']){
   const el = document.getElementById('farm_sorth_'+c);
   if(!el) continue;
   if(c === FARM_SORT_COL){
     el.textContent = FARM_SORT_DIR === 'desc' ? '▼' : '▲';
     el.classList.add('active');
   } else {
     el.textContent = '⇅';
     el.classList.remove('active');
   }
 }
}
function getFarmRowSortKey(row, col){
 if(col === 'level') return +row.level || 0;
 if(col === 'power') return +row.power || 0;
 if(col === 'dist')  return row.dist != null ? +row.dist : Number.MAX_SAFE_INTEGER;
 if(col === 'score') return +row.score || 0;
 if(col === 'reward') return +row.reward_score || 0;
 if(col === 'vps')   return +row.value_per_stam || 0;
 return 0;
}
function applyFarmClientSort(rows){
 if(!FARM_SORT_COL) return rows;
 const dir = FARM_SORT_DIR === 'asc' ? 1 : -1;
 return rows.slice().sort((a,b)=>(getFarmRowSortKey(a,FARM_SORT_COL) - getFarmRowSortKey(b,FARM_SORT_COL)) * dir);
}
function fmtK(n){ n=+n||0; if(n>=1e6) return (n/1e6).toFixed(1)+'M'; if(n>=1e3) return (n/1e3).toFixed(1)+'k'; return ''+Math.round(n); }
function farmRewardTip(x){
 const r=x.reward||{}; const lines=[];
 lines.push('Per-kill (static loot table):');
 lines.push('  exp '+(r.exp||0)+' · honor '+(r.pop||0)+' · credits '+(r.cred||0));
 if(r.items&&r.items.length){
   const gua=r.items.filter(it=>(+it[2]||0)>=100).length;
   lines.push('  items: '+r.items.length+' types ('+gua+' guaranteed), EV '+(x.items_ev||0)+'/kill');
   // top items por prioridad (drop%), con nombre legible
   const top=r.items.slice().sort((a,b)=>(+b[2]||0)-(+a[2]||0)).slice(0,8);
   for(const it of top){
     const pri=+it[2]||0;
     lines.push('    · '+itemName(it[0])+' ×'+(+it[1]||0)+' ('+(pri>=100?'100%':pri+'%')+')');
   }
   if(r.items.length>8) lines.push('    … +'+(r.items.length-8)+' more');
 }
 if(r.horn) lines.push('  horn drop: '+itemName((''+r.horn).split(':')[0]));
 if(r.stam) lines.push('  stamina cost: '+r.stam+'  →  val/stam '+(x.value_per_stam||0));
 const fk=r.fk||{}; const fkres=(+fk.rA||0)+(+fk.rB||0)+(+fk.rC||0)+(+fk.rD||0);
 if((+fk.exp||0)||fkres) lines.push('First-kill (one-time): exp '+(fk.exp||0)+(fkres?(' · res '+fmtK(fkres)):''));
 const o=x.obs;
 if(o){
   lines.push(''); lines.push('🎯 Observed by W/E ('+o.n+' kills):');
   lines.push('  avg exp '+o.exp_avg+' · avg troop loss '+fmtK(o.cost_avg));
   const its=o.items||{}; const ks=Object.keys(its);
   if(ks.length){
     const top=ks.map(k=>[k,+its[k]||0]).sort((a,b)=>b[1]-a[1]).slice(0,8);
     for(const it of top) lines.push('    · '+itemName(it[0])+' ×'+it[1]);
     if(ks.length>8) lines.push('    … +'+(ks.length-8)+' more');
   }
 } else {
   lines.push(''); lines.push('(no real kills observed yet)');
 }
 return lines.join('&#10;');
}
function renderFarmRows(rowsIn){
 updateFarmSortArrows();
 const rows = applyFarmClientSort(rowsIn);
 _farmRendered = rows;   // referencia para el popup (openFarmReward usa el indice de fila)
 const tb = document.getElementById('farmrows'); tb.innerHTML='';
 let i=0;
 for(const x of rows){
  i++;
  const idx=i-1;
  const tr=document.createElement('tr');
  const status = x.busy
    ? `<span class=stat style="color:#fca5a5">rally ${fmtETA(x.eta)}${x.march_count>1?(' ×'+x.march_count):''}</span>`
    : '<span class=stat style="color:#4ade80">free</span>';
  const rtip = farmRewardTip(x);
  const obsMark = x.obs ? ` <span style="color:#fbbf24;font-size:11px" title="real loot observed by W/E">🎯${x.obs.n}</span>` : '';
  tr.innerHTML=`<td class=stat>${i}</td>
   <td><span class=frlink onclick="openFarmReward(${idx})" title="View reward">${x.name||'?'}</span></td>
   <td class=lvl>${x.level}</td>
   <td>${fmtPow(x.power)}</td>
   <td><span class=tag>${x.group}</span></td>
   <td>${x.x}</td><td>${x.y}</td>
   <td>${x.dist!=null?(x.dist+' km'):'-'}</td>
   <td>${status}</td>
   <td><b>${x.score}</b></td>
   <td title="${rtip}"><span class=frlink onclick="openFarmReward(${idx})"><b>${fmtK(x.reward_score)}</b>${obsMark}</span></td>
   <td title="${rtip}">${x.value_per_stam||0}</td>
   <td><span class=cp onclick="navigator.clipboard.writeText('${x.x},${x.y}')">${x.x},${x.y} &#8682;</span></td>`;
  tb.appendChild(tr);
 }
}
// ===== Farm reward popup (click en nombre o valor de reward) =====
function frEsc(s){ return (''+s).replace(/[&<>"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c])); }
function frPriCls(p){ p=+p||0; return p>=100?'fr-gua':(p>=50?'fr-hi':'fr-lo'); }
function frPriTxt(p){ p=+p||0; return p>=100?'100%':p+'%'; }
function farmRewardHTML(x){
 const r=x.reward||{}; const h=[];
 // Per-kill estatico
 h.push('<div class=fr-sec><h4>Per-kill — static loot table</h4>');
 h.push('<div class=fr-kv>'
   +'<span>exp <b>'+(r.exp||0)+'</b></span>'
   +'<span>honor <b>'+(r.pop||0)+'</b></span>'
   +'<span>credits <b>'+(r.cred||0)+'</b></span>'
   +(r.stam?('<span>stamina <b>'+r.stam+'</b></span>'):'')
   +'<span>val/stam <b>'+(x.value_per_stam||0)+'</b></span>'
   +'</div>');
 const items=(r.items||[]).slice().sort((a,b)=>(+b[2]||0)-(+a[2]||0));
 if(items.length){
   h.push('<div style="color:#7c8595;font-size:11px;margin-bottom:5px">'+items.length+' item types · EV '+(x.items_ev||0)+'/kill</div>');
   h.push('<table class=fr-itbl>');
   for(const it of items){
     h.push('<tr><td>'+frEsc(itemName(it[0]))+'</td><td class=amt>×'+(+it[1]||0)+'</td>'
       +'<td class="pri '+frPriCls(it[2])+'">'+frPriTxt(it[2])+'</td></tr>');
   }
   h.push('</table>');
 } else h.push('<div style="color:#7c8595">No static loot table for this monster.</div>');
 if(r.horn) h.push('<div style="margin-top:7px;color:#94a3b8">horn drop: <b style="color:#cbd5e1">'+frEsc(itemName((''+r.horn).split(':')[0]))+'</b></div>');
 h.push('</div>');
 // First-kill (one-time)
 const fk=r.fk||{}; const fkres=(+fk.rA||0)+(+fk.rB||0)+(+fk.rC||0)+(+fk.rD||0);
 if((+fk.exp||0)||fkres){
   h.push('<div class=fr-sec><h4>First-kill bonus — one-time</h4>'
     +'<div class=fr-kv><span>exp <b>'+(fk.exp||0)+'</b></span>'
     +(fk.pop?('<span>honor <b>'+fk.pop+'</b></span>'):'')
     +(fkres?('<span>resources <b>'+fmtK(fkres)+'</b></span>'):'')
     +'</div><div style="color:#7c8595;font-size:11px">Excluded from per-kill reward (one-time only).</div></div>');
 }
 // Observado por W/E
 const o=x.obs;
 h.push('<div class="fr-sec fr-obs"><h4>🎯 Observed by W/E (real calibration)</h4>');
 if(o){
   h.push('<div class=fr-kv><span>kills <b>'+o.n+'</b></span>'
     +'<span>avg exp <b>'+o.exp_avg+'</b></span>'
     +'<span>avg troop loss <b>'+fmtK(o.cost_avg)+'</b></span></div>');
   const its=o.items||{}; const ks=Object.keys(its);
   if(ks.length){
     const sorted=ks.map(k=>[k,+its[k]||0]).sort((a,b)=>b[1]-a[1]);
     h.push('<table class=fr-itbl>');
     for(const it of sorted) h.push('<tr><td>'+frEsc(itemName(it[0]))+'</td><td class=amt>×'+it[1]+'</td><td class=pri></td></tr>');
     h.push('</table>');
   } else h.push('<div style="color:#7c8595">No items recorded in observed kills.</div>');
 } else {
   h.push('<div style="color:#7c8595">No kills observed yet by W/E. Calibration will appear after the first kill.</div>');
 }
 h.push('</div>');
 h.push('<div class=fr-note>Per-kill reward = exp + honor + credits + item EV (amount×priority). '
   +'First-kill bonus (resources) is excluded because it is one-time. 🎯 = real observed loot.</div>');
 return h.join('');
}
function openFarmReward(idx){
 const x=_farmRendered[idx]; if(!x) return;
 document.getElementById('farmTitle').textContent=(x.name||'?')+'  ·  Lv'+x.level;
 document.getElementById('farmBody').innerHTML=farmRewardHTML(x);
 document.getElementById('farmModal').style.display='flex';
}
function closeFarmReward(){ document.getElementById('farmModal').style.display='none'; }
async function loadFarm(){
 const q = buildQ('farm_', {groups:'groups', lvmin:'lv_min', lvmax:'lv_max',
   cx:'cx', cy:'cy', rad:'radius', sort:'sort', lim:'limit'});
 q.set('only_free', document.getElementById('farm_free').checked ? '1' : '0');
 const r = await fetch('/api/farm?'+q+'&_='+Date.now(),{cache:'no-store'});
 const j = await r.json();
 FARM_ITEM_NAMES = j.item_names || {};
 _lastFarmRows = j.rows||[];
 renderFarmRows(_lastFarmRows);
 const lbl = `${j.total} farm targets`;
 document.getElementById('farm_stat').textContent = lbl;
 document.getElementById('stat').textContent = lbl;
}
function copyFarmRows(){
 if(!_lastFarmRows.length){ alert('No targets to copy'); return; }
 const txt = _lastFarmRows.map(x=>`${x.x},${x.y} ${x.name} L${x.level} (${x.dist!=null?(x.dist+' km'):'-'})`).join('\\n');
 navigator.clipboard.writeText(txt).then(()=>{
  const m=document.getElementById('farmCopyMsg');
  if(m){ m.style.display='inline'; clearTimeout(window.__farmMsgT); window.__farmMsgT=setTimeout(()=>{m.style.display='none';},5000); }
 }).catch(e=>alert('Copy failed: '+e));
}

async function loadArctic(keepPage){
 if(!keepPage) arCurPage=1;
 const q = buildQ('ar_', {occupied:'occupied', alliance:'alliance',
   lvmin:'lv_min', lvmax:'lv_max', cx:'cx', cy:'cy', rad:'radius', sort:'sort', lim:'limit'});
 q.set('page', arCurPage);
 const r = await fetch('/api/arctic?'+q+'&_='+Date.now(),{cache:'no-store'});
 const j = await r.json();
 arCurPage = j.page;
 const tb = document.getElementById('arrows'); tb.innerHTML='';
 for(const x of j.rows){
  const tr=document.createElement('tr');
  tr.innerHTML=`<td>${x.name||'?'}</td>
   <td><span class=tag>${x.kind}</span></td>
   <td class=lvl>${x.level}</td>
   <td>${fmtPow(x.power)}</td>
   <td>${fmtOccupied(x)}</td>
   <td>${x.x}</td><td>${x.y}</td>
   <td>${x.dist!=null?(x.dist+' km'):'-'}</td>
   <td><span class=cp onclick="navigator.clipboard.writeText('${x.x},${x.y}')">${x.x},${x.y} &#8682;</span></td>
   <td class=stat>${fmtAgo(x.age)}</td>`;
  tb.appendChild(tr);
 }
 renderPager('ar', j, gotoPageArc);
 document.getElementById('stat').textContent = `${j.total} arctic barbarians captured`;
}

// Color del badge de quality (color del enum del juego)
const QUALITY_COLOR = {
  white:'#cbd5e1', green:'#34d399', blue:'#60a5fa',
  purple:'#a78bfa', gold:'#fbbf24', red:'#f87171'
};
function fmtQuality(q){
  const c = QUALITY_COLOR[q] || '#cbd5e1';
  const lbl = q ? q.charAt(0).toUpperCase()+q.slice(1) : '?';
  return `<span class=tag style="color:${c};border-color:${c}66">${lbl}</span>`;
}
// ---- Sort columnas Subcities ----
let SC_SORT_COL = null;
let SC_SORT_DIR = 'desc';
let _lastScRows = [];
let _lastScMeta = null;

function setScSort(col){
 if(SC_SORT_COL === col){
   if(SC_SORT_DIR === 'desc') SC_SORT_DIR = 'asc';
   else { SC_SORT_COL = null; SC_SORT_DIR = 'desc'; }
 } else {
   SC_SORT_COL = col; SC_SORT_DIR = 'desc';
 }
 updateScSortArrows();
 if(_lastScRows.length) renderSubcityRows(_lastScRows, _lastScMeta);
}
function updateScSortArrows(){
 for(const c of ['quality','culture','level','power','occupied','bubble','dist','discovered']){
   const el = document.getElementById('sc_sorth_'+c);
   if(!el) continue;
   if(c === SC_SORT_COL){
     el.textContent = SC_SORT_DIR === 'desc' ? '▼' : '▲';
     el.classList.add('active');
   } else {
     el.textContent = '⇅';
     el.classList.remove('active');
   }
 }
}
// Quality y Culture suelen tener orden semántico (común→raro). Mapeo manual:
const SC_QUALITY_ORDER = {'common':1,'uncommon':2,'rare':3,'epic':4,'legendary':5,'mythic':6,'divine':7};
function getScRowSortKey(row, col){
 if(col === 'quality') {
   const lbl = (row.quality_label||'').toLowerCase();
   return SC_QUALITY_ORDER[lbl] || 0;
 }
 if(col === 'culture')   return (row.culture_label||'').toLowerCase();
 if(col === 'level')     return +row.level || 0;
 if(col === 'power')     return +row.power || 0;
 if(col === 'occupied') {
   // Free (NPC, sin owner) primero asc. Con owner suma por tag.
   const owner = row.owner_uid || 0;
   if(!owner) return 0;
   return (row.owner_tag||'zz').toLowerCase().charCodeAt(0) * 1e6 + owner;
 }
 if(col === 'bubble') {
   // Similar a Players: ETA > tier > none
   const e = +row.shield_eta || 0;
   const t = +row.shield_tier || 0;
   if(e > 0) return e + 1e9;
   if(t > 0) return t * 1000;
   return 0;
 }
 if(col === 'dist')      return row.dist != null ? +row.dist : Number.MAX_SAFE_INTEGER;
 if(col === 'discovered') return +row.age || 0;
 return 0;
}
function applyScClientSort(rows){
 if(!SC_SORT_COL) return rows;
 const dir = SC_SORT_DIR === 'asc' ? 1 : -1;
 return rows.slice().sort((a,b)=>{
   const av = getScRowSortKey(a, SC_SORT_COL);
   const bv = getScRowSortKey(b, SC_SORT_COL);
   if(typeof av === 'string') return av.localeCompare(bv) * dir;
   return (av - bv) * dir;
 });
}
function renderSubcityRows(rowsIn, j){
 updateScSortArrows();
 const rows = applyScClientSort(rowsIn);
 const tb = document.getElementById('screws'); tb.innerHTML='';
 for(const x of rows){
  const tr=document.createElement('tr');
  // Bubble: misma logica que tab Players. tier (heredado del owner) o ETA si lo tenemos.
  let sh;
  if (x.shield_eta && x.shield_eta>0) {
    sh = `<span class=sh title="Exact ETA via owner's shield">YES · ${fmtLeft(x.shield_eta)}</span>`;
  } else if (x.shield_tier === 2) {
    sh = `<span class=sh title="Owner has newbie shield (7d). Subcity inherits the bubble">NEWBIE</span>`;
  } else if (x.shield_tier === 1) {
    sh = `<span class=sh title="Owner has item shield active. Subcity inherits the bubble">BUBBLE</span>`;
  } else {
    sh = '<span class=nosh>no</span>';
  }
  let pw;
  if (+x.power > 0) {
    pw = `<span title="Exact subcity power from client (GetPower calc)">${fmtPow(x.power)}</span>`;
  } else if (+x.owner_power > 0) {
    // Power de la subcity no disponible (requiere scout); mostramos el power TOTAL del monarca
    // dueño como referencia, en gris + tilde para indicar que es aproximado/del dueño.
    pw = `<span class=stat style="color:#94a3b8" title="Subcity power needs scout. Showing OWNER's total power (from power ranking) as reference.">~${fmtPow(x.owner_power)} <small style="opacity:.6">owner</small></span>`;
  } else {
    pw = `<span class=stat title="Player-owned subcity power not exposed in broadcast — requires scout report (CLV 19+)">—</span>`;
  }
  const culture = x.culture_label
    ? `<span class=tag>${x.culture_label}</span>`
    : '<span class=stat>—</span>';
  tr.innerHTML=`<td>${x.name||'?'}</td>
   <td>${fmtQuality(x.quality_label)}</td>
   <td>${culture}</td>
   <td class=lvl>${x.level}</td>
   <td>${pw}</td>
   <td>${fmtOccupied(x)}</td>
   <td>${sh}</td>
   <td>${x.x}</td><td>${x.y}</td>
   <td>${x.dist!=null?(x.dist+' km'):'-'}</td>
   <td><span class=cp onclick="navigator.clipboard.writeText('${x.x},${x.y}')">${x.x},${x.y} &#8682;</span></td>
   <td class=stat>${fmtAgo(x.age)}</td>`;
  tb.appendChild(tr);
 }
 if(j) renderPager('sc', j, gotoPageSc);
}
async function loadSubcities(keepPage){
 if(!keepPage) scCurPage=1;
 const q = buildQ('sc_', {quality:'quality', culture:'culture', occupied:'occupied',
   alliance:'alliance', lvmin:'lv_min', lvmax:'lv_max',
   cx:'cx', cy:'cy', rad:'radius', sort:'sort', lim:'limit'});
 q.set('page', scCurPage);
 const r = await fetch('/api/subcities?'+q+'&_='+Date.now(),{cache:'no-store'});
 const j = await r.json();
 scCurPage = j.page;
 _lastScRows = j.rows || [];
 _lastScMeta = j;
 renderSubcityRows(_lastScRows, j);
 document.getElementById('stat').textContent = `${j.total} subcities captured`;
}

async function tick(){
 const auto = document.getElementById('auto').checked;
 if(TAB==='quick'){ if(Date.now()-_qLastTick>=12000) await loadQuick(); return; }   // refresco de contadores CADA 12s aunque auto esté OFF: evita pills fantasma (cuenta cacheada vieja) cuando un evento termina
 if(TAB==='pl'){  if(auto) await loadPlayers();   return; }
 if(TAB==='svs'){ if(auto) await svsRefresh();    return; }
 if(TAB==='res'){ if(auto) await loadResources(true); return; }
 if(TAB==='farm'){ if(auto) await loadFarm(); return; }
 if(TAB==='rel'){ if(auto) await loadRelics(true);    return; }
 if(TAB==='arc'){ if(auto) await loadArctic(true);    return; }
 if(TAB==='sc'){  if(auto) await loadSubcities(true); return; }
 if(TAB==='rs'){  if(auto) await loadRespawns();        return; }
 if(TAB==='rl'){  if(auto) await loadRelocations();     return; }
 if(TAB==='wl'){  if(auto) await loadWatchlistView();   return; }
 if(TAB==='proto'){ if(auto) await loadProtoStats();    return; }
 if(TAB==='intel'){ if(auto){ await loadKingdomIntel(); await loadPvpStats(); } return; }
 await loadCatalog();await loadFamilies();
 if(auto) await load(true);   // auto-refresh respeta la pagina actual
 if(SHOWATK) await loadAttacks();
}
// init: toggle de ataques (estado persistente)
(function(){const cb=document.getElementById('showatk');cb.checked=SHOWATK;cb.addEventListener('change',e=>toggleAtk(e.target.checked));if(SHOWATK){loadAttacks();loadFocus();}})();
/* [respawns-disabled] loadFamiliesRs() retirado del boot (tab Respawns oculto) */
switchTab('quick');Promise.all([loadCatalog(),loadFamilies(),loadWatchlist()]).then(()=>{updateWatchlistCount();load();});setInterval(tick,4000);
// Re-sync watchlist UID set cada 15s (por si se modifica desde otra pestaña/cliente)
setInterval(()=>{loadWatchlist().then(updateWatchlistCount);}, 15000);
</script></body></html>"""

# ============================================================================
# AUTH: login de usuarios para acceder a la UI.
#   - Passwords hasheadas con PBKDF2-HMAC-SHA256 (stdlib, sin deps externas).
#   - Sesiones via cookie FIRMADA (HMAC con secret_key) -> stateless: sobrevive
#     restarts del backend mientras users.json conserve el secret_key.
#   - Roles:
#       "superadmin" -> acceso a TODO.
#       "admin"      -> todo EXCEPTO Pause/Restart Scanners (POST /api/scanner).
#   - El control de acceso se aplica TANTO en la UI (ocultar botones) como en el
#     backend (rechazar POST /api/scanner si no es superadmin), de modo que un
#     admin no pueda saltarse el gate llamando a la API directamente.
# ============================================================================
import hashlib, hmac, base64, secrets

USERS_FILE  = os.path.join(HERE, "users.json")
INITPW_FILE = os.path.join(HERE, "users_initial_passwords.txt")
SESSION_TTL = 30 * 86400        # 30 dias
MAX_SESSIONS_PER_USER = 10      # sesiones CONCURRENTES por usuario (móvil + PC + ... a la vez): entrar en un dispositivo NO expulsa a los otros
SESSION_COOKIE = "iscout_session"
_AUTH = {"secret": "", "users": {}}     # cargado/seed-eado de USERS_FILE

# --- Rate-limit del login (anti fuerza-bruta + anti CPU-DoS del PBKDF2) ---
# La UI es publica (dominio VPS -> Tailscale -> Mac), asi que limitamos
# intentos fallidos por IP REAL del visitante (X-Real-IP / X-Forwarded-For),
# no por la IP del reverse-proxy. El chequeo se hace ANTES del PBKDF2.
LOGIN_MAX_FAILS = 5            # intentos fallidos permitidos por ventana
LOGIN_WINDOW_S  = 300         # ventana de 5 min (y duracion del bloqueo)
LOGIN_FAILS     = {}          # ip -> [timestamps de fallos recientes]
LOGIN_FAIL_LOCK = threading.Lock()

def _login_blocked(ip):
    """True si la IP supero LOGIN_MAX_FAILS dentro de la ventana.
    Devuelve (blocked, retry_after_segundos)."""
    now = time.time()
    with LOGIN_FAIL_LOCK:
        fails = [t for t in LOGIN_FAILS.get(ip, []) if now - t < LOGIN_WINDOW_S]
        if fails:
            LOGIN_FAILS[ip] = fails
        else:
            LOGIN_FAILS.pop(ip, None)
        if len(fails) >= LOGIN_MAX_FAILS:
            retry = int(LOGIN_WINDOW_S - (now - fails[0])) + 1
            return True, max(1, retry)
        return False, 0

def _login_record_fail(ip):
    now = time.time()
    with LOGIN_FAIL_LOCK:
        fails = [t for t in LOGIN_FAILS.get(ip, []) if now - t < LOGIN_WINDOW_S]
        fails.append(now)
        LOGIN_FAILS[ip] = fails

def _login_clear(ip):
    with LOGIN_FAIL_LOCK:
        LOGIN_FAILS.pop(ip, None)

def _pbkdf2(password, salt_hex):
    return hashlib.pbkdf2_hmac("sha256", password.encode(), bytes.fromhex(salt_hex), 200_000).hex()

def _make_user(password, role):
    salt = secrets.token_hex(16)
    return {"salt": salt, "hash": _pbkdf2(password, salt), "role": role}

def _seed_users():
    """Crea users.json con los usuarios iniciales y passwords (random para admins).
    El SuperAdmin BEGR usa la password fijada por el usuario. Los 5 admins reciben
    passwords aleatorias que se escriben EN CLARO una sola vez en INITPW_FILE para
    poder entregarselas al usuario; este fichero debe borrarse tras leerlo."""
    global _AUTH
    admins = ["Niteman", "Versius", "Sweetwilly", "Alice", "Steeler"]
    plain = {"BEGR": "Superbe1!"}
    users = {"BEGR": _make_user("Superbe1!", "superadmin")}
    for name in admins:
        pw = secrets.token_urlsafe(9)        # ~12 chars, alfanumerico + -_
        plain[name] = pw
        users[name] = _make_user(pw, "admin")
    # "sessions": uid -> session id activa (single-session: solo 1 sesion por usuario).
    _AUTH = {"secret": secrets.token_hex(32), "users": users, "sessions": {}}
    with open(USERS_FILE, "w") as f:
        json.dump(_AUTH, f, indent=2)
    try: os.chmod(USERS_FILE, 0o600)
    except Exception: pass
    # Volcado de passwords iniciales en claro (para entregar y luego borrar).
    lines = ["# Passwords iniciales de iScout V3 — BORRA este fichero tras anotarlas.\n",
             "# (Las passwords NO se guardan en claro en ningun otro sitio.)\n\n"]
    lines.append(f"{'BEGR':<12} superadmin  {plain['BEGR']}\n")
    for name in admins:
        lines.append(f"{name:<12} admin       {plain[name]}\n")
    with open(INITPW_FILE, "w") as f:
        f.writelines(lines)
    try: os.chmod(INITPW_FILE, 0o600)
    except Exception: pass
    print(f"[auth] users.json creado ({len(users)} usuarios). Passwords iniciales en {INITPW_FILE}", flush=True)

def load_users():
    """Carga users.json; si falta o esta corrupto, hace seed inicial."""
    global _AUTH
    if os.path.exists(USERS_FILE):
        try:
            with open(USERS_FILE) as f:
                data = json.load(f)
            if data.get("secret") and isinstance(data.get("users"), dict) and data["users"]:
                data.setdefault("sessions", {})    # compat con users.json previos
                _AUTH = data
                print(f"[auth] users.json cargado ({len(_AUTH['users'])} usuarios)", flush=True)
                return
        except Exception as e:
            print(f"[auth] users.json ilegible ({e}); regenerando", flush=True)
    _seed_users()

def save_users():
    """Persiste _AUTH (incluye sessions activas) en users.json.
    ALTO#2: escritura ATOMICA (tmp + os.replace) bajo USERS_LOCK. Antes era un
    open('w') directo, sin lock -> dos requests concurrentes (login / logout /
    flush de last_seen) intercalaban la escritura y dejaban users.json TRUNCADO;
    al reiniciar, load_users caia al except y hacia _seed_users() = RESET de todas
    las contrasenas + secret nuevo (bloqueo total). El rename es atomico y el lock
    serializa a los escritores (evita que 2 corrompan el mismo .tmp)."""
    try:
        with USERS_LOCK:
            tmp = USERS_FILE + ".tmp"
            with open(tmp, "w") as f:
                json.dump(_AUTH, f, indent=2)
            os.chmod(tmp, 0o600)
            os.replace(tmp, USERS_FILE)
    except Exception as e:
        print(f"[auth] no se pudo guardar users.json: {e}", flush=True)

# ── Preferencias de notificaciones POR USUARIO (persistidas en users.json) ──
# Cada usuario decide sus propias preferencias; no afectan a los demás. master=switch
# global del usuario. browser=OFF por defecto (requiere permiso del navegador).
NOTIF_DEFAULTS = {"master": True, "rally": True, "solo": True, "bubbles": True,
                  "enemy": True, "ares": True, "sound": True, "browser": False,
                  "spawns": True,         # spawns = aviso de boss/event NUEVO en el mapa (summons)
                  "send_coords": True}    # send_coords = visibilidad del panel 📍 SEND COORDS (UI pref, ON por defecto para todos)
def get_notif_prefs(username):
    rec = _AUTH.get("users", {}).get(username) or {}
    saved = rec.get("notif") or {}
    p = dict(NOTIF_DEFAULTS)
    for k in NOTIF_DEFAULTS:
        if k in saved:
            p[k] = bool(saved[k])
    return p
def set_notif_prefs(username, updates):
    rec = _AUTH.get("users", {}).get(username)
    if not rec or not isinstance(updates, dict):
        return None
    cur = dict(rec.get("notif") or {})
    for k, v in updates.items():
        if k in NOTIF_DEFAULTS:
            cur[k] = bool(v)
    rec["notif"] = cur
    save_users()
    return get_notif_prefs(username)

def get_quick_prefs(username):
    """Prefs de Shortcuts POR USUARIO (server-side): selecciones (QSEL) + ajustes del Menu (hive/sort/results).
    'saved'=False cuando el usuario aún no tiene nada guardado (la UI migra su localStorage la 1ª vez)."""
    rec = _AUTH.get("users", {}).get(username) or {}
    q = rec.get("quick")
    if not isinstance(q, dict):
        return {"saved": False, "sel": {}, "prefs": {}}
    sel = q.get("sel") if isinstance(q.get("sel"), dict) else {}
    prefs = q.get("prefs") if isinstance(q.get("prefs"), dict) else {}
    try: n = int(prefs.get("n") or 40)
    except Exception: n = 40
    return {"saved": True,
            "sel": {str(k): True for k, v in sel.items() if v},
            "prefs": {"hx": str(prefs.get("hx", ""))[:16], "hy": str(prefs.get("hy", ""))[:16],
                      "sort": ("power" if prefs.get("sort") == "power" else "distance"),
                      "n": max(1, min(200, n))}}
def set_quick_prefs(username, data):
    rec = _AUTH.get("users", {}).get(username)
    if not rec or not isinstance(data, dict):
        return None
    q = dict(rec.get("quick") or {})
    if isinstance(data.get("sel"), dict):
        q["sel"] = {str(k): True for k, v in list(data["sel"].items())[:400] if v}   # cap defensivo
    if isinstance(data.get("prefs"), dict):
        p = data["prefs"]
        try: n = int(p.get("n") or 40)
        except Exception: n = 40
        q["prefs"] = {"hx": str(p.get("hx", ""))[:16], "hy": str(p.get("hy", ""))[:16],
                      "sort": ("power" if p.get("sort") == "power" else "distance"),
                      "n": max(1, min(200, n))}
    rec["quick"] = q
    save_users()
    return get_quick_prefs(username)

def _sign(token):
    return hmac.new(_AUTH["secret"].encode(), token.encode(), hashlib.sha256).hexdigest()

def _session_sid(entry):
    """Extrae el sid de una entrada de sesion. Compat: las sesiones antiguas eran
    un string (el sid pelado); las nuevas son un dict con metadatos."""
    if isinstance(entry, dict):
        return entry.get("sid")
    return entry

def _sessions_list(entry):
    """Normaliza la entrada de sesiones de UN usuario a una LISTA de dicts. Compat con
    formatos viejos: None / str(sid pelado) / dict(1 sesión, single-session) / list(multi, actual)."""
    if not entry: return []
    if isinstance(entry, list): return [e for e in entry if e]
    if isinstance(entry, dict): return [entry]
    if isinstance(entry, str): return [{"sid": entry}]
    return []

# last_seen vivo en memoria (se actualiza en CADA request) + persistido en users.json
# con throttle (como mucho 1 escritura/LAST_SEEN_PERSIST_S por usuario) para que el
# "ultimo acceso" sobreviva a logout/reinicio sin machacar el disco. "online ahora" =
# visto hace < SESSION_ONLINE_S.
SESSION_LAST_SEEN  = {}           # username -> timestamp del ultimo request autenticado (memoria)
SESSION_ONLINE_S   = 90           # umbral para considerar a un usuario "conectado ahora"
LAST_SEEN_PERSIST_S = 60          # frecuencia maxima de persistencia de last_seen a users.json

def _maybe_persist_last_seen(username, now):
    """Persiste rec['last_seen'] a users.json como mucho cada LAST_SEEN_PERSIST_S."""
    rec = _AUTH.get("users", {}).get(username)
    if not rec:
        return
    last = rec.get("last_seen", 0) or 0
    if now - last >= LAST_SEEN_PERSIST_S:
        rec["last_seen"] = int(now)
        save_users()

def make_session_cookie(username, ip=None, ua=None):
    """Crea una sesion NUEVA para username SIN invalidar las de otros dispositivos
    (MULTI-SESIÓN: móvil + PC a la vez). El sid se AÑADE a la lista de sesiones del
    usuario; verify_session acepta la cookie si su sid está en esa lista. Antes era
    single-session (sobrescribía) -> entrar desde el móvil deslogueaba el PC y viceversa."""
    sid = secrets.token_hex(8)
    now = int(time.time())
    with USERS_LOCK:   # ALTO#2: lectura-modificacion-escritura de la lista de sesiones + guardado, atomico (evita perder una sesion por carrera entre 2 logins)
        sess = _AUTH.setdefault("sessions", {})
        lst = [s for s in _sessions_list(sess.get(username))
               if now - int(s.get("login_at", 0) or 0) < SESSION_TTL]      # poda las expiradas
        lst.append({"sid": sid, "login_at": now, "ip": ip or "", "ua": (ua or "")[:200]})
        sess[username] = lst[-MAX_SESSIONS_PER_USER:]                       # cap: conserva las más recientes
        SESSION_LAST_SEEN[username] = time.time()
        save_users()
    exp = now + SESSION_TTL
    token = base64.urlsafe_b64encode(f"{username}|{exp}|{sid}".encode()).decode()
    return f"{token}.{_sign(token)}"

def end_session(username, sid=None):
    """Cierra sesión de username. sid dado -> solo ESA sesión (logout de ESTE dispositivo,
    deja vivas las de los demás). sid None -> TODAS las del usuario (kick del admin)."""
    with USERS_LOCK:   # ALTO#2: read-modify-write de sesiones + guardado, atomico
        rec = _AUTH.get("users", {}).get(username)
        sess = _AUTH.get("sessions", {})
        if sid is not None:
            lst = _sessions_list(sess.get(username))
            keep = [s for s in lst if _session_sid(s) != sid]
            if len(keep) == len(lst):
                return                          # ese sid no estaba -> nada que hacer
            if keep:                            # aún le quedan sesiones en otros dispositivos
                sess[username] = keep; save_users(); return
            # era su última sesión -> continúa al cierre total (flush del last_seen)
        seen = SESSION_LAST_SEEN.pop(username, None)
        if rec and seen:
            rec["last_seen"] = int(seen)   # flush final del ultimo acceso
        removed = sess.pop(username, None) is not None
        if removed or (rec and seen):
            save_users()

def verify_session(cookie_val):
    """Devuelve (username, role) si la cookie es valida, no expirada Y su sid coincide
    con la sesion activa del usuario (single-session). Si no, None."""
    if not cookie_val or "." not in cookie_val:
        return None
    token, sig = cookie_val.rsplit(".", 1)
    if not hmac.compare_digest(sig, _sign(token)):
        return None
    try:
        payload = base64.urlsafe_b64decode(token.encode()).decode()
        rest, sid = payload.rsplit("|", 1)
        username, exp = rest.rsplit("|", 1)
        if int(exp) < time.time():
            return None
    except Exception:
        return None
    u = _AUTH["users"].get(username)
    if not u:
        return None
    # Multi-sesión: el sid de la cookie debe estar entre las sesiones ACTIVAS del usuario.
    # (Entrar en otro dispositivo ya NO invalida esta; solo kick/logout la sacan de la lista.)
    if sid not in {_session_sid(s) for s in _sessions_list(_AUTH.get("sessions", {}).get(username))}:
        return None
    return (username, u["role"])

def _cookie_sid(cookie_val):
    """Extrae el sid de una cookie de sesión SIN validar la firma (solo para el logout
    puntual de ESTE dispositivo). Devuelve None si no se puede parsear."""
    try:
        token = cookie_val.rsplit(".", 1)[0]
        payload = base64.urlsafe_b64decode(token.encode()).decode()
        return payload.rsplit("|", 1)[1]
    except Exception:
        return None

LOGIN_HTML = """<!DOCTYPE html><html lang=es><head><meta charset=utf-8>
<meta name=viewport content="width=device-width,initial-scale=1">
<title>Scout V5 — Login</title>
<style>
 *{box-sizing:border-box}
 body{margin:0;height:100vh;display:flex;align-items:center;justify-content:center;
   background:#0b1220;color:#e6e6e6;font:15px/1.4 system-ui,-apple-system,Segoe UI,Roboto,sans-serif}
 .card{background:#111a2b;border:1px solid #1e293b;border-radius:12px;padding:30px 28px;
   width:320px;box-shadow:0 12px 40px rgba(0,0,0,.5)}
 h1{margin:0 0 4px;font-size:20px;color:#fbbf24}
 p.sub{margin:0 0 22px;font-size:13px;color:#94a3b8}
 label{display:block;font-size:12px;color:#94a3b8;margin:14px 0 5px}
 input{width:100%;padding:10px 12px;border:1px solid #243049;border-radius:7px;
   background:#0b1220;color:#e6e6e6;font-size:14px}
 input:focus{outline:none;border-color:#3b82f6}
 button{width:100%;margin-top:22px;padding:11px;border:0;border-radius:7px;cursor:pointer;
   background:#2563eb;color:#fff;font-size:15px;font-weight:600}
 button:hover{background:#1d4ed8}
 button:disabled{opacity:.6;cursor:default}
 .err{margin-top:14px;font-size:13px;color:#f87171;min-height:18px}
</style></head><body>
<form class=card id=f onsubmit="return doLogin(event)">
 <label for=u>Username</label>
 <input id=u name=u autocomplete=username autofocus required>
 <label for=p>Password</label>
 <input id=p name=p type=password autocomplete=current-password required>
 <button id=btn type=submit>Sign in</button>
 <div class=err id=err></div>
</form>
<script>
async function doLogin(e){
 e.preventDefault();
 const btn=document.getElementById('btn'), err=document.getElementById('err');
 err.textContent=''; btn.disabled=true; btn.textContent='Signing in…';
 try{
   const r=await fetch('/api/login',{method:'POST',headers:{'Content-Type':'application/json'},
     body:JSON.stringify({username:document.getElementById('u').value,
                          password:document.getElementById('p').value})});
   if(r.ok){ location.href='/'; return false; }
   err.textContent = r.status===401 ? 'Invalid username or password.' : ('Error '+r.status);
 }catch(ex){ err.textContent='Could not connect.'; }
 btn.disabled=false; btn.textContent='Sign in';
 return false;
}
</script></body></html>"""

class H(BaseHTTPRequestHandler):
    def log_message(self, *a): pass
    def _send(self, code, body, ctype="application/json"):
        b = body.encode() if isinstance(body, str) else body
        self.send_response(code)
        self.send_header("Content-Type", ctype)
        self.send_header("Content-Length", str(len(b)))
        self.send_header("Cache-Control", "no-store, no-cache, must-revalidate")
        self.send_header("Pragma", "no-cache")
        self.end_headers()
        self.wfile.write(b)
    def _cookies(self):
        out = {}
        for part in (self.headers.get("Cookie", "") or "").split(";"):
            if "=" in part:
                k, v = part.strip().split("=", 1)
                out[k] = v
        return out

    def _current_user(self):
        u = verify_session(self._cookies().get(SESSION_COOKIE, ""))
        if u:
            # Marca de actividad: memoria (online/idle) + persistencia throttled.
            now = time.time()
            SESSION_LAST_SEEN[u[0]] = now
            _maybe_persist_last_seen(u[0], now)
        return u

    def _client_ip(self):
        """IP real del visitante detras del reverse-proxy.
        El nginx del VPS pone X-Real-IP = $remote_addr (sobrescribe lo que mande
        el cliente, asi que es de fiar) y X-Forwarded-For. El socket peer seria la
        IP Tailscale del VPS, inutil para rate-limit. Fallback al peer si no hay proxy."""
        xri = self.headers.get("X-Real-IP", "").strip()
        if xri:
            return xri
        xff = self.headers.get("X-Forwarded-For", "").strip()
        if xff:
            return xff.split(",")[0].strip()
        return self.client_address[0]

    def do_GET(self):
        from urllib.parse import urlparse, parse_qs
        u = urlparse(self.path)
        # ---- AUTH gate ----
        user = self._current_user()
        if u.path in ("/login", "/login.html"):
            return self._send(200, LOGIN_HTML, "text/html; charset=utf-8")
        if u.path == "/api/me":
            if user:
                return self._send(200, json.dumps({"user": user[0], "role": user[1]}))
            return self._send(401, json.dumps({"error": "not_authenticated"}))
        if not user:
            # Sin sesion: la raiz muestra el login; cualquier API responde 401.
            if u.path in ("/", "/index.html"):
                return self._send(200, LOGIN_HTML, "text/html; charset=utf-8")
            return self._send(401, json.dumps({"error": "not_authenticated"}))
        # ---- a partir de aqui: usuario autenticado ----
        if u.path in ("/readme", "/readme.html"):
            return self._send(200, README_HTML, "text/html; charset=utf-8")
        if u.path == "/api/enemy_config":
            now = time.time()
            with LOCK:
                ours, foreign = foreign_breakdown(now)
            enemy_sv = effective_enemy_server(foreign)   # override o dominante (umbral)
            svs_active = enemy_sv > 0
            # Solo mostramos la lista de servers extranjeros si hay SVS activo
            # (server dominante sobre el umbral) o un override puesto; si no, vacío
            # para no distraer con inmigrantes sueltos del día a día.
            foreign_list = ([{"server": s, "count": c} for s, c in
                             sorted(foreign.items(), key=lambda kv: -kv[1])]
                            if (svs_active or ENEMY_SERVER_OVERRIDE > 0) else [])
            return self._send(200, json.dumps({
                "our_server": ours,
                "enemy_server": ENEMY_SERVER_OVERRIDE,
                "effective_enemy": enemy_sv,
                "svs_active": svs_active,
                "mode": ("override" if ENEMY_SERVER_OVERRIDE > 0 else "auto"),
                "min_players": SVS_MIN_ENEMY_PLAYERS,
                "enemy_tags": sorted(ENEMY_GUILD_TAGS),
                "enemy_gids": sorted(ENEMY_GUILD_IDS),
                "foreign_servers": foreign_list,
            }))
        if u.path == "/api/focus":
            # estado del Focus Zone + (si hay tag) cuántos jugadores de esa alianza vemos.
            # Si hay tag, devolvemos el CENTROIDE EN VIVO de esa alianza como cx/cy (no el
            # valor guardado, que puede ser 0,0 justo tras Apply antes de que el hilo lo
            # recalcule) -> la UI siempre muestra el centro real de la alianza.
            tag = (FOCUS.get("tag") or "").strip().lower()
            tag_n = 0
            cx_out = int(FOCUS.get("cx") or 0); cy_out = int(FOCUS.get("cy") or 0)
            if tag:
                now = time.time(); xs = []; ys = []
                with LOCK:
                    for p in PLAYERS.values():
                        if now - p.get("ts", 0) > TTL_SECONDS: continue
                        if (p.get("tag") or "").strip().lower() != tag: continue
                        tag_n += 1
                        wx = int(p.get("wx", 0) or 0); wy = int(p.get("wy", 0) or 0)
                        if wx or wy: xs.append(wx); ys.append(wy)
                if xs:
                    cx_out = sum(xs) // len(xs); cy_out = sum(ys) // len(ys)
            return self._send(200, json.dumps({
                "active": bool(FOCUS.get("active")), "half": FOCUS.get("half", "W"),
                "cx": cx_out, "cy": cy_out,
                "radius": int(FOCUS.get("radius") or 60), "tag": FOCUS.get("tag", ""),
                "by": FOCUS.get("by", ""), "tag_count": tag_n}))
        if u.path == "/api/scanner_profile":
            # Perfil de escáner activo + disponibles + qué ve cada half ahora mismo.
            live = {h: scan_cfg_for(h) for h in ("W", "E")}
            return self._send(200, json.dumps({
                "active": SCANNER_CFG.get("active", "home"),
                "profiles": SCANNER_CFG.get("profiles", {}),
                "live": live,
                "scanners": {h: sr for sr, h in SCANNERS},
                "revert_to": SCANNER_CFG.get("revert_to"),
                "revert_at": SCANNER_CFG.get("revert_at", 0),
                "now": int(time.time()),
                "detected_server": {"W": DETECTED_SERVER_ID.get("W", 0), "E": DETECTED_SERVER_ID.get("E", 0)}}))
        if u.path == "/api/svs_hitlist":
            # HIT-LIST SVS: enemigos ordenados por "mejor objetivo AHORA" =
            # valor (power) × hittabilidad (burbuja caída / cae pronto) ×
            # que sea real (actividad reciente) × cercanía (si se da centro).
            params = {k: v[0] for k, v in parse_qs(u.query).items()}
            now = time.time()
            def _f(k, d=None):
                v = params.get(k, "")
                try: return float(v) if v not in ("", None) else d
                except: return d
            cx = _f("cx"); cy = _f("cy")
            max_seen = _f("max_seen", TTL_SECONDS)
            soon_s = int(_f("soon", 1800) or 1800)   # umbral "cae pronto" (def 30 min)
            try: limit = int(params.get("limit", "100"))
            except: limit = 100
            state_filter = (params.get("state", "") or "").lower()  # ""|open|drops_soon|shielded
            sort = params.get("sort", "score")
            out = []
            with LOCK:
                _ours, _foreign = foreign_breakdown(now)
                enemy_sv = effective_enemy_server(_foreign)
                ps = list(PLAYERS.values())
                for p in ps:
                    if now - p.get("ts", 0) > max_seen: continue
                    if not is_enemy(p, enemy_sv): continue
                    uid = int(p.get("uid", 0) or 0)
                    wx = int(p.get("wx", 0) or 0); wy = int(p.get("wy", 0) or 0)
                    pw = int(p.get("power", 0) or 0)
                    ppi = PLAYER_POWER.get(uid)
                    if ppi and ppi.get("power", 0) > 0: pw = int(ppi["power"])
                    elif MEMBER_INFO.get(uid, {}).get("power", 0) > 0: pw = int(MEMBER_INFO[uid]["power"])
                    pwM = round(pw / 1e6, 1)
                    tier = int(p.get("shield", 0) or 0)
                    eta_info = SHIELD_ETA.get(uid)
                    sh_eta = 0
                    if eta_info and eta_info.get("end_time", 0) > now:
                        sh_eta = int(eta_info["end_time"] - now)
                    if tier == 0 and sh_eta == 0:
                        state = "open"
                    elif 0 < sh_eta <= soon_s:
                        state = "drops_soon"
                    else:
                        state = "shielded"
                    if state_filter and state != state_filter: continue
                    act = PLAYER_ACT.get(uid)
                    last_act = int(now - act["last"]) if (act and act.get("last")) else None
                    d = None
                    if cx is not None and cy is not None and (wx or wy):
                        d = round(math.hypot(wx - cx, wy - cy))
                    # multiplicadores del score
                    sh_mult = 1.0 if state == "open" else (0.6 if state == "drops_soon" else 0.12)
                    if last_act is None:        act_mult = 0.4   # nunca visto activo -> quizá cuenta muerta
                    elif last_act < 6*3600:     act_mult = 1.0   # online en las últimas 6h
                    elif last_act < 24*3600:    act_mult = 0.7
                    else:                       act_mult = 0.45
                    dist_mult = 1.0 if d is None else (120.0 / (d + 120.0))  # cercanía suave
                    score = round(max(pwM, 0.1) * sh_mult * act_mult * dist_mult, 2)
                    ml = MEMBER_LASTSEEN.get(uid)
                    lastseen = ml if ml is not None else int(p.get("ls", 0) or 0)
                    p_age = int(now - p.get("ts", now))
                    # PRESENCIA: ¿sigue en el campo o se fue a su server?
                    #   field  = avistado físicamente en el mapa hace poco (señal más fuerte)
                    #   online = member-list lo da online (ls<=1) pero sin avistamiento fresco
                    #   gone   = member-list lo da OFFLINE (ls=epoch) y sin avistamiento fresco
                    #   stale  = sin dato de member-list y avistamiento viejo (incierto)
                    PRESENCE_FRESH_S = 240
                    if p_age <= PRESENCE_FRESH_S:
                        presence = "field"; presence_since = None
                    elif ml is not None and ml <= 1:
                        presence = "online"; presence_since = None
                    elif ml and ml > 1:
                        presence = "gone"; presence_since = int(ml)
                    else:
                        presence = "stale"; presence_since = None
                    # relocation reciente (<15 min): info inline para el hit-list
                    reloc = None
                    rl = RELOCATIONS.get(uid)
                    if rl:
                        last = rl[-1]
                        r_age = int(now - last.get("ts", 0))
                        if r_age <= 900:
                            fsrv = int(last.get("from_srv", 0) or 0); tsrv = int(last.get("srv", 0) or 0)
                            reloc = {"age": r_age,
                                     "to": [int(last.get("to_x", 0)), int(last.get("to_y", 0))],
                                     "from": [int(last.get("from_x", 0)), int(last.get("from_y", 0))],
                                     "crossed": bool(tsrv and tsrv == _ours and fsrv and fsrv != _ours)}
                    out.append({
                        "uid": uid, "name": p.get("name", ""), "tag": p.get("tag", ""),
                        "gid": int(p.get("gid", 0) or 0), "sv": int(p.get("sv", 0) or 0),
                        "power": pw, "powerM": pwM, "x": wx, "y": wy, "dist": d,
                        "shield_tier": tier, "shield_eta": sh_eta, "state": state,
                        "last_active": last_act, "lastseen": lastseen, "score": score,
                        "age": p_age, "presence": presence, "presence_since": presence_since,
                        "seen_srv": int(p.get("seen_srv", 0) or 0), "reloc": reloc,
                        "off_window": attack_window(uid, now),   # mejor ventana de ataque
                    })
            if   sort == "power":  out.sort(key=lambda r: -r["power"])
            elif sort == "dist":   out.sort(key=lambda r: (r["dist"] if r["dist"] is not None else 1e9, -r["power"]))
            elif sort == "eta":    out.sort(key=lambda r: (r["shield_eta"] if r["shield_eta"] > 0 else 1e12, -r["power"]))
            elif sort == "active": out.sort(key=lambda r: (r["last_active"] if r["last_active"] is not None else 1e12, -r["power"]))
            else:                  out.sort(key=lambda r: -r["score"])
            total = len(out)
            if limit > 0: out = out[:limit]
            return self._send(200, json.dumps({
                "rows": out, "total": total, "our_server": _ours, "now": int(now),
                "mode": ("override" if ENEMY_SERVER_OVERRIDE > 0 else "auto"),
                "enemy_server": ENEMY_SERVER_OVERRIDE,
                "effective_enemy": enemy_sv, "svs_active": enemy_sv > 0}))
        if u.path == "/api/svs_shields":
            return self._send(200, json.dumps(svs_shields_data()))
        if u.path == "/api/pulse":
            # Llamada ÚNICA que agrega los datasets de las alertas de solo-lectura
            # (burbujas + relocations + Ares) -> el dashboard hace 1 fetch en vez de 3-4,
            # y el backend escanea una sola vez por ciclo. Alivia backend y móvil.
            now = time.time()
            return self._send(200, json.dumps({
                "now": int(now),
                "shields": svs_shields_data(now),
                "relocations": svs_relocations_data(now, 10),
                "ares": ares_sightings_data(now),
                "spawns": spawn_sightings_data(now),
            }))
        if u.path == "/api/svs_relocations":
            params = {k: v[0] for k, v in parse_qs(u.query).items()}
            try: max_age_m = int(params.get("max_age_m", "120"))
            except: max_age_m = 120
            return self._send(200, json.dumps(svs_relocations_data(time.time(), max_age_m)))
        if u.path == "/api/svs_defense":
            # DEFENSA: ataques entrantes hacia castillos de NUESTRA alianza, agrupados
            # por tile-objetivo, ordenados por ETA (lo más inminente primero).
            now = time.time()
            STALE_TH = 120
            ATTACK_MTYS = {10, 19, 20, 21}   # ataque directo + fases de rally (no monster/scout)
            PHASE_NAME = {10: "march", 19: "rally(form)", 20: "march", 21: "combat"}
            tiles = {}
            with LOCK:
                my_guilds = {g for g in (SELF["W"]["guild_id"], SELF["E"]["guild_id"]) if g > 0}
                if not my_guilds:
                    return self._send(200, json.dumps({"rows": [], "total": 0, "my_guilds": []}))
                tile_uid = {(int(p.get("wx", 0) or 0), int(p.get("wy", 0) or 0)): int(p.get("uid", 0) or 0)
                            for p in PLAYERS.values() if int(p.get("wx", 0) or 0)}
                uid_to_gid = {int(p.get("uid", 0) or 0): int(p.get("gid", 0) or 0)
                              for p in PLAYERS.values() if int(p.get("uid", 0) or 0)}
                pl_by_uid = {int(p.get("uid", 0) or 0): p for p in PLAYERS.values() if int(p.get("uid", 0) or 0)}
                # Conjunto ROBUSTO de NUESTRA alianza (member-list + PLAYERS + tags) para
                # excluir con fiabilidad las marchas internas LAN->LAN (refuerzos/ayudas/
                # rallies entre compañeros), aunque el atacante no esté ahora en PLAYERS.
                our_member_uids = set(); our_tags = set()
                for gid in my_guilds:
                    info = GUILD_MEMBERS.get(gid) or {}
                    if info.get("tag"): our_tags.add(info["tag"].strip().lower())
                    for m in info.get("members", []):
                        mu = int(m.get("uid", 0) or 0)
                        if mu: our_member_uids.add(mu)
                for p in PLAYERS.values():
                    if int(p.get("gid", 0) or 0) in my_guilds:
                        pu = int(p.get("uid", 0) or 0)
                        if pu: our_member_uids.add(pu)
                        pt = (p.get("tag") or "").strip().lower()
                        if pt: our_tags.add(pt)
                _ours, _foreign = foreign_breakdown(now)
                enemy_sv = effective_enemy_server(_foreign)
                for (tx, ty), sub in MARCH_BY_TGT.items():
                    tuid = tile_uid.get((tx, ty), 0)
                    tgid = uid_to_gid.get(tuid, 0)
                    if tgid not in my_guilds: continue   # solo objetivos de nuestra alianza
                    for trp in sub.keys():
                        m = MARCHES.get(trp)
                        if not m: continue
                        mty_v = int(m.get("mty", 0))
                        if mty_v not in ATTACK_MTYS: continue
                        te = int(m.get("te", 0))
                        if te <= now: continue
                        ts_recv = float(m.get("ts_recv", 0) or 0)
                        if ts_recv > 0 and (now - ts_recv) > STALE_TH: continue
                        ow = int(m.get("ow", 0) or 0)
                        # EXCLUIR marcha interna de nuestra alianza (refuerzo/ayuda/rally
                        # entre compañeros). Robusto: por gid, por uid en member-list, o por
                        # tag de alianza. Evita falsas "Attack incoming" LAN->LAN.
                        ow_tag = ((pl_by_uid.get(ow) or {}).get("tag") or "").strip().lower()
                        if (uid_to_gid.get(ow, 0) in my_guilds or ow in our_member_uids
                                or (ow_tag and ow_tag in our_tags)):
                            continue
                        if mty_v == 10 and ow > 0 and ow == tuid: continue  # relocation
                        apl = pl_by_uid.get(ow)
                        apw = 0
                        if apl:
                            apw = int(apl.get("power", 0) or 0); ppi = PLAYER_POWER.get(ow)
                            if ppi and ppi.get("power", 0) > 0: apw = int(ppi["power"])
                        eta = max(0, te - now)
                        key = (tx, ty)
                        g = tiles.get(key)
                        if g is None:
                            tpl = pl_by_uid.get(tuid)
                            g = {"x": tx, "y": ty, "tgt_uid": tuid,
                                 "tgt_name": (tpl.get("name", "") if tpl else ""),
                                 "tgt_tag": (tpl.get("tag", "") if tpl else ""),
                                 "count": 0, "eta": eta, "phase": PHASE_NAME.get(mty_v, "?"),
                                 "atk_uid": ow, "atk_name": (apl.get("name", "") if apl else ""),
                                 "atk_tag": (apl.get("tag", "") if apl else ""),
                                 "atk_powerM": round(apw / 1e6, 1),
                                 "atk_enemy": bool(apl and is_enemy(apl, enemy_sv))}
                            tiles[key] = g
                        g["count"] += 1
                        if eta < g["eta"]: g["eta"] = eta            # ETA = la más inminente
                        if apw / 1e6 > g["atk_powerM"]:              # atacante representativo = el más fuerte
                            g.update({"atk_uid": ow, "atk_name": (apl.get("name", "") if apl else ""),
                                      "atk_tag": (apl.get("tag", "") if apl else ""),
                                      "atk_powerM": round(apw / 1e6, 1),
                                      "atk_enemy": bool(apl and is_enemy(apl, enemy_sv))})
            rows = sorted(tiles.values(), key=lambda r: r["eta"])
            return self._send(200, json.dumps({"rows": rows, "total": len(rows),
                                                "my_guilds": sorted(my_guilds)}))
        if u.path == "/api/svs_reinforce":
            # REFUERZOS ENEMIGOS: marchas de ayuda (mismo guild que el objetivo, distinto
            # jugador) que llegan a un castillo ENEMIGO -> ese enemigo se está apilando.
            # NOTA: contamos nº de marchas, NO tropas (la marcha no trae tamaño; la fuerza
            # real de guarnición requiere scout). Útil para no rallear un objetivo reforzado.
            now = time.time(); STALE_TH = 120
            REINF_MTYS = {10, 19, 20, 21}
            tiles = {}
            with LOCK:
                _ours, _foreign = foreign_breakdown(now)
                enemy_sv = effective_enemy_server(_foreign)
                tile_uid = {(int(p.get("wx", 0) or 0), int(p.get("wy", 0) or 0)): int(p.get("uid", 0) or 0)
                            for p in PLAYERS.values() if int(p.get("wx", 0) or 0)}
                uid_to_gid = {int(p.get("uid", 0) or 0): int(p.get("gid", 0) or 0)
                              for p in PLAYERS.values() if int(p.get("uid", 0) or 0)}
                pl_by_uid = {int(p.get("uid", 0) or 0): p for p in PLAYERS.values() if int(p.get("uid", 0) or 0)}
                for (tx, ty), sub in MARCH_BY_TGT.items():
                    tuid = tile_uid.get((tx, ty), 0)
                    tgid = uid_to_gid.get(tuid, 0)
                    tpl = pl_by_uid.get(tuid)
                    if not (tpl and is_enemy(tpl, enemy_sv)): continue   # objetivo debe ser ENEMIGO
                    for trp in sub.keys():
                        m = MARCHES.get(trp)
                        if not m: continue
                        if int(m.get("mty", 0)) not in REINF_MTYS: continue
                        te = int(m.get("te", 0))
                        if te <= now: continue
                        ts_recv = float(m.get("ts_recv", 0) or 0)
                        if ts_recv > 0 and (now - ts_recv) > STALE_TH: continue
                        ow = int(m.get("ow", 0) or 0); ow_gid = uid_to_gid.get(ow, 0)
                        if not (ow_gid > 0 and ow_gid == tgid and ow != tuid): continue  # refuerzo interno
                        eta = max(0, te - now)
                        g = tiles.get((tx, ty))
                        if g is None:
                            pw = int(tpl.get("power", 0) or 0); ppi = PLAYER_POWER.get(tuid)
                            if ppi and ppi.get("power", 0) > 0: pw = int(ppi["power"])
                            elif MEMBER_INFO.get(tuid, {}).get("power", 0) > 0: pw = int(MEMBER_INFO[tuid]["power"])
                            g = {"x": tx, "y": ty, "tgt_uid": tuid, "tgt_name": tpl.get("name", ""),
                                 "tgt_tag": tpl.get("tag", ""), "tgt_powerM": round(pw / 1e6, 1),
                                 "count": 0, "eta": eta}
                            tiles[(tx, ty)] = g
                        g["count"] += 1
                        if eta < g["eta"]: g["eta"] = eta
            rows = sorted(tiles.values(), key=lambda r: (-r["count"], r["eta"]))
            return self._send(200, json.dumps({"rows": rows, "total": len(rows)}))
        if u.path == "/api/sessions":
            # Todos los usuarios: conectados ahora + conectados antes (ultimo acceso).
            # SOLO superadmin (igual que el panel de scanners).
            if user[1] != "superadmin":
                return self._send(403, json.dumps({"error": "forbidden"}))
            now = time.time()
            sessions = _AUTH.get("sessions", {})
            out = []
            for uname, rec in _AUTH.get("users", {}).items():
                slist = _sessions_list(sessions.get(uname))
                has_session = len(slist) > 0
                sess_count = len(slist)                                     # nº de dispositivos con sesión activa
                newest = max(slist, key=lambda s: int(s.get("login_at", 0) or 0)) if slist else {}
                login_at = int(newest.get("login_at", 0) or 0)              # inicio de la sesión más reciente
                sess_ip = newest.get("ip", "")
                ua = newest.get("ua", "")
                seen = SESSION_LAST_SEEN.get(uname)            # actividad en memoria (live)
                seen_persisted = rec.get("last_seen")          # actividad persistida
                idle = int(now - seen) if seen else None
                online = bool(has_session and idle is not None and idle < SESSION_ONLINE_S)
                # Ultimo acceso = actividad mas reciente conocida (live, persistida o login).
                last_activity = max(x for x in (seen, seen_persisted, rec.get("last_login"),
                                                login_at) if x) if any(
                    (seen, seen_persisted, rec.get("last_login"), login_at)) else None
                out.append({
                    "user": uname,
                    "role": rec.get("role", ""),
                    "has_session": has_session,
                    "sessions_count": sess_count,                  # cuántos dispositivos (multi-sesión)
                    "online": online,
                    "login_at": login_at or None,                 # inicio de la sesion actual
                    "session_age_s": int(now - login_at) if login_at else None,
                    "last_seen": int(seen) if seen else None,
                    "idle_s": idle,
                    "last_login": rec.get("last_login"),           # ultimo login (persistente)
                    "last_activity": int(last_activity) if last_activity else None,  # ultimo acceso
                    "last_ip": rec.get("last_ip", ""),
                    "ip": sess_ip,
                    "ua": ua,
                })
            # Orden: online primero, luego con sesion abierta, luego por ultimo acceso desc.
            out.sort(key=lambda r: (not r["online"], not r["has_session"],
                                    -(r["last_activity"] or 0)))
            return self._send(200, json.dumps({
                "now": int(now), "online_window_s": SESSION_ONLINE_S, "sessions": out
            }))
        if u.path == "/" or u.path == "/index.html":
            return self._send(200, HTML.replace("__SCANNER_VERSION_BADGE__", _VER_BADGE_HTML), "text/html; charset=utf-8")
        if u.path == "/api/names":
            return self._send(200, json.dumps(names_list()))
        if u.path == "/api/catalog":
            return self._send(200, json.dumps(catalog()))
        if u.path == "/api/alliance_members":
            # Miembros de NUESTRA alianza (para el selector de destinatario de Send Coords).
            # Disponible para cualquier admin autenticado. Fuente: member-list (GUILD_MEMBERS)
            # de nuestros guild_ids; fallback a PLAYERS con guild propio. [{uid,name,power}].
            now = time.time()
            seen_uid = set(); out = []
            with LOCK:
                my_guilds = {g for g in (SELF["W"]["guild_id"], SELF["E"]["guild_id"]) if g > 0}
                tag = ""
                for gid in my_guilds:
                    info = GUILD_MEMBERS.get(gid) or {}
                    if info.get("tag"): tag = info["tag"]
                    for m in info.get("members", []):
                        uid = int(m.get("uid", 0) or 0)
                        if uid <= 0 or uid in seen_uid: continue
                        seen_uid.add(uid)
                        pw = int(m.get("power", 0) or 0); ppi = PLAYER_POWER.get(uid)
                        if ppi and ppi.get("power", 0) > 0: pw = int(ppi["power"])
                        out.append({"uid": uid, "name": m.get("name", "") or f"u{uid}", "power": pw})
                # fallback / completar con PLAYERS de nuestro guild (por si la member-list no llegó)
                for p in PLAYERS.values():
                    if int(p.get("gid", 0) or 0) not in my_guilds: continue
                    uid = int(p.get("uid", 0) or 0)
                    if uid <= 0 or uid in seen_uid: continue
                    seen_uid.add(uid)
                    out.append({"uid": uid, "name": p.get("name", "") or f"u{uid}", "power": int(p.get("power", 0) or 0)})
            out.sort(key=lambda r: (r["name"] or "").lower())
            return self._send(200, json.dumps({"tag": tag, "members": out, "total": len(out)}))
        if u.path == "/api/cheat_scan":
            # Ranking de anomalías conductuales: escanea TODOS los jugadores con actividad
            # observada, corre cheat_analysis y devuelve los marcados (score>0) ordenados
            # por sospecha. SOLO superadmin.
            if user[1] != "superadmin":
                return self._send(403, json.dumps({"error": "forbidden"}))
            params = {k: v[0] for k, v in parse_qs(u.query).items()}
            try: min_total = int(params.get("min_total", "100"))
            except: min_total = 100
            only_wl = params.get("watchlist", "") in ("1", "true", "yes")
            now = time.time(); rows = []
            # 2026-08-17: snapshot de candidatos bajo LOCK (rápido) y luego cheat_analysis con LOCK
            # POR-JUGADOR (se libera entre uno y otro). Antes el scoring de TODOS los jugadores corría
            # con el LOCK retenido de principio a fin -> ahogaba la ingesta del escaneo y el bombeo de
            # mensajes de frida durante todo el barrido -> podía provocar un freeze PUNTUAL del juego
            # al lanzar el Cheat Scan. cheat_analysis sigue leyendo bajo LOCK; si un uid desaparece
            # entre el snapshot y el análisis, devuelve None y se salta.
            with LOCK:
                cand = [uid for uid, a in PLAYER_ACT.items()
                        if int(a.get("total", 0) or 0) >= min_total and (not only_wl or uid in WATCHLIST)]
            for uid in cand:
                with LOCK:
                    c = cheat_analysis(uid, now)
                    if not c or c["score"] <= 0: continue
                    pl = PLAYERS.get(uid) or {}
                    pw = int((PLAYER_POWER.get(uid) or {}).get("power", 0)
                             or MEMBER_INFO.get(uid, {}).get("power", 0) or pl.get("power", 0) or 0)
                    rows.append({
                        "uid": uid, "name": pl.get("name", "") or f"u{uid}",
                        "tag": pl.get("tag", "") or "", "sv": int(pl.get("sv", 0) or 0),
                        "x": int(pl.get("wx", 0) or 0), "y": int(pl.get("wy", 0) or 0),
                        "castle": int(pl.get("clv", 0) or 0), "powerM": round(pw / 1e6, 1),
                        "score": c["score"], "level": c["level"], "total": c["total"],
                        "per_day": c["per_day"], "span_days": c["span_days"],
                        "hours_covered": c["hours_covered"], "longest_quiet_h": c["longest_quiet_h"],
                        "week_hours": c["week_hours"], "relocate": c["relocate"],
                        "clean_reloc": c["clean_reloc"], "clean_reloc_rate": c["clean_reloc_rate"],
                        "days_observed": c["days_observed"], "days_no_sleep": c["days_no_sleep"],
                        "flag_keys": [f["key"] for f in c["flags"]],
                        "in_watchlist": uid in WATCHLIST,
                    })
            rows.sort(key=lambda r: (-r["score"], -r["per_day"]))
            return self._send(200, json.dumps({"rows": rows[:200], "total": len(rows), "now": int(now)}))
        if u.path == "/api/rally_contention":
            # ROBO/CONTENCIÓN DE RALLIES: tiles de monstruo donde NUESTRA alianza (LAN) y
            # un enemigo rallean el MISMO objetivo dentro de una ventana, con quién llegó
            # primero. "steal" = el enemigo ralleó a la vez o DESPUÉS que nosotros (nos
            # disputa/roba el objetivo). SOLO superadmin.
            if user[1] != "superadmin":
                return self._send(403, json.dumps({"error": "forbidden"}))
            params = {k: v[0] for k, v in parse_qs(u.query).items()}
            try: win = int(params.get("window_m", "30")) * 60
            except: win = 1800
            try: max_age_h = int(params.get("max_age_h", "48"))
            except: max_age_h = 48
            now = time.time(); cutoff = now - max_age_h * 3600
            with LOCK:
                my_guilds = {g for g in (SELF["W"]["guild_id"], SELF["E"]["guild_id"]) if g > 0}
                evs = [dict(e) for e in RALLY_LOG if e.get("ts", 0) >= cutoff]
            by_tile = {}
            for e in evs:
                by_tile.setdefault((e["tx"], e["ty"]), []).append(e)
            per_enemy = {}; per_ally = {}; contested = 0
            for tile, lst in by_tile.items():
                lst.sort(key=lambda e: e["ts"])
                ours = [e for e in lst if e["gid"] in my_guilds]
                ens  = [e for e in lst if e["gid"] > 0 and e["gid"] not in my_guilds]
                if not ours or not ens: continue
                seen_cluster = set()   # (uid, bucket) para no contar fases/joiners repetidos
                for en in ens:
                    near = [o for o in ours if abs(o["ts"] - en["ts"]) <= win]
                    if not near: continue
                    o = min(near, key=lambda o: abs(o["ts"] - en["ts"]))
                    ck = (en["uid"], tile, en["ts"] // win)
                    if ck in seen_cluster: continue
                    seen_cluster.add(ck)
                    steal = en["ts"] >= o["ts"]
                    contested += 1
                    rec = per_enemy.setdefault(en["uid"], {"uid": en["uid"], "name": en["name"],
                          "tag": en["tag"], "gid": en["gid"], "count": 0, "steals": 0, "examples": []})
                    rec["count"] += 1; rec["steals"] += 1 if steal else 0
                    if len(rec["examples"]) < 8:
                        rec["examples"].append({"x": tile[0], "y": tile[1], "tname": en.get("tname", ""),
                            "lv": en.get("lv", 0), "gap": int(en["ts"] - o["ts"]),
                            "our": o.get("name", ""), "ts": int(en["ts"]), "steal": steal})
                    a = per_ally.setdefault(en["gid"], {"gid": en["gid"], "tag": en["tag"], "count": 0, "steals": 0})
                    a["count"] += 1; a["steals"] += 1 if steal else 0
            players = sorted(per_enemy.values(), key=lambda r: (-r["steals"], -r["count"]))
            alliances = sorted(per_ally.values(), key=lambda r: (-r["steals"], -r["count"]))
            return self._send(200, json.dumps({
                "players": players[:100], "alliances": alliances[:40],
                "contested_total": contested, "window_m": win // 60, "max_age_h": max_age_h,
                "rally_log_size": len(RALLY_LOG), "my_guilds": sorted(my_guilds), "now": int(now)}))
        if u.path == "/api/sc_monsters":
            # Para el selector de Send Coords: combos (nombre, NIVEL REAL=obj.lv)
            # detectados, con su conteo. Usa obj.lv (no cfg.level) para que los event
            # monsters planos (Ymir/Warlord) muestren sus niveles reales y se puedan elegir.
            now = time.time()
            our_sv_scm = our_server()
            agg = {}
            with LOCK:
                for o in OBJS.values():
                    if int(o.get("t", 0)) != 2: continue
                    if o.get("srv") and int(o["srv"]) != our_sv_scm: continue   # solo nuestro server
                    # "on map" = VIVO ahora (ventana corta STALE), igual que send_coords.
                    # Antes usaba TTL (24h) -> contaba acumulado (engañoso, p.ej. 3196).
                    if not _onmap_ok(o, now): continue   # 2026-08-12: SPLIT por grupo (300/900) = /api/data (SEND COORDS envía lo mismo que ves en el buscador)
                    c = CFG.get(str(o["id"])) or {}
                    nm = c.get("name", "")
                    if not nm or not is_boss(nm): continue
                    lv = int(o.get("lv", 0) or 0)
                    if lv <= 0: lv = int(c.get("level", 0) or 0)   # obj.lv 0 = sin poblar -> usar cfg.level
                    agg[(nm, lv)] = agg.get((nm, lv), 0) + 1
            rows = [{"name": nm, "level": lv, "count": n,
                     "label": (f"{nm} Lv{lv}" if lv else nm)}
                    for (nm, lv), n in agg.items()]
            rows.sort(key=lambda r: (r["name"], r["level"]))
            return self._send(200, json.dumps(rows))
        if u.path == "/api/families":
            params = {k: v[0] for k, v in parse_qs(u.query).items()}
            try: ms = float(params["max_seen"]) if params.get("max_seen") else None
            except: ms = None
            ot = params.get("type", "")               # "" = todos (uso legacy); el tab Monsters manda type=2
            ws = params.get("summon", "") == "1"        # "Show only Summons" del tab Monsters
            return self._send(200, json.dumps(families(max_seen=ms, only_type=ot, want_summon=ws)))
        if u.path == "/api/quick":
            params = {k: v[0] for k, v in parse_qs(u.query).items()}
            try: ms = float(params["max_seen"]) if params.get("max_seen") else None
            except: ms = None
            return self._send(200, json.dumps(quick_families(max_seen=ms)))
        if u.path == "/api/data":
            params = {k: v[0] for k, v in parse_qs(u.query).items()}
            all_rows = query(params)
            try: limit = int(params.get("limit", "500"))
            except: limit = 500
            try: page = max(1, int(params.get("page", "1")))
            except: page = 1
            total = len(all_rows)
            if limit <= 0:
                page, pages, rows = 1, 1, all_rows
            else:
                pages = max(1, (total + limit - 1) // limit)
                page  = min(page, pages)
                rows  = all_rows[(page-1)*limit : page*limit]
            with LOCK:
                alln = len(OBJS)
            return self._send(200, json.dumps({
                "rows": rows, "total": total, "all": alln,
                "page": page, "pages": pages, "limit": limit,
                "cfg_n": STATE["cfg_n"],
                "sw_W": STATE["W"], "sw_E": STATE["E"]}))
        if u.path == "/api/farm":
            # OPTIMIZADOR DE FARMEO (#1): monstruos LIBRES (sin rally/march de ataque
            # encima) de los grupos elegidos, dentro de un rango de nivel, ordenados por
            # un score que prioriza CERCANIA (=> mas kills/hora) y NIVEL (=> mas valor).
            # NO usamos recompensa por monstruo (no la tenemos en config); el "valor" es
            # un proxy de level/power. Reusa OBJS (t=2) + MARCH_BY_TGT para "libre".
            params = {k: v[0] for k, v in parse_qs(u.query).items()}
            now = time.time()
            def _ff(k, d=None):
                v = params.get(k, "")
                try: return float(v) if v not in ("", None) else d
                except: return d
            cx = _ff("cx", 458.0); cy = _ff("cy", 568.0)
            rad = _ff("radius"); lv_min = _ff("lv_min"); lv_max = _ff("lv_max")
            # groups: labels de group_of ("Boss","Event","Shadow of Dawn","Other","Normal")
            # separados por ||. Vacio o "all" = sin filtro de grupo.
            grp_raw = (params.get("groups", "Boss||Event") or "").strip()
            groups = set(g for g in grp_raw.split("||") if g) if grp_raw.lower() != "all" else set()
            fams = set(f for f in (params.get("fams", "") or "").split("||") if f)
            only_free = params.get("only_free", "1") != "0"
            # M1: sin &max_seen -> None -> _onmap_ok aplica el SPLIT por grupo (boss/event 2700s, resto 300s),
            # igual que families()/quick_families()/send_coords. Antes el default era STALE_SECONDS=90s y la
            # tabla principal ocultaba casi todo boss/event de periferia (re-barrida cada ~7min >> 90s).
            try: max_seen = float(params["max_seen"]) if params.get("max_seen") else None
            except: max_seen = None
            sort = params.get("sort", "score")
            try: limit = int(params.get("limit", "200"))
            except: limit = 200
            fam_id_set = fam_ids(fams) if fams else None
            ATTACK_MTYS = {2, 10, 19, 20, 21, 43}
            out = []
            with LOCK:
                obs_stats = observed_kill_stats()   # calibracion real (MONSTER_KILLS)
                our_sv_farm = our_server()
                for o in OBJS.values():
                    if o.get("srv") and int(o["srv"]) != our_sv_farm: continue   # excluir server enemigo
                    if int(o.get("t", 0)) != 2: continue
                    if not _onmap_ok(o, now, max_seen): continue   # M1: split por grupo si max_seen=None; ventana fija si viene en el request
                    if fam_id_set is not None and int(o.get("id", 0)) not in fam_id_set: continue
                    c = CFG.get(str(o["id"])) or {}
                    nm = c.get("name", "")
                    if not nm or not is_boss(nm): continue
                    grp = group_of(nm)
                    if groups and grp not in groups: continue
                    lv = int(o.get("lv", 0) or 0)
                    if lv_min is not None and lv < lv_min: continue
                    if lv_max is not None and lv > lv_max: continue
                    dist = math.hypot(o.get("wx", 0) - cx, o.get("wy", 0) - cy)
                    if rad is not None and dist > rad: continue
                    # "libre" = sin march de ATAQUE activa (te>now) hacia el tile
                    busy = False; eta = 0; mcount = 0
                    subs = MARCH_BY_TGT.get((int(o["wx"]), int(o["wy"])))
                    if subs:
                        for trp in list(subs.keys()):
                            m = MARCHES.get(trp)
                            if not m: continue
                            if int(m.get("mty", 0)) not in ATTACK_MTYS: continue
                            if int(m.get("te", 0)) <= now: continue
                            busy = True; mcount += 1
                            e = max(0, int(m.get("te", 0) - now))
                            if eta == 0 or e < eta: eta = e
                    if only_free and busy: continue
                    power = int(c.get("power", 0) or 0)
                    d = round(dist)
                    # score (v2): el VALOR escala super-lineal con el nivel (lv^1.5) porque
                    # la recompensa de boss crece fuerte por tier; la distancia penaliza con
                    # smoothing +25 (mas suave que +15) para que un boss alto algo mas lejos
                    # no quede tapado por trivialidades pegadas a casa. Mayor = mejor.
                    score = round((lv ** 1.5) * 100.0 / (dist + 25.0), 2)
                    # --- reward intel: tabla estatica (CFG.reward) + calibracion observada ---
                    rw = c.get("reward") or {}
                    rscore, items_ev = reward_score(rw)
                    stam = int(rw.get("stam", 0) or 0)
                    vps = round(rscore / stam, 1) if stam > 0 else 0.0
                    ob = obs_stats.get(int(o["id"]))   # None si nunca lo matamos
                    out.append({
                        "id": o["id"], "name": nm, "level": lv, "power": power,
                        "group": grp, "x": o["wx"], "y": o["wy"], "dist": d,
                        "busy": busy, "eta": eta, "march_count": mcount,
                        "score": score, "age": int(now - o.get("ts", now)),
                        "reward": rw, "reward_score": rscore, "items_ev": items_ev,
                        "stam": stam, "value_per_stam": vps, "obs": ob,
                    })
            if   sort == "dist":   out.sort(key=lambda r: (r["dist"], -r["level"]))
            elif sort == "level":  out.sort(key=lambda r: (-r["level"], r["dist"]))
            elif sort == "power":  out.sort(key=lambda r: (-r["power"], r["dist"]))
            elif sort == "reward": out.sort(key=lambda r: -r["reward_score"])
            elif sort == "vps":    out.sort(key=lambda r: -r["value_per_stam"])
            else:                  out.sort(key=lambda r: -r["score"])   # score (default)
            total = len(out)
            if limit > 0: out = out[:limit]
            # item_names: resolver ids de loot que aparecen en las filas devueltas
            # (tabla estatica reward.items/horn + observado obs.items) -> {id: nombre}
            item_ids = set()
            for r in out:
                for it in ((r.get("reward") or {}).get("items") or []):
                    try: item_ids.add(int(it[0]))
                    except Exception: pass
                h = (r.get("reward") or {}).get("horn") or ""
                if ":" in h:
                    try: item_ids.add(int(h.split(":")[0]))
                    except Exception: pass
                ob = r.get("obs")
                if isinstance(ob, dict):
                    for k in (ob.get("items") or {}).keys():
                        try: item_ids.add(int(k))
                        except Exception: pass
            with LOCK:
                item_names = {str(i): ITEMCFG[str(i)] for i in item_ids if str(i) in ITEMCFG}
            return self._send(200, json.dumps({"now": int(now), "total": total,
                                               "rows": out, "cx": cx, "cy": cy,
                                               "item_names": item_names}))
        if u.path == "/api/players":
            params = {k: v[0] for k, v in parse_qs(u.query).items()}
            rows = query_players(params)
            with LOCK:
                alln = len(PLAYERS)
            return self._send(200, json.dumps({
                "rows": rows, "total": len(rows), "all": alln,
                "sw_W": STATE["W"], "sw_E": STATE["E"]}))
        if u.path == "/api/player_activity":
            # Actividad inferida de un jugador (Fase 1+2). last activity, histograma
            # 7×24 (hora local), horas pico y eventos recientes. Ver _record_activity.
            params = {k: v[0] for k, v in parse_qs(u.query).items()}
            try: uid = int(params.get("uid", "0"))
            except: uid = 0
            # cheat=1 (solo superadmin): fuerza el análisis aunque NO esté en watchlist,
            # para ver el report de cualquier jugador del Cheat Scan / Rally steals.
            force_cheat = (params.get("cheat", "") in ("1", "true")) and user[1] == "superadmin"
            now = time.time()
            with LOCK:
                a = PLAYER_ACT.get(uid)
                pl = PLAYERS.get(uid) or {}
                scanner_ts = int(pl.get("ts", 0) or 0)
                name = pl.get("name", ""); tag = pl.get("tag", "")
                mls = MEMBER_LASTSEEN.get(uid)   # None=no es miembro sondeado; 0/1=online; epoch=ultimo visto
                if a is None:
                    return self._send(200, json.dumps({
                        "uid": uid, "name": name, "tag": tag,
                        "last_activity": None, "first_activity": None,
                        "total_events": 0, "by_type": {},
                        "heatmap": [[0] * 24 for _ in range(7)],
                        "hour_hist": [0] * 24, "peak_hours": [],
                        "recent": [], "scanner_last_seen": scanner_ts or None,
                        "member_lastseen": mls,
                        "now": int(now)}))
                hod = list(a["hod"])
                heatmap = [hod[d * 24:(d + 1) * 24] for d in range(7)]
                hour_hist = [0] * 24
                for d in range(7):
                    for h in range(24):
                        hour_hist[h] += hod[d * 24 + h]
                peak = sorted([h for h in range(24) if hour_hist[h] > 0],
                              key=lambda h: -hour_hist[h])[:4]
                recent = list(a["recent"])[::-1]
                in_wl = uid in WATCHLIST
                # relocations recientes (rutas concretas) para el report exacto
                relocs = []
                for ev in list(RELOCATIONS.get(uid, []))[-20:][::-1]:
                    relocs.append({"ts": int(ev.get("ts", 0)),
                                   "from": [int(ev.get("from_x", 0)), int(ev.get("from_y", 0))],
                                   "to": [int(ev.get("to_x", 0)), int(ev.get("to_y", 0))],
                                   "from_srv": int(ev.get("from_srv", 0) or 0),
                                   "to_srv": int(ev.get("srv", 0) or 0)})
                out = {
                    "uid": uid, "name": name, "tag": tag,
                    "gid": int(pl.get("gid", 0) or 0), "sv": int(pl.get("sv", 0) or 0),
                    "x": int(pl.get("wx", 0) or 0), "y": int(pl.get("wy", 0) or 0),
                    "castle": int(pl.get("clv", 0) or 0),
                    "power": int((PLAYER_POWER.get(uid) or {}).get("power", 0)
                                 or MEMBER_INFO.get(uid, {}).get("power", 0)
                                 or pl.get("power", 0) or 0),
                    "last_activity": int(a["last"]), "first_activity": int(a["first"]),
                    "total_events": a["total"], "by_type": a["by_type"],
                    "heatmap": heatmap, "hour_hist": hour_hist, "peak_hours": peak,
                    "recent": recent, "relocations": relocs,
                    "off_window": attack_window(uid, now),   # ventana de ataque óptima
                    "pvp": player_pvp_intel(uid),            # ficha de combate (scout + batallas)
                    "scanner_last_seen": scanner_ts or None,
                    "member_lastseen": mls, "in_watchlist": in_wl,
                    # análisis SOLO si está en watchlist, O si lo fuerza el superadmin (cheat=1,
                    # desde el Cheat Scan / Rally steals) para ver el report de cualquiera.
                    "cheat": cheat_analysis(uid, now) if (in_wl or force_cheat) else None,
                    "rally_steal": player_rally_contention(uid) if (in_wl or force_cheat) else None,
                    "now": int(now),
                }
            return self._send(200, json.dumps(out))
        if u.path == "/api/watchlist":
            # Devuelve uids del watchlist con su data live (player + shield ETA).
            now = time.time(); now_i = int(now)
            out = []
            with LOCK:
                for uid in sorted(WATCHLIST):
                    pl = PLAYERS.get(uid) or {}
                    eta_info = SHIELD_ETA.get(uid) or {}
                    et = int(eta_info.get("end_time", 0) or 0)
                    note = (WATCHLIST_NOTES.get(uid) or {}).get("note", "")
                    note_info = WATCHLIST_NOTES.get(uid) or {}
                    out.append({
                        "uid": uid,
                        "name": pl.get("name", "") or "?",
                        "level": pl.get("lv", 0), "castle": pl.get("clv", 0),
                        "tag":   pl.get("tag", "") or "",
                        "x": pl.get("wx", 0), "y": pl.get("wy", 0),
                        "shield_tier": int(pl.get("shield", 0) or 0),
                        "shield_eta": max(0, et - now_i) if et > now_i else 0,
                        "shield_end_time": et,
                        "shield_src": eta_info.get("src", ""),
                        "shield_confidence": eta_info.get("confidence", ""),
                        "shield_activation_ts": int(eta_info.get("activation_ts", 0) or 0),
                        "shield_history_n": len(SHIELD_HISTORY.get(uid, [])),
                        "note": note,
                        "source": note_info.get("source", "manual"),
                        "in_players_cache": uid in PLAYERS,
                    })
            return self._send(200, json.dumps({"rows": out, "total": len(out),
                                               "rules": dict(WATCHLIST_RULES),
                                               "blacklist_size": len(WATCHLIST_BLACKLIST)}))
        if u.path == "/api/watchlist/rules":
            return self._send(200, json.dumps(WATCHLIST_RULES))
        if u.path == "/api/notif_prefs":
            # Preferencias de notificaciones DEL USUARIO LOGUEADO (no globales).
            return self._send(200, json.dumps(get_notif_prefs(user[0])))
        if u.path == "/api/quick_prefs":
            # Selecciones + ajustes de Shortcuts DEL USUARIO LOGUEADO (server-side, por usuario).
            return self._send(200, json.dumps(get_quick_prefs(user[0])))
        if u.path == "/api/incidents":
            # LOG DE INCIDENCIAS visible en Settings: por qué una mitad dejó de escanear.
            # Antes esto vivía sólo en /tmp/evony_pixel.log, inaccesible desde la web.
            try:
                _q = parse_qs(u.query or "")
                lim = max(1, min(200, int((_q.get("limit") or ["60"])[0])))
            except Exception:
                lim = 60
            with INC_LOCK:
                rows = list(INCIDENTS)[-lim:]
            rows.reverse()                     # más reciente primero
            _n = time.time()
            out = [{"ts": r["ts"], "age": int(_n - r["ts"]), "half": r["half"],
                    "kind": r["kind"], "detail": r["detail"],
                    "src": r.get("src", "live")} for r in rows]
            return self._send(200, json.dumps({
                "incidents": out, "n": len(out),
                "stuck": {h: (int(_n - NAV_STUCK_SINCE[h]) if NAV_STUCK_SINCE.get(h, 0) > 0 else 0)
                          for h in ("W", "E")},
                "episodes": {h: NAV_STUCK_EPISODES.get(h, 0) for h in ("W", "E")},
                "restart_after_s": NAV_STUCK_RESTART_S}))
        if u.path == "/api/scanner":
            # GET = estado actual; POST = cambiar (action=pause|resume)
            # server_id: detectado por cada half + consensus (si ambos coinciden lo damos como canonical)
            sw, se = DETECTED_SERVER_ID.get("W", 0), DETECTED_SERVER_ID.get("E", 0)
            srv = sw if (sw > 0 and (se == 0 or sw == se)) else (se if se > 0 else 0)
            _now = time.time()
            def _sc_st(h):
                st = STATE.get(h, {})
                hb = LAST_HEARTBEAT.get(h, 0.0)
                sw = st.get("sweep", "?")
                lot = (SCAN_STATS.get(h) or {}).get("last_obj_ts", 0) or 0
                obj_age = (round(_now - lot, 1) if lot else None)
                # "stalled": dice estar escaneando (running/pasada) pero lleva >30s sin objetos
                # nuevos y no está pausado -> freeze en curso (el watchdog lo reattachará a los
                # SCAN_FREEZE_TIMEOUT s). Hace que Settings NO muestre "running" verde mintiendo.
                stalled = bool(lot and (not SCANNER_PAUSED) and (sw == "running" or sw.startswith("pasada"))
                               and obj_age is not None and obj_age > 30)
                return {
                    "sweep": sw,                         # running|warmup|reattach|reconectando|hard-reset|nuclear-reset|esperando-adb|frozen|done|pasada N|?
                    "num": st.get("num", 0),             # nº de vuelta actual
                    "last_secs": st.get("last_secs"),    # duración de la última vuelta completa
                    "hb_age": (round(_now - hb, 1) if hb else None),   # seg desde el último mensaje del agente
                    "obj_age": obj_age,                  # seg desde el ÚLTIMO objeto parseado (frescura REAL del escaneo)
                    "stalled": stalled,                  # running pero sin objetos nuevos hace >30s (freeze en curso)
                    "fails": REATTACH_FAILS.get(h, 0),   # reattaches fallidos consecutivos
                    "cold_done": bool(COLD_GRACE_DONE.get(h, False)),  # 1ª vuelta completada (cold-load OK)
                }
            return self._send(200, json.dumps({
                "paused": SCANNER_PAUSED,
                "server_id": srv,
                "server_id_W": sw, "server_id_E": se,
                "self_W": SELF.get("W"), "self_E": SELF.get("E"),
                "scanners": {"W": _sc_st("W"), "E": _sc_st("E")},   # estado por escáner para el panel de Settings
            }))
        if u.path == "/api/scan_stats":
            # Métricas por scanner para comparar estrategias de división del mapa.
            now = time.time()
            with LOCK:
                elapsed = now - SCAN_STATS.get("session_start", now)
                mode = SCAN_STATS.get("mode", "?")
                def _half_report(h):
                    s = SCAN_STATS.get(h, {})
                    disc = s.get("discoveries", 0)
                    resp = s.get("respawns", 0)
                    redet = s.get("redetects", 0)
                    total_seen = disc + resp + redet
                    mins = max(elapsed / 60.0, 0.001)
                    return {
                        "discoveries": disc,            # tiles nuevos descubiertos
                        "respawns": resp,               # re-spawns detectados
                        "redetects": redet,             # re-detecciones (solapamiento)
                        "monsters_by_group": s.get("monsters_by_group", {}),
                        "cerberus": s.get("cerberus", 0),
                        "passes": s.get("passes", 0),
                        "discoveries_per_min": round(disc / mins, 2),
                        "monsters_per_min": round((sum(s.get("monsters_by_group", {}).values())) / mins, 2),
                        # eficiencia: % de detecciones que fueron descubrimientos útiles
                        # (alto redetect = mucho solapamiento = trabajo desperdiciado)
                        "efficiency_pct": round(100.0 * (disc + resp) / total_seen, 1) if total_seen else 0,
                        # frescura del centro (avg s entre re-detecciones de objetos centrales)
                        "central_refresh_avg_s": round(s.get("central_refresh_sum", 0.0) / s["central_refresh_n"], 1) if s.get("central_refresh_n", 0) else None,
                        "central_redetects": s.get("central_refresh_n", 0),
                    }
                w = _half_report("W"); e = _half_report("E")
                # totales combinados
                comb_disc = w["discoveries"] + e["discoveries"]
                comb_mon = sum(w["monsters_by_group"].values()) + sum(e["monsters_by_group"].values())
                comb_cerb = w["cerberus"] + e["cerberus"]
                # frescura central COMBINADA (gap medio entre re-detecciones de cualquier half)
                csum = SCAN_STATS.get("W", {}).get("central_refresh_sum", 0.0) + SCAN_STATS.get("E", {}).get("central_refresh_sum", 0.0)
                cn   = SCAN_STATS.get("W", {}).get("central_refresh_n", 0) + SCAN_STATS.get("E", {}).get("central_refresh_n", 0)
            return self._send(200, json.dumps({
                "mode": mode,
                "elapsed_min": round(elapsed / 60.0, 1),
                "W": w, "E": e,
                "combined": {
                    "discoveries": comb_disc,
                    "monsters": comb_mon,
                    "cerberus": comb_cerb,
                    "pool_objs": len(OBJS),
                    "discoveries_per_min": round(comb_disc / max(elapsed/60.0, 0.001), 2),
                    "central_refresh_avg_s": round(csum / cn, 1) if cn else None,
                    "central_redetects_per_min": round(cn / max(elapsed/60.0, 0.001), 1),
                },
            }))
        if u.path == "/api/scan_stats/reset":
            with LOCK:
                mode = parse_qs(u.query).get("mode", ["?"])[0]
                SCAN_STATS["W"] = _new_scan_stat()
                SCAN_STATS["E"] = _new_scan_stat()
                SCAN_STATS["session_start"] = time.time()
                SCAN_STATS["mode"] = mode
            print(f"[scan_stats] RESET (mode={mode})", flush=True)
            return self._send(200, json.dumps({"ok": True, "mode": mode}))
        if u.path == "/api/pvp_stats":
            # V3 PvP INTELLIGENCE: análisis agregado de batallas/scouts/PvE.
            now = time.time()
            with LOCK:
                battles = list(BATTLE_LOG)
                scouts = list(SCOUT_INTEL.values())
                kills = list(MONSTER_KILLS)
            def _gen_label(g):
                if not g: return None
                nk = (g.get("name_key", "") or "").strip()
                fid = g.get("famous_id", 0) or 0
                # name_key suele ser clave de localización; si no, usar famous_id
                lbl = nk if (nk and not nk.startswith("general") and len(nk) < 40) else (f"General#{fid}" if fid else "?")
                return {"label": lbl, "famous_id": fid, "star": g.get("star", 0), "power": g.get("power", 0)}
            # ── Battle analytics ──
            from collections import Counter, defaultdict
            atk_alliance = Counter()      # tag atacante -> nº batallas
            atk_wins = Counter()          # tag atacante -> wins (heurístico: def perdió más power)
            power_destroyed = Counter()   # tag atacante -> power total destruido al rival
            gen_use = Counter()           # general atacante -> usos
            gen_wins = Counter()          # general atacante -> wins
            def_gen_seen = Counter()      # general defensor -> veces visto
            attacker_kd = defaultdict(lambda: {"kills": 0, "losses": 0, "battles": 0, "name": "", "tag": ""})
            recent = []
            for b in battles:
                atk = b.get("attacker") or {}; dfn = b.get("defender") or {}
                abi = b.get("atk_battle") or {}; dbi = b.get("def_battle") or {}
                atag = (atk.get("tag", "") or "?"); auid = atk.get("uid", 0)
                # win heurístico: el atacante "gana" si causó más power perdido del que sufrió
                atk_lost = int(abi.get("lost_power", 0) or 0)
                def_lost = int(dbi.get("lost_power", 0) or 0)
                won = def_lost > atk_lost
                if atag != "?":
                    atk_alliance[atag] += 1
                    if won: atk_wins[atag] += 1
                    power_destroyed[atag] += def_lost
                ag = _gen_label(b.get("atk_general"))
                if ag and ag["label"] != "?":
                    gen_use[ag["label"]] += 1
                    if won: gen_wins[ag["label"]] += 1
                dg = _gen_label(b.get("def_general"))
                if dg and dg["label"] != "?":
                    def_gen_seen[dg["label"]] += 1
                if auid:
                    e = attacker_kd[auid]
                    e["kills"] += int(dbi.get("killed", 0) or 0) + int(dbi.get("lossed", 0) or 0)
                    e["losses"] += int(abi.get("lossed", 0) or 0)
                    e["battles"] += 1
                    e["name"] = atk.get("name", ""); e["tag"] = atag
                recent.append({
                    "ts": b.get("ts", 0), "report": b.get("report"),
                    "attacker": f"[{atag}] {atk.get('name','?')}" if atk else "?",
                    "defender": f"[{dfn.get('tag','?')}] {dfn.get('name','?')}" if dfn else "?",
                    "wx": b.get("wx"), "wy": b.get("wy"),
                    "atk_killed": int(dbi.get("killed",0) or 0)+int(dbi.get("lossed",0) or 0),
                    "atk_lost_power": atk_lost, "def_lost_power": def_lost,
                    "atk_general": ag["label"] if ag else None,
                    "def_general": dg["label"] if dg else None,
                    "result": "WIN" if won else "LOSS",
                })
            recent.sort(key=lambda x: -(x["ts"] or 0))
            def _wr(tag): t=atk_alliance[tag]; return round(100*atk_wins[tag]/t,1) if t else 0
            alliance_war = [{"tag": t, "battles": n, "wins": atk_wins[t], "win_rate": _wr(t),
                             "power_destroyed": power_destroyed[t]}
                            for t, n in atk_alliance.most_common(15)]
            top_atk_generals = [{"general": g, "uses": n, "wins": gen_wins[g],
                                 "win_rate": round(100*gen_wins[g]/n,1) if n else 0}
                                for g, n in gen_use.most_common(15)]
            top_def_generals = [{"general": g, "seen": n} for g, n in def_gen_seen.most_common(15)]
            top_attackers = sorted(
                [{"uid": u, **e, "kd": round(e["kills"]/max(e["losses"],1),2)} for u, e in attacker_kd.items()],
                key=lambda x: -x["kills"])[:15]
            # ── Scout intel (formaciones defensivas) ──
            troop_dist = Counter()   # type_id -> total tropas vistas en defensas
            scout_rows = []
            for s in sorted(scouts, key=lambda x: -(x.get("ts", 0)))[:50]:
                troops = s.get("troops", []) or []
                for t in troops: troop_dist[t.get("type_id", 0)] += int(t.get("num", 0) or 0)
                dg = _gen_label(s.get("def_general"))
                scout_rows.append({
                    "uid": s.get("uid"), "name": s.get("name", "?"),
                    "wx": s.get("wx"), "wy": s.get("wy"), "ts": s.get("ts", 0),
                    "total_army": s.get("total_army", 0), "total_wall": s.get("total_wall", 0),
                    "archertower": s.get("archertower", 0),
                    "def_general": dg["label"] if dg else None,
                    "tactics": (s.get("tactics") or {}).get("id", 0),
                    "n_troop_types": len(troops),
                    "top_troops": sorted(troops, key=lambda t: -int(t.get("num",0) or 0))[:5],
                })
            # ── PvE (monster kills) ── ranking por EXPERIENCIA (user_damage es 0 en
            # cacería solo; solo se rellena en rally/unión). Métricas útiles: kills,
            # experiencia ganada y poder desplegado (total_power de las marchas).
            boss_kills = Counter()       # monster_id -> nº kills
            hunters = defaultdict(lambda: {"experience": 0, "kills": 0, "power": 0, "name": "", "tag": ""})
            for k in kills:
                mid = k.get("monster_id", 0)
                boss_kills[mid] += 1
                u = k.get("user") or {}
                uid = u.get("uid", 0)
                if uid:
                    h = hunters[uid]
                    h["experience"] += int(k.get("experience", 0) or 0)
                    h["power"] += int(k.get("total_power", 0) or 0)
                    h["kills"] += 1
                    h["name"] = u.get("name", ""); h["tag"] = u.get("tag", "")
            top_boss = []
            for m, n in boss_kills.most_common(15):
                nm = (CFG.get(str(m)) or {}).get("name", f"id{m}")
                top_boss.append({"monster_id": m, "name": nm, "group": group_of(nm), "kills": n})
            top_hunters = sorted([{"uid": u, **h} for u, h in hunters.items()],
                                 key=lambda x: -x["experience"])[:15]
            return self._send(200, json.dumps({
                "now": int(now),
                "totals": {"battles": len(battles), "scouts": len(scouts), "pve_kills": len(kills)},
                "alliance_war": alliance_war,
                "top_attack_generals": top_atk_generals,
                "top_defense_generals": top_def_generals,
                "top_attackers": top_attackers,
                "recent_battles": recent[:60],
                "scout_intel": scout_rows,
                "troop_distribution": [{"type_id": t, "total": n} for t, n in troop_dist.most_common(20)],
                "pve_top_bosses": top_boss,
                "pve_top_hunters": top_hunters,
            }))
        if u.path == "/api/kingdom_intel":
            # V3 RESEARCH: stats agregadas del kingdom para dashboard Server Stats
            now = time.time()
            with LOCK:
                # Distribución por castle level
                castle_buckets = {"C1-9": 0, "C10-19": 0, "C20-29": 0, "C30-34": 0, "C35+": 0}
                alliance_counts = {}
                shield_dist = {"none": 0, "tier_1": 0, "tier_2": 0, "eta_exact": 0, "eta_inferred": 0}
                total_players = len(PLAYERS)
                for uid, p in PLAYERS.items():
                    c = int(p.get("clv", 0) or 0)
                    if c < 10: castle_buckets["C1-9"] += 1
                    elif c < 20: castle_buckets["C10-19"] += 1
                    elif c < 30: castle_buckets["C20-29"] += 1
                    elif c < 35: castle_buckets["C30-34"] += 1
                    else: castle_buckets["C35+"] += 1
                    tag = (p.get("tag", "") or "(none)").strip() or "(none)"
                    alliance_counts[tag] = alliance_counts.get(tag, 0) + 1
                    tier = int(p.get("shield", 0) or 0)
                    eta = SHIELD_ETA.get(uid)
                    if eta:
                        if eta.get("confidence") == "exact": shield_dist["eta_exact"] += 1
                        else: shield_dist["eta_inferred"] += 1
                    elif tier == 1: shield_dist["tier_1"] += 1
                    elif tier == 2: shield_dist["tier_2"] += 1
                    else: shield_dist["none"] += 1

                # Top 10 per ranking
                top_power = sorted(PLAYER_POWER.items(), key=lambda x: -x[1].get("power", 0))[:10]
                top_fame  = sorted(PLAYER_FAME.items(),  key=lambda x: -x[1].get("fame", 0))[:10]
                top_kills = sorted(PLAYER_KILLS.items(), key=lambda x: -x[1].get("kills", 0))[:10]
                top_keep  = sorted(PLAYER_KEEP.items(),  key=lambda x: -x[1].get("keep_rank", 0))[:10]

                def enrich(uid_dict_list, statkey):
                    out = []
                    for uid, d in uid_dict_list:
                        pl = PLAYERS.get(uid) or {}
                        out.append({
                            "uid": uid,
                            "name": pl.get("name", "") or d.get("name", "") or "?",
                            "tag":  pl.get("tag", ""),
                            "castle": pl.get("clv", 0),
                            "x": pl.get("wx", 0), "y": pl.get("wy", 0),
                            "value": d.get(statkey, 0),
                            "rank": d.get("rank", 0),
                        })
                    return out

                # Top alianzas por miembros activos (no inactivos)
                active_alliance_counts = {}
                for uid, p in PLAYERS.items():
                    if int(p.get("clv", 0) or 0) < 15: continue   # solo activos
                    tag = (p.get("tag", "") or "(none)").strip() or "(none)"
                    if tag == "(none)": continue
                    active_alliance_counts[tag] = active_alliance_counts.get(tag, 0) + 1
                top_alliances = sorted(active_alliance_counts.items(), key=lambda x: -x[1])[:15]

            return self._send(200, json.dumps({
                "now": int(now),
                "totals": {
                    "players": total_players,
                    "players_active_c10plus": sum(v for k,v in castle_buckets.items() if k != "C1-9"),
                    "players_competitive_c20plus": sum(v for k,v in castle_buckets.items() if k.startswith("C2") or k.startswith("C3")),
                    "players_top_c30plus": castle_buckets.get("C30-34",0) + castle_buckets.get("C35+",0),
                    "alliances_count": len([t for t in alliance_counts if t != "(none)"]),
                    "objs_in_pool": len(OBJS),
                    "subcities": len(SUBCITIES),
                    "marches_active": len(MARCHES),
                },
                "castle_distribution": castle_buckets,
                "shield_distribution": shield_dist,
                "top_alliances": [{"tag": t, "members": c} for t,c in top_alliances],
                "guild_member_lists": {
                    str(gid): {
                        "tag": info.get("tag", ""),
                        "n": len(info.get("members", [])),
                        "updated_secs_ago": int(now - info.get("updated_ts", now)),
                    } for gid, info in sorted(GUILD_MEMBERS.items(), key=lambda kv: -len(kv[1].get("members", [])))[:10]
                },
                "rankings": {
                    "power": enrich(top_power, "power"),
                    "fame":  enrich(top_fame, "fame"),
                    "kills": enrich(top_kills, "kills"),
                    "keep":  enrich(top_keep, "keep_rank"),
                },
                "scanner_stats": {
                    "W": STATE.get("W", {}),
                    "E": STATE.get("E", {}),
                    "first_pass_ts": FIRST_PASS_TS,
                },
            }))
        if u.path == "/api/guild_members":
            # V3 RESEARCH: lista de members capturados por GetMemberList. ?gid=N para uno solo.
            from urllib.parse import parse_qs as _pqs
            qs = _pqs(u.query or "")
            gid_q = qs.get("gid", [None])[0]
            now = time.time()
            with LOCK:
                if gid_q:
                    try: gid = int(gid_q)
                    except Exception: gid = 0
                    info = GUILD_MEMBERS.get(gid)
                    if not info:
                        return self._send(404, json.dumps({"error": "gid not captured", "available": list(GUILD_MEMBERS.keys())}))
                    return self._send(200, json.dumps({
                        "gid": gid, "tag": info.get("tag", ""),
                        "updated_secs_ago": int(now - info.get("updated_ts", now)),
                        "n": len(info.get("members", [])),
                        "members": info.get("members", []),
                    }))
                # All: summary
                out = []
                for gid, info in sorted(GUILD_MEMBERS.items(), key=lambda kv: -len(kv[1].get("members", []))):
                    out.append({
                        "gid": gid, "tag": info.get("tag", ""),
                        "n": len(info.get("members", [])),
                        "updated_secs_ago": int(now - info.get("updated_ts", now)),
                    })
            return self._send(200, json.dumps({"guilds": out, "total_guilds": len(out)}))
        if u.path == "/api/protocol_stats":
            # V3 RESEARCH: protocol tracer counts. Devuelve por half los counters
            # acumulados de UP/DOWN messages por tipo, plus totales.
            with LOCK:
                snap = {}
                for h, ps in PROTO_STATS.items():
                    snap[h] = {
                        "total_up": ps.get("total_up", 0),
                        "total_down": ps.get("total_down", 0),
                        "last_update": ps.get("last_update", 0),
                        "up": dict(ps.get("up", {})),
                        "down": dict(ps.get("down", {})),
                    }
            return self._send(200, json.dumps({"halves": snap}))
        if u.path == "/api/shields":
            # diagnostico/UI: contadores + recientes + alerts de proxima expiracion.
            # alerts: ETAs activos cuyo tiempo restante <= SHIELD_ALERT_THRESHOLD (10min)
            # y que aun no hayamos alertado en esta session (gestionado por SHIELD_ALERTED).
            now = time.time()
            now_i = int(now)
            new_alerts = []
            active_list = []   # todos los SHIELD_ETA activos, expanded con info de PLAYERS
            with LOCK:
                # primero funde coord->uid (resuelve pending_coord cuyo jugador ya
                # conocemos y aplica la regla exact>inferido). Evita duplicados y
                # corrige confidences obsoletas como inferred_undershot.
                _merge_coord_shields_locked(now)
                active_uid    = 0
                pending_coord = 0
                for k, e in SHIELD_ETA.items():
                    et = int(e.get("end_time", 0) or 0)
                    if et <= now_i: continue
                    if isinstance(k, int):
                        # No mostrar undershot (inferencia debil) en indicador/panel/alerts
                        if e.get("confidence", "") == "inferred_undershot":
                            continue
                        active_uid += 1
                        left = et - now_i
                        pl = PLAYERS.get(k) or {}
                        active_list.append({
                            "uid": k, "left": left, "end_time": et,
                            "name": pl.get("name", "") or "?",
                            "tag":  pl.get("tag", "") or "",
                            "wx": int(pl.get("wx", 0) or 0),
                            "wy": int(pl.get("wy", 0) or 0),
                            "castle": int(pl.get("clv", 0) or 0),
                            "src":  e.get("src", "?"),
                            "confidence": e.get("confidence", ""),
                        })
                        # alerta solo si esta en el threshold, no se ha alertado aun
                        # y el jugador esta en el WATCHLIST (toasts/beep solo para watchlist)
                        if 0 < left <= SHIELD_ALERT_THRESHOLD and k not in SHIELD_ALERTED and k in WATCHLIST:
                            new_alerts.append({
                                "uid": k, "left": left, "end_time": et,
                                "name": pl.get("name", "") or e.get("name", "") or "?",
                                "tag":  pl.get("tag", "") or "",
                                "src":  e.get("src", "?"),
                                "wx": int(pl.get("wx", 0) or 0),
                                "wy": int(pl.get("wy", 0) or 0),
                            })
                            SHIELD_ALERTED.add(k)
                    elif isinstance(k, str) and k.startswith("coord:"):
                        pending_coord += 1
                        # tambien expone los pending coord (sin uid match aun)
                        active_list.append({
                            "uid": 0, "left": int(e.get("end_time", 0)) - now_i,
                            "end_time": int(e.get("end_time", 0)),
                            "name": e.get("name", "") or "?",
                            "tag": "",
                            "wx": int(e.get("wx", 0) or 0),
                            "wy": int(e.get("wy", 0) or 0),
                            "castle": 0,
                            "src": e.get("src", "?"),
                            "confidence": "pending_coord_match",
                        })
                # sort ascending por tiempo restante (los que expiran antes primero)
                active_list.sort(key=lambda x: x["left"])
                recent = []
                for r in SHIELD_RECENT[:10]:
                    et = int(r.get("end_time", 0) or 0)
                    if et <= now_i: continue   # ocultar caducados del feed
                    recent.append({
                        "name": r.get("name", "") or "?",
                        "wx": r.get("wx", 0), "wy": r.get("wy", 0),
                        "uid": r.get("uid", 0),
                        "left": et - now_i,
                        "src": r.get("src", "?"),
                        "age": int(now - r.get("ts", now)),
                    })
            return self._send(200, json.dumps({
                "active_uid": active_uid,
                "pending_coord": pending_coord,
                "total": active_uid + pending_coord,
                "active_list": active_list,
                "recent": recent,
                "alerts": new_alerts,
                "alert_threshold": SHIELD_ALERT_THRESHOLD,
            }))
        if u.path == "/api/resources":
            # Resource tiles del mapa: t=3 (mapinfo_farm). El __id de mapinfo indexa
            # WorldResourceConfig (no MonsterConfig). farm_type: 1=food, 2=wood, 3=stone, 4=iron,
            # 5+=gems/pumpkin/rose/especiales.
            # Campos extra (capturados ahora):
            #   __resource_num (Int64) -> cantidad disponible
            #   __occupy_id (UInt32)   -> uid del ocupante (0 si Free)
            #   __occupy_guild_id      -> guild_id del ocupante
            #   __occupy_time          -> instante de ocupacion
            params = {k: v[0] for k, v in parse_qs(u.query).items()}
            try: cx = float(params.get("cx", "")) if params.get("cx") else None
            except: cx = None
            try: cy = float(params.get("cy", "")) if params.get("cy") else None
            except: cy = None
            try: rad = float(params.get("radius", "")) if params.get("radius") else None
            except: rad = None
            try: lv_min = int(params["lv_min"]) if params.get("lv_min") else None
            except: lv_min = None
            try: lv_max = int(params["lv_max"]) if params.get("lv_max") else None
            except: lv_max = None
            rtype = params.get("rtype", "")    # "" = todos | "1" food | "2" wood | "3" stone | "4" iron
            # occupied: "" all | "free" | "ext" (occupied no-alliance) | "mine" (occupied IN my alliance)
            occ = params.get("occupied", "")
            alliance_tag = (params.get("alliance", "") or "").strip().lower()
            try: limit = int(params.get("limit", "500"))
            except: limit = 500
            try: page = max(1, int(params.get("page", "1")))
            except: page = 1
            try: max_seen = float(params["max_seen"]) if params.get("max_seen") else STALE_SECONDS
            except: max_seen = STALE_SECONDS
            sort = params.get("sort", "newest")
            now = time.time()
            # set de guild_ids del propio jugador (uno por scanner W/E). Tiles cuyo
            # __occupy_guild_id este en este set -> "in MY alliance".
            my_guilds = {g for g in (SELF["W"]["guild_id"], SELF["E"]["guild_id"]) if g > 0}
            out = []
            with LOCK:
                for o in OBJS.values():
                    if int(o.get("t", 0)) != 3: continue
                    if (now - o.get("ts", 0)) > max_seen: continue
                    wcfg = WCFG.get(str(o["id"])) or {}
                    rtp = int(wcfg.get("type", 0) or 0)
                    if rtype and rtp != int(rtype): continue
                    lv = int(o.get("lv", 0) or wcfg.get("level", 0) or 0)
                    if lv_min is not None and lv < lv_min: continue
                    if lv_max is not None and lv > lv_max: continue
                    oid = int(o.get("oid", 0) or 0)
                    ogid = int(o.get("ogd", 0) or 0)
                    is_free = (oid == 0)
                    is_mine = (not is_free) and (ogid in my_guilds) if my_guilds else False
                    is_ext  = (not is_free) and (not is_mine)
                    if occ == "free" and not is_free: continue
                    if occ == "mine" and not is_mine: continue
                    if occ == "ext"  and not is_ext:  continue
                    # info del ocupante para mostrar y filtrar por alliance tag
                    occ_name = ""; occ_tag = ""
                    if oid > 0:
                        pl = PLAYERS.get(oid) or {}
                        occ_name = pl.get("name", "") or ""
                        occ_tag  = (pl.get("tag", "") or "")
                    if alliance_tag and alliance_tag not in occ_tag.lower():
                        continue
                    d = None
                    if cx is not None and cy is not None:
                        d = math.hypot(o["wx"] - cx, o["wy"] - cy)
                        if rad is not None and d > rad: continue
                        d = round(d)
                    out.append({
                        "id": o["id"],
                        "name": wcfg.get("name", "") or f"id{o['id']}",
                        "type_id": rtp,
                        "type_label": WCFG_TYPE_LABEL.get(rtp, f"type{rtp}" if rtp else "?"),
                        "level": lv,
                        "available": int(o.get("rn", 0) or 0),
                        "occupy_uid": oid,
                        "occupy_guild_id": ogid,
                        "occupy_name": occ_name,
                        "occupy_tag":  occ_tag,
                        "occupy_status": "free" if is_free else ("mine" if is_mine else "ext"),
                        "x": o["wx"], "y": o["wy"], "dist": d,
                        "age": int(now - o.get("ts", now)),
                    })
            if sort == "level": out.sort(key=lambda r: (-r["level"], r["name"]))
            elif sort == "type": out.sort(key=lambda r: (r["type_id"], -r["level"]))
            elif sort == "dist": out.sort(key=lambda r: (r["dist"] if r["dist"] is not None else 9e9))
            elif sort == "available": out.sort(key=lambda r: -r["available"])
            elif sort == "newest": out.sort(key=lambda r: r["age"])
            total = len(out)
            if limit <= 0:
                page, pages, rows = 1, 1, out
            else:
                pages = max(1, (total + limit - 1) // limit)
                page  = min(page, pages)
                rows  = out[(page-1)*limit : page*limit]
            return self._send(200, json.dumps({"rows": rows, "total": total,
                                               "page": page, "pages": pages, "limit": limit}))
        if u.path == "/api/relics":
            # Relics/Pyramids: tiles especiales del mapa. Combina:
            #   t=5  mapinfo_ruins
            #   t=34 mapinfo_altar
            #   t=57 pyramid_castle
            #   t=12 mapinfo_boss (bosses rallyeables)
            # Filtros adicionales: occupied (free/ext/mine) + alliance tag
            params = {k: v[0] for k, v in parse_qs(u.query).items()}
            try: cx = float(params.get("cx", "")) if params.get("cx") else None
            except: cx = None
            try: cy = float(params.get("cy", "")) if params.get("cy") else None
            except: cy = None
            try: rad = float(params.get("radius", "")) if params.get("radius") else None
            except: rad = None
            try: lv_min = int(params["lv_min"]) if params.get("lv_min") else None
            except: lv_min = None
            try: lv_max = int(params["lv_max"]) if params.get("lv_max") else None
            except: lv_max = None
            kind_filter = params.get("kind", "")   # "" / "ruins" / "altar" / "pyramid" / "boss"
            occ = params.get("occupied", "")        # "" / "free" / "ext" / "mine"
            alliance_tag = (params.get("alliance", "") or "").strip().lower()
            try: limit = int(params.get("limit", "500"))
            except: limit = 500
            try: page = max(1, int(params.get("page", "1")))
            except: page = 1
            # Relics/pyramids/altars/ruins son tiles ESTATICOS y dispersos, casi todos en
            # la periferia (una vuelta completa del barrido ~7min >> STALE_SECONDS=90s). Con
            # la ventana de 90s casi siempre quedaban fuera. Usamos TTL_SECONDS (24h, igual
            # que Subcities) para mostrar todo lo encontrado en cache, no solo lo de los ult. 90s.
            try: max_seen = float(params["max_seen"]) if params.get("max_seen") else TTL_SECONDS
            except: max_seen = TTL_SECONDS
            sort = params.get("sort", "level")
            now = time.time()
            my_guilds = {g for g in (SELF["W"]["guild_id"], SELF["E"]["guild_id"]) if g > 0}
            RELIC_TYPES = {5: "ruins", 34: "altar", 57: "pyramid", 12: "boss"}
            out = []
            with LOCK:
                for o in RUINS.values():   # RUINS = get_ruins_list (pirámides del evento), store separado de OBJS
                    _rid = int(o.get("id", 0) or 0)
                    kind_name = "pyramid"   # todas las ruinas de get_ruins_list del evento actual son pirámides
                    if (now - o.get("ts", 0)) > max_seen: continue
                    if kind_filter and kind_filter != kind_name: continue
                    lv = RUINS_ID_LEVEL.get(_rid) or max(1, _rid - 9)   # ruins_id 14 -> Lv5 (observado); heurística para otros niveles
                    nm = f"Lv{lv} Pyramid Ruins"
                    if lv_min is not None and lv < lv_min: continue
                    if lv_max is not None and lv > lv_max: continue
                    oid = int(o.get("oid", 0) or 0)
                    ogid = int(o.get("ogd", 0) or 0)
                    is_free = (oid == 0)
                    is_mine = (not is_free) and (ogid in my_guilds) if my_guilds else False
                    is_ext  = (not is_free) and (not is_mine)
                    if occ == "free" and not is_free: continue
                    if occ == "mine" and not is_mine: continue
                    if occ == "ext"  and not is_ext:  continue
                    occ_name = ""; occ_tag = ""
                    if oid > 0:
                        pl = PLAYERS.get(oid) or {}
                        occ_name = pl.get("name", "") or ""
                        occ_tag  = (pl.get("tag", "") or "")
                    if alliance_tag and alliance_tag not in occ_tag.lower():
                        continue
                    d = None
                    if cx is not None and cy is not None:
                        d = math.hypot(o["wx"] - cx, o["wy"] - cy)
                        if rad is not None and d > rad: continue
                        d = round(d)
                    out.append({
                        "id": o["id"], "name": nm, "kind": kind_name,
                        "level": lv, "power": 0,
                        "occupy_uid": oid,
                        "occupy_guild_id": ogid,
                        "occupy_name": occ_name,
                        "occupy_tag":  occ_tag,
                        "occupy_status": "free" if is_free else ("mine" if is_mine else "ext"),
                        "x": o["wx"], "y": o["wy"], "dist": d,
                        "age": int(now - o.get("ts", now)),
                    })
            if sort == "level": out.sort(key=lambda r: (-r["level"], r["name"]))
            elif sort == "kind": out.sort(key=lambda r: (r["kind"], -r["level"]))
            elif sort == "power": out.sort(key=lambda r: -r["power"])
            elif sort == "dist": out.sort(key=lambda r: (r["dist"] if r["dist"] is not None else 9e9))
            elif sort == "newest": out.sort(key=lambda r: r["age"])
            total = len(out)
            if limit <= 0:
                page, pages, rows = 1, 1, out
            else:
                pages = max(1, (total + limit - 1) // limit)
                page  = min(page, pages)
                rows  = out[(page-1)*limit : page*limit]
            return self._send(200, json.dumps({"rows": rows, "total": total,
                                               "page": page, "pages": pages, "limit": limit}))
        if u.path == "/api/arctic":
            # Arctic Barbarians: dos fuentes
            #   1) t=36 (barbarian_castle)
            #   2) monstruos t=2/12 cuyo nombre contenga "arctic" (familia FAMILIES)
            # Filtros adicionales: occupied + alliance (igual que resources/relics)
            params = {k: v[0] for k, v in parse_qs(u.query).items()}
            try: cx = float(params.get("cx", "")) if params.get("cx") else None
            except: cx = None
            try: cy = float(params.get("cy", "")) if params.get("cy") else None
            except: cy = None
            try: rad = float(params.get("radius", "")) if params.get("radius") else None
            except: rad = None
            try: lv_min = int(params["lv_min"]) if params.get("lv_min") else None
            except: lv_min = None
            try: lv_max = int(params["lv_max"]) if params.get("lv_max") else None
            except: lv_max = None
            occ = params.get("occupied", "")
            alliance_tag = (params.get("alliance", "") or "").strip().lower()
            try: limit = int(params.get("limit", "500"))
            except: limit = 500
            try: page = max(1, int(params.get("page", "1")))
            except: page = 1
            # Arctic barbarians (t=36 castillos + monstruos "arctic") tambien son dispersos
            # y mayormente perifericos. Ventana de 90s los dejaba fuera casi siempre. 24h igual que relics.
            try: max_seen = float(params["max_seen"]) if params.get("max_seen") else TTL_SECONDS
            except: max_seen = TTL_SECONDS
            sort = params.get("sort", "level")
            now = time.time()
            my_guilds = {g for g in (SELF["W"]["guild_id"], SELF["E"]["guild_id"]) if g > 0}
            arctic_kws = ["arctic barbarian", "arctic"]
            out = []
            with LOCK:
                for o in OBJS.values():
                    if (now - o.get("ts", 0)) > max_seen: continue
                    tval = int(o.get("t", 0))
                    cfg = CFG.get(str(o["id"])) or {}
                    nm = cfg.get("name", "") or ""
                    is_arctic = tval == 36 or fam_match(nm, arctic_kws)
                    if not is_arctic: continue
                    kind_name = "barbarian_castle" if tval == 36 else "barbarian"
                    if not nm:
                        nm = f"Barbarian Castle id{o['id']}" if tval == 36 else f"id{o['id']}"
                    lv = int(o.get("lv", 0) or 0)
                    if lv_min is not None and lv < lv_min: continue
                    if lv_max is not None and lv > lv_max: continue
                    oid = int(o.get("oid", 0) or 0)
                    ogid = int(o.get("ogd", 0) or 0)
                    is_free = (oid == 0)
                    is_mine = (not is_free) and (ogid in my_guilds) if my_guilds else False
                    is_ext  = (not is_free) and (not is_mine)
                    if occ == "free" and not is_free: continue
                    if occ == "mine" and not is_mine: continue
                    if occ == "ext"  and not is_ext:  continue
                    occ_name = ""; occ_tag = ""
                    if oid > 0:
                        pl = PLAYERS.get(oid) or {}
                        occ_name = pl.get("name", "") or ""
                        occ_tag  = (pl.get("tag", "") or "")
                    if alliance_tag and alliance_tag not in occ_tag.lower():
                        continue
                    d = None
                    if cx is not None and cy is not None:
                        d = math.hypot(o["wx"] - cx, o["wy"] - cy)
                        if rad is not None and d > rad: continue
                        d = round(d)
                    out.append({
                        "id": o["id"], "name": nm, "kind": kind_name,
                        "level": lv, "power": int(cfg.get("power", 0) or 0),
                        "occupy_uid": oid, "occupy_guild_id": ogid,
                        "occupy_name": occ_name, "occupy_tag": occ_tag,
                        "occupy_status": "free" if is_free else ("mine" if is_mine else "ext"),
                        "x": o["wx"], "y": o["wy"], "dist": d,
                        "age": int(now - o.get("ts", now)),
                    })
            if sort == "level": out.sort(key=lambda r: (-r["level"], r["name"]))
            elif sort == "power": out.sort(key=lambda r: -r["power"])
            elif sort == "dist": out.sort(key=lambda r: (r["dist"] if r["dist"] is not None else 9e9))
            elif sort == "newest": out.sort(key=lambda r: r["age"])
            total = len(out)
            if limit <= 0:
                page, pages, rows = 1, 1, out
            else:
                pages = max(1, (total + limit - 1) // limit)
                page  = min(page, pages)
                rows  = out[(page-1)*limit : page*limit]
            return self._send(200, json.dumps({"rows": rows, "total": total,
                                               "page": page, "pages": pages, "limit": limit}))
        if u.path == "/api/subcities":
            # Sub-cities capturadas via PlayersManager.addSubCity. Enriquecidas con:
            #  - shield_eta del owner (SHIELD_ETA)
            #  - quality (white/green/blue/purple/gold/red)
            #  - famous_id (>0 = Famous City con cultura, p.ej. Roma)
            #  - occupied: free (npc), ext (jugador otra alianza), mine (jugador mi alianza)
            params = {k: v[0] for k, v in parse_qs(u.query).items()}
            try: cx = float(params.get("cx", "")) if params.get("cx") else None
            except: cx = None
            try: cy = float(params.get("cy", "")) if params.get("cy") else None
            except: cy = None
            try: rad = float(params.get("radius", "")) if params.get("radius") else None
            except: rad = None
            try: lv_min = int(params["lv_min"]) if params.get("lv_min") else None
            except: lv_min = None
            try: lv_max = int(params["lv_max"]) if params.get("lv_max") else None
            except: lv_max = None
            quality_filter = params.get("quality", "")  # "" / "white"/"green"/"blue"/"purple"/"gold"/"red"
            culture_filter = params.get("culture", "")  # "" / "1".."7" (id de cultura)
            famous_filter = params.get("famous", "")    # "" / "yes" / "no"  (no usado en UI ahora pero queda)
            occ = params.get("occupied", "")            # "" / "free" / "ext" / "mine"
            alliance_tag = (params.get("alliance", "") or "").strip().lower()
            try: limit = int(params.get("limit", "500"))
            except: limit = 500
            try: page = max(1, int(params.get("page", "1")))
            except: page = 1
            try: max_seen = float(params["max_seen"]) if params.get("max_seen") else TTL_SECONDS
            except: max_seen = TTL_SECONDS
            sort = params.get("sort", "level")
            now = time.time()
            now_i = int(now)
            my_guilds = {g for g in (SELF["W"]["guild_id"], SELF["E"]["guild_id"]) if g > 0}
            out = []
            with LOCK:
                for sid, s in SUBCITIES.items():
                    if (now - s.get("ts", 0)) > max_seen: continue
                    lv = int(s.get("lv", 0) or 0)
                    if lv_min is not None and lv < lv_min: continue
                    if lv_max is not None and lv > lv_max: continue
                    quality_id = int(s.get("quality", 0) or 0)
                    quality_label = SUBCITY_QUALITY.get(quality_id, f"q{quality_id}")
                    if quality_filter and quality_filter != quality_label: continue
                    culture_id = int(s.get("evony", 0) or 0)
                    culture_label = SUBCITY_CULTURE.get(culture_id, f"Culture {culture_id}" if culture_id else "")
                    if culture_filter and str(culture_id) != culture_filter: continue
                    famous_id = int(s.get("famous_id", 0) or 0)
                    if famous_filter == "yes" and famous_id <= 0: continue
                    if famous_filter == "no"  and famous_id  > 0: continue
                    ouid = int(s.get("owner_uid", 0) or 0)
                    # ogd: guild_id capturado del owner en agent, o fallback a PLAYERS[ouid].gid
                    ogid = int(s.get("owner_guild_id", 0) or 0)
                    if ouid > 0 and ogid == 0:
                        pl0 = PLAYERS.get(ouid) or {}
                        ogid = int(pl0.get("gid", 0) or 0)
                    is_free = (ouid == 0)
                    is_mine = (not is_free) and (ogid in my_guilds) if my_guilds else False
                    is_ext  = (not is_free) and (not is_mine)
                    if occ == "free" and not is_free: continue
                    if occ == "mine" and not is_mine: continue
                    if occ == "ext"  and not is_ext:  continue
                    occ_name = ""; occ_tag = ""
                    if ouid > 0:
                        pl = PLAYERS.get(ouid) or {}
                        occ_name = pl.get("name", "") or s.get("owner_name", "") or ""
                        occ_tag  = (pl.get("tag", "") or "")
                    if alliance_tag and alliance_tag not in occ_tag.lower():
                        continue
                    d = None
                    if cx is not None and cy is not None:
                        d = math.hypot(s["wx"] - cx, s["wy"] - cy)
                        if rad is not None and d > rad: continue
                        d = round(d)
                    # Bubble:
                    #   - shield_tier viene del user_summary del OWNER en cache PLAYERS
                    #     (0=ninguno, 1=item, 2=newbie). Las sub-cities heredan la burbuja
                    #     del owner, asi que el tier del owner indica el estado de bubble.
                    #   - shield_eta: si tenemos exact ETA en SHIELD_ETA[owner_uid].
                    sh_eta = 0; sh_tier = 0
                    if ouid > 0:
                        pl_o = PLAYERS.get(ouid) or {}
                        sh_tier = int(pl_o.get("shield", 0) or 0)
                        e = SHIELD_ETA.get(ouid)
                        if e:
                            et = int(e.get("end_time", 0) or 0)
                            if et > now_i: sh_eta = et - now_i
                    # power final: power_calc (GetPower) si > 0, sino raw, sino 0.
                    # Player-owned suele dar 0 porque el broadcast pasivo no expone
                    # troops/buildings — sólo un scout report del sub-city lo traería.
                    pw_raw = int(s.get("power", 0) or 0)
                    pw_calc = int(s.get("power_calc", 0) or 0)
                    pw_final = pw_calc if pw_calc > 0 else pw_raw
                    pw_src = "calc" if pw_calc > 0 else ("broadcast" if pw_raw > 0 else "")
                    # Fallback: si la subcity es player-owned y no tenemos su power directo,
                    # exponer el power TOTAL del monarca dueño (de PLAYER_POWER / power ranking)
                    # como referencia. NO es el power de esta subcity, pero da una idea del dueño.
                    owner_power = 0
                    if pw_final == 0 and ouid > 0:
                        pp = PLAYER_POWER.get(ouid) or {}
                        owner_power = int(pp.get("power", 0) or 0)
                    out.append({
                        "id": sid, "name": s.get("name", "") or "?",
                        "level": lv, "power": pw_final, "power_src": pw_src,
                        "owner_power": owner_power,
                        "quality": quality_id, "quality_label": quality_label,
                        "culture": culture_id, "culture_label": culture_label,
                        "famous_id": famous_id, "is_famous": famous_id > 0,
                        "shield_tier": sh_tier,
                        "x": s["wx"], "y": s["wy"], "dist": d,
                        "owner_uid": ouid, "occupy_uid": ouid,
                        "occupy_name": occ_name, "occupy_tag": occ_tag,
                        "occupy_status": "free" if is_free else ("mine" if is_mine else "ext"),
                        "shield_eta": sh_eta,
                        "shield_end": int(s.get("shield_end", 0) or 0),
                        "age": int(now - s.get("ts", now)),
                    })
            if sort == "level": out.sort(key=lambda r: (-r["level"], r["name"]))
            elif sort == "quality": out.sort(key=lambda r: (-r["quality"], -r["level"]))
            elif sort == "power":
                # NPCs con power directo primero (desc), luego player-owned por owner_power desc
                out.sort(key=lambda r: (-(r["power"]), -(r.get("owner_power", 0) or 0)))
            elif sort == "dist": out.sort(key=lambda r: (r["dist"] if r["dist"] is not None else 9e9))
            elif sort == "newest": out.sort(key=lambda r: r["age"])
            total = len(out)
            if limit <= 0:
                page, pages, rows = 1, 1, out
            else:
                pages = max(1, (total + limit - 1) // limit)
                page  = min(page, pages)
                rows  = out[(page-1)*limit : page*limit]
            return self._send(200, json.dumps({"rows": rows, "total": total,
                                               "page": page, "pages": pages, "limit": limit}))
        if u.path == "/api/marches":
            now = time.time()
            with LOCK:
                arr = [dict(m) for m in MARCHES.values() if int(m.get("te", 0)) > now]
            arr.sort(key=lambda m: int(m.get("te", 0)))
            return self._send(200, json.dumps({"now": int(now), "n": len(arr), "marches": arr}))
        if u.path == "/api/respawns":
            # [respawns-disabled] predictor desactivado temporalmente (info poco fiable).
            # Devolvemos vacio sin calcular nada para no consumir CPU/memoria.
            return self._send(200, json.dumps({"now": int(time.time()), "n": 0,
                                               "respawns": [], "disabled": True}))
            # --- código original (revertir quitando el return de arriba) ---
            # qs = parse_qs(u.query or "")
            # try: ms = int(qs.get("min_samples", ["2"])[0])
            # except: ms = 2
            # only_empty = qs.get("only_empty", ["1"])[0] != "0"
            # allow_normal = qs.get("allow_normal", ["1"])[0] != "0"
            # try: limit = int(qs.get("limit", ["100"])[0])
            # except: limit = 100
            # ids = set()
            # for s in qs.get("ids", [""])[0].split(","):
            #     s = s.strip()
            #     if s.isdigit(): ids.add(int(s))
            # fams = [f for f in qs.get("fams", [""])[0].split("||") if f]
            # arr = respawn_predictions(min_samples=ms, only_empty=only_empty,
            #                            ids=ids, fams=set(fams), allow_normal=allow_normal)
            # if limit > 0: arr = arr[:limit]
            # return self._send(200, json.dumps({"now": int(time.time()), "n": len(arr), "respawns": arr}))
        if u.path == "/api/relocations":
            qs = parse_qs(u.query or "")
            try: max_h = float(qs.get("max_age_h", ["24"])[0])
            except: max_h = 24.0
            near = None
            cx = qs.get("cx", [""])[0]; cy = qs.get("cy", [""])[0]; rad = qs.get("radius", [""])[0]
            if cx and cy and rad:
                try: near = (float(cx), float(cy), float(rad))
                except: near = None
            try: limit = int(qs.get("limit", ["200"])[0])
            except: limit = 200
            arr = relocations_recent(max_age_h=max_h, near=near)
            if limit > 0: arr = arr[:limit]
            return self._send(200, json.dumps({"now": int(time.time()), "n": len(arr), "relocations": arr}))
        if u.path == "/api/attacks":
            ATTACK_MTYS = {2, 10, 19, 20, 21, 43}   # 2=monster, 10=player-attack directo, 19/20/21=boss-rally fases, 43=scout
            ALLY_MTYS   = {19, 20, 21}
            # ?include_helps=1: NO filtrar reinforces/helps internos de alianza (default: filtra)
            from urllib.parse import parse_qs as _pqs2
            _q = _pqs2(u.query or "")
            include_helps = (_q.get("include_helps", ["0"])[0] in ("1", "true", "yes"))
            PHASE_NAME  = {10: "way", 19: "wait", 20: "way", 21: "combat", 2: "march", 43: "scout"}
            now = time.time()
            out = []
            with LOCK:
                # indice rapido tile -> player (para identificar castillos de jugador)
                tile_pl = {}
                for pl in PLAYERS.values():
                    wx = int(pl.get("wx", 0) or 0); wy = int(pl.get("wy", 0) or 0)
                    if wx and wy: tile_pl[(wx, wy)] = pl
                # labels por mapinfo_type para targets no-monstruo
                T_LABEL = {1:"castle", 3:"farm", 4:"garrison", 5:"ruins",
                           6:"king_city", 7:"sub_city", 8:"snowberg",
                           9:"guild_city", 10:"guild_store", 11:"guild_farm",
                           12:"boss"}
                # Pre-index para detectar relocations (attacker.uid == castle_owner_at(target).uid)
                tile_uid = {(int(p.get("wx", 0) or 0), int(p.get("wy", 0) or 0)): int(p.get("uid", 0) or 0)
                            for p in PLAYERS.values() if int(p.get("wx", 0) or 0)}
                # Indice uid -> guild_id, para detectar reinforces dentro de la misma alianza
                uid_to_gid = {int(p.get("uid", 0) or 0): int(p.get("gid", 0) or 0)
                              for p in PLAYERS.values() if int(p.get("uid", 0) or 0)}
                _o_atk, _f_atk = foreign_breakdown(now)
                enemy_sv_atk = effective_enemy_server(_f_atk)   # server enemigo SVS (para flag por marcha)
                our_sv_atk = our_server()
                relos_filtered = 0    # contador relocations descartadas
                helps_filtered  = 0    # contador reinforces/helps (mismo guild) descartados
                stale_filtered  = 0    # contador marches stale (sin update > STALE_TH s)
                # STALE_TH: una march activa debería refrescarse cada vuelta del sweep (~50s).
                # Si no se actualiza en >120s, lo más probable es que el rally se canceló/completó
                # y el server dejó de broadcastearla. Filtrar como stale para limpiar la UI.
                STALE_TH = 120
                # LANDED_GRACE: seguimos mostrando una marcha hasta 12s DESPUÉS de que
                # aterrice (te<=now). Una marcha de 2s aterriza entre dos polls y, sin
                # esto, se descartaría al instante y nunca se vería. Se marca landed=true.
                LANDED_GRACE = 12

                for (tx, ty), sub in MARCH_BY_TGT.items():
                    ms = []
                    target_owner_uid = tile_uid.get((tx, ty), 0)
                    target_owner_gid = uid_to_gid.get(target_owner_uid, 0)
                    for trp in sub.keys():
                        m = MARCHES.get(trp)
                        if not m: continue
                        mty_v = int(m.get("mty", 0))
                        if mty_v not in ATTACK_MTYS: continue
                        if int(m.get("te", 0)) <= now - LANDED_GRACE: continue
                        # FILTRO STALE (corregido): el server re-broadcastea las marchas de NUESTRA
                        # alianza en vivo (ts_recv fresco), pero NO las de OTRAS alianzas en cada
                        # re-visita — solo al inicio del rally. Así, rallies largos ajenos (eta>120s)
                        # caían como stale aunque siguieran activos. FIX: no descartar si el OBJETIVO
                        # del tile se sigue viendo fresco (<STALE_TH) — el sweep confirma que el target
                        # sigue ahí y el rally no ha aterrizado (te>now ya verificado arriba). Solo se
                        # filtra si NI la marcha NI su objetivo se han visto en STALE_TH (cancelación real).
                        ts_recv = float(m.get("ts_recv", 0) or 0)
                        _to = OBJS.get((tx, ty))
                        _obj_seen = float((_to or {}).get("ts", 0) or 0)
                        if (ts_recv > 0 and (now - ts_recv) > STALE_TH
                                and (_obj_seen == 0 or (now - _obj_seen) > STALE_TH)):
                            stale_filtered += 1
                            continue
                        ow = int(m.get("ow", 0) or 0)
                        ow_gid = uid_to_gid.get(ow, 0)
                        # FILTRO RELOCATION: mty=10 con attacker.uid == castle_owner(target).uid
                        # es un Relocate/Teleport, no un attack. El jugador se está moviendo,
                        # no atacando a nadie. Las coords (tx,ty) son el destino del teleport.
                        if mty_v == 10 and ow > 0 and ow == target_owner_uid:
                            relos_filtered += 1
                            continue
                        # FILTRO REINFORCE/HELP: march cuyo target es un castle de un aliado de
                        # la misma guild que el atacante NO es un attack — es un help/reinforce.
                        # Solo si el target es un PLAYER castle (no monster), guild del atacante > 0,
                        # y guilds coinciden. Skipeable con ?include_helps=1 desde la UI.
                        if (not include_helps
                                and target_owner_uid > 0 and ow_gid > 0 and target_owner_gid > 0
                                and ow_gid == target_owner_gid and ow != target_owner_uid):
                            helps_filtered += 1
                            continue
                        ms.append(m)
                    if not ms: continue
                    # FILTRO ANTI-FANTASMA refinado v3:
                    #  - Grupo con >=2 marches: rally legítimo (gathering en rally point).
                    #  - Grupo con 1 march y src==target:
                    #     · target tile tiene PLAYER castle: ghost individual → descartar
                    #     · target tile NO tiene player (probable boss/empty): rally INICIANDO
                    #       a boss (DulciMea acaba de abrir rally, aún sin helpers) → mantener
                    real_ms = [m for m in ms
                               if int(m.get("sx", 0) or 0) != tx or int(m.get("sy", 0) or 0) != ty]
                    if not real_ms and len(ms) < 2:
                        target_has_player_castle = (tx, ty) in tile_uid
                        if target_has_player_castle:
                            continue   # ghost individual residual
                        # si no hay player en target, probablemente rally iniciando a boss → mantener
                    if real_ms: ms = real_ms   # si hay reales, usarlas para rep (mejor info)
                    ally_ms = [m for m in ms if int(m.get("mty", 0)) in ALLY_MTYS]
                    if ally_ms:
                        by = {}
                        for m in ally_ms: by.setdefault(int(m["mty"]), []).append(m)
                        if   21 in by: rep = min(by[21], key=lambda m: int(m["te"]))
                        elif 20 in by: rep = min(by[20], key=lambda m: int(m["te"]))
                        else:          rep = min(by[19], key=lambda m: int(m["te"]))
                        kind = "alliance"; count = len(ally_ms)
                    else:
                        rep = max(ms, key=lambda m: int(m["te"]))
                        mty = int(rep.get("mty", 0))
                        kind = "scout" if mty == 43 else "solo"
                        count = 1
                    mty = int(rep.get("mty", 0))
                    uid = int(rep.get("ow", 0))
                    pl  = PLAYERS.get(str(uid)) or PLAYERS.get(uid) or {}
                    obj = OBJS.get((tx, ty)) or {}
                    tg_id = int(rep.get("tg", 0) or 0)   # target_id del map_target_info
                    tname = ""; tlevel = 0; tgroup = ""; ttype = 0
                    if obj:
                        ttype = int(obj.get("t", 0))
                        tlevel = obj.get("lv", 0)
                        if ttype == 2:
                            # monstruo NPC -> resolver via CFG
                            c = CFG.get(str(obj.get("id"))) or {}
                            nm = c.get("name", "") or ""
                            tname  = nm or f"id{obj.get('id')}"
                            tgroup = group_of(nm)
                        elif ttype in (1, 7):
                            # castillo de jugador (1) o sub-city (7): buscar dueno por coords
                            tpl = tile_pl.get((tx, ty))
                            if tpl:
                                nm = tpl.get("name", "") or f"u{tpl.get('uid', '?')}"
                                tg = tpl.get("tag", "") or ""
                                player_label = f"[{tg}] {nm}" if tg else nm
                                # Si el march es un boss rally (19/20/21) Y apunta a un castillo,
                                # NO es un boss rally — es un rally PvP contra ese jugador.
                                # Etiquetar correctamente para que el usuario lo entienda.
                                if mty in (19, 20, 21):
                                    tname  = f"Rally vs {player_label}"
                                    tgroup = "Rally PvP"
                                else:
                                    tname  = player_label
                                    tgroup = "Player" if ttype == 1 else "SubCity"
                                tlevel = tpl.get("clv", 0) or tlevel
                            else:
                                tname  = T_LABEL.get(ttype, "?") + f" id{obj.get('id', 0)}"
                                tgroup = "Player" if ttype == 1 else "SubCity"
                        else:
                            lbl = T_LABEL.get(ttype, f"t{ttype}")
                            tname  = lbl
                            tgroup = lbl.capitalize()
                    else:
                        # OBJS no tiene el tile. El server normalmente NO popula __target_id
                        # en los broadcasts de march -> tg_id casi siempre llega 0.
                        # Estrategia de fallbacks:
                        # 1) MONSTERS_BY_TILE (cache LARGO de ultimo monstruo visto en el tile).
                        #    Para boss rallies (mty 19/20/21) ignoramos TTL: un rally puede durar
                        #    horas y el boss puede haberse despawneado del broadcast hace rato.
                        # 2) tg_id + CFG (raro pero posible).
                        # 3) MARCHES_BY_TILE: si otra march activa al mismo (tx,ty) tiene
                        #    target_id resuelto, lo heredamos.
                        # 4) PLAYERS por coords (castillo de jugador).
                        # 5) Label semantico segun mty.
                        mhist = MONSTERS_BY_TILE.get((tx, ty))
                        is_boss_rally = mty in (19, 20, 21)
                        if mhist and (is_boss_rally or (now - mhist.get("ts", 0)) <= MONSTERS_TTL):
                            tname  = mhist["name"]
                            tlevel = mhist.get("lv", 0)
                            tgroup = group_of(mhist["name"])
                            ttype  = 2
                        elif tg_id > 0 and mty in ATTACK_MTYS:
                            c = CFG.get(str(tg_id)) or {}
                            nm = c.get("name", "") or ""
                            if nm:
                                tname  = nm
                                tlevel = c.get("level", 0) or 0
                                tgroup = group_of(nm)
                                ttype  = 2
                        if not tname:
                            # heredar de otras marches activas al mismo tile que SÍ tengan tg_id
                            for m_other in ms:
                                tgo = int(m_other.get("tg", 0) or 0)
                                if tgo > 0:
                                    c = CFG.get(str(tgo)) or {}
                                    nm = c.get("name", "") or ""
                                    if nm:
                                        tname  = nm
                                        tlevel = c.get("level", 0) or 0
                                        tgroup = group_of(nm)
                                        ttype  = 2
                                        break
                        if not tname:
                            tpl = tile_pl.get((tx, ty))
                            if tpl:
                                nm = tpl.get("name", "") or f"u{tpl.get('uid', '?')}"
                                tg = tpl.get("tag", "") or ""
                                player_label = f"[{tg}] {nm}" if tg else nm
                                # Mismo razonamiento: si es boss rally (mty 19/20/21)
                                # apuntando a un player castle, es rally PvP, no boss.
                                if mty in (19, 20, 21):
                                    tname  = f"Rally vs {player_label}"
                                    tgroup = "Rally PvP"
                                else:
                                    tname  = player_label
                                    tgroup = "Player"
                                tlevel = tpl.get("clv", 0)
                                ttype  = 1
                        # Si tras todos los fallbacks aun no resolvimos, dar un label limpio
                        # según la fase del rally (en vez del feo "Boss rally (combat)"):
                        if not tname:
                            rep_eta_pre = max(0, int(rep.get("te", 0) - now))
                            label_by_mty = {
                                2:  "Unknown monster",
                                19: "Boss rally · forming",
                                20: "Boss rally · marching",
                                21: "Boss rally · returning" if rep_eta_pre > 60 else "Boss rally · in combat",
                                43: "Scout (target unscouted)",
                            }
                            tname  = label_by_mty.get(mty, "Unresolved target")
                            tgroup = "Unknown"
                    # Phase fix para mty=21: el server marca todo "combat" desde que las
                    # troops llegan al target hasta que vuelven al castle. Combate real dura
                    # 5-30s; el resto es RETURN. Si eta > 60s, las troops están volviendo,
                    # no peleando — etiquetar como "return".
                    rep_eta = max(0, int(rep.get("te", 0) - now))
                    phase_name = PHASE_NAME.get(mty, "march")
                    if mty == 21 and rep_eta > 60:
                        phase_name = "return"

                    out.append({
                        "tx": tx, "ty": ty,
                        "tname": tname, "tlevel": tlevel, "tgroup": tgroup, "ttype": ttype,
                        # SUMMON (verde, FIABLE): el objetivo es un EVENT MONSTER cuyo owner_id es un
                        # uid real de jugador/guild (>= SUMMON_OWNER_MIN) -> fue INVOCADO. Solo grupo
                        # Event; excluye monstruos Normal con dueño, enums NPC y subcities.
                        "tsummon": bool(tgroup == "Event"
                                        and int((OBJS.get((tx, ty)) or {}).get("own", 0) or 0) >= SUMMON_OWNER_MIN),
                        # tsummon_likely (amarillo): DESACTIVADO (heurística inundaba de falsos positivos).
                        "tsummon_likely": False,
                        "tg_id": tg_id,   # debug: target_id del march, para diagnosticar resolucion
                        "kind": kind, "phase": phase_name, "mty": mty,
                        "count": count,
                        "uid":   uid,
                        "name":  pl.get("name", "") or "",
                        "tag":   pl.get("tag", "") or "",
                        "guild": int(pl.get("gid", 0) or 0),
                        "eta":   max(0, int(rep.get("te", 0) - now)),
                        "te":    int(rep.get("te", 0)),
                        "landed": int(rep.get("te", 0)) <= int(now),   # ya aterrizó (dentro del grace)
                        "sx":    int(rep.get("sx", 0)),
                        "sy":    int(rep.get("sy", 0)),
                        # server donde ocurre la marcha (lo etiqueta el escáner que la capta).
                        # enemy=True -> está pasando en el server enemigo del SVS (p.ej. 1954).
                        "srv":   int(rep.get("srv", 0) or 0),
                        "enemy": bool(enemy_sv_atk > 0 and int(rep.get("srv", 0) or 0) == enemy_sv_atk),
                        # tag de la alianza DUEÑA del tile objetivo (para el filtro por alianza del Focus)
                        "ttag":  (tile_pl.get((tx, ty), {}).get("tag", "") if tile_pl.get((tx, ty)) else ""),
                    })
            # FILTROS de Active Attacks (gobiernan lista Y notificaciones, ambas leen este endpoint):
            #  1) PERMANENTE: ocultar TODA la actividad del server ENEMIGO del SVS (srv==enemy).
            #     Son rallies/solos de los enemigos en su propio server -> no nos interesan.
            #     (Un enemigo atacando NUESTRO server se capta en nuestro srv -> enemy=False -> se ve.)
            #  2) Si el Focus Zone tiene un tag de alianza, mostrar SOLO lo relativo a esa
            #     alianza: atacante CON ese tag o objetivo de ese tag.
            # El tag del Focus Zone YA NO acota la lista por defecto. Antes, con un focus
            # en LAN (p.ej. el preset Defense) o en UN enemigo (NUD), /api/attacks ocultaba
            # TODO ataque que no involucrara a esa alianza -> no se veían los ataques del
            # RESTO de alianzas enemigas (el bug reportado). Ahora el filtrado por alianza es
            # OPT-IN (?focus_filter=1); por defecto se muestran TODOS los ataques (el front ya
            # tiene "exclude our alliance" + buscador para acotar). El tag del Focus se sigue
            # usando para que el scanner RONDE el centroide de esa alianza (focus_scan_thread).
            focus_filter = (_q.get("focus_filter", ["0"])[0] in ("1", "true", "yes"))
            ally_tag = (FOCUS.get("tag") or "").strip().lower() if focus_filter else ""
            filt_enemy = 0; filt_ally = 0; kept = []
            for a in out:
                if a.get("enemy"):
                    filt_enemy += 1
                    continue
                if ally_tag:
                    atag = (a.get("tag") or "").strip().lower()
                    ttag = (a.get("ttag") or "").strip().lower()
                    if atag != ally_tag and ttag != ally_tag:
                        filt_ally += 1
                        continue
                kept.append(a)
            out = kept
            out.sort(key=lambda a: (a["landed"], a["eta"]))
            return self._send(200, json.dumps({"now": int(now), "n": len(out),
                                                "attacks": out,
                                                "relos_filtered": relos_filtered,
                                                "helps_filtered": helps_filtered,
                                                "stale_filtered": stale_filtered,
                                                "enemy_hidden": filt_enemy,
                                                "ally_filtered": filt_ally,
                                                "ally_tag": ally_tag}))
        return self._send(404, "{}")

    def do_POST(self):
        from urllib.parse import urlparse, parse_qs
        u = urlparse(self.path)
        # M3: Content-Length con try (una cabecera no numérica tumbaba el handler) + cota
        # de tamaño (evita alloc gigante / slowloris con Content-Length enorme). Los bodies
        # reales (login, send_coords, prefs) son pequeños.
        try: length = int(self.headers.get("Content-Length", "0") or "0")
        except (ValueError, TypeError): length = 0
        MAX_BODY = 8 * 1024 * 1024
        if length < 0 or length > MAX_BODY:
            return self._send(413, json.dumps({"error": "payload_too_large", "max": MAX_BODY}))
        raw = self.rfile.read(length) if length > 0 else b""
        try: body = json.loads(raw) if raw else {}
        except: body = {}
        # ---- AUTH ----
        if u.path == "/api/login":
            ip = self._client_ip()
            username = (body.get("username") or "").strip()
            # M2: rate-limit por IP Y por USERNAME. La IP sale de X-Real-IP/X-Forwarded-For
            # (cabeceras del proxy, falsificables si se alcanza el bind directamente); el gate
            # por-username corta la fuerza-bruta aunque el atacante rote IPs. Reusa la misma
            # ventana/umbral. Trade-off: un atacante puede bloquear el login de un username
            # conocido durante LOGIN_WINDOW_S (asumible: herramienta privada, equipo pequeño).
            ukey = ("user:" + username.lower()) if username else None
            blocked, retry = _login_blocked(ip)
            if not blocked and ukey:
                ub, ur = _login_blocked(ukey)
                if ub: blocked, retry = True, ur
            if blocked:
                # ANTES del PBKDF2: corta fuerza-bruta y evita gastar CPU.
                b = json.dumps({"error": "too_many_attempts", "retry_after": retry}).encode()
                self.send_response(429)
                self.send_header("Content-Type", "application/json")
                self.send_header("Content-Length", str(len(b)))
                self.send_header("Retry-After", str(retry))
                self.end_headers()
                self.wfile.write(b)
                return
            password = body.get("password") or ""
            rec = _AUTH["users"].get(username)
            ok = bool(rec) and hmac.compare_digest(_pbkdf2(password, rec["salt"]), rec["hash"])
            if not ok:
                _login_record_fail(ip)
                if ukey: _login_record_fail(ukey)
                return self._send(401, json.dumps({"error": "invalid_credentials"}))
            _login_clear(ip)
            if ukey: _login_clear(ukey)
            # Registro persistente del ultimo acceso (sobrevive a logout y reinicios).
            rec["last_login"] = int(time.time())
            rec["last_ip"] = ip
            cookie = make_session_cookie(username, ip=ip, ua=self.headers.get("User-Agent", ""))
            b = json.dumps({"ok": True, "user": username, "role": rec["role"]}).encode()
            self.send_response(200)
            self.send_header("Content-Type", "application/json")
            self.send_header("Content-Length", str(len(b)))
            self.send_header("Set-Cookie",
                f"{SESSION_COOKIE}={cookie}; Path=/; HttpOnly; SameSite=Lax; Max-Age={SESSION_TTL}")
            self.end_headers()
            self.wfile.write(b)
            return
        if u.path == "/api/logout":
            cur = self._current_user()
            if cur:
                end_session(cur[0], sid=_cookie_sid(self._cookies().get(SESSION_COOKIE, "")))   # solo ESTE dispositivo (deja vivas las demás)
            b = json.dumps({"ok": True}).encode()
            self.send_response(200)
            self.send_header("Content-Type", "application/json")
            self.send_header("Content-Length", str(len(b)))
            self.send_header("Set-Cookie", f"{SESSION_COOKIE}=; Path=/; HttpOnly; Max-Age=0")
            self.end_headers()
            self.wfile.write(b)
            return
        user = self._current_user()
        if not user:
            return self._send(401, json.dumps({"error": "not_authenticated"}))
        # Forzar expulsion de un usuario (cerrar su sesion activa) -> SOLO superadmin.
        if u.path == "/api/notif_prefs":
            # Guarda las prefs de notificaciones SOLO del usuario logueado.
            prefs = body.get("prefs")
            if not isinstance(prefs, dict):
                k = body.get("key")
                prefs = {k: body.get("on")} if k else {}
            out = set_notif_prefs(user[0], prefs)
            return self._send(200, json.dumps(out if out is not None else get_notif_prefs(user[0])))
        if u.path == "/api/quick_prefs":
            # Guarda selecciones + ajustes de Shortcuts SOLO del usuario logueado.
            out = set_quick_prefs(user[0], body if isinstance(body, dict) else {})
            return self._send(200, json.dumps(out if out is not None else get_quick_prefs(user[0])))
        if u.path == "/api/directed_scan":
            # Actualiza + dispara el ESCANEO DIRIGIDO (coords foco: pirámides / estructuras de periferia).
            global DIRECTED_COORDS
            cs = body.get("coords")
            if isinstance(cs, list):
                # M5: int() defensivo por-coord. Antes una coord no numérica ({"coords":[["x","y"]]})
                # lanzaba ValueError sin try -> el handler moría con respuesta rota en vez de 400.
                _dc = []
                for c in cs:
                    if not (isinstance(c, (list, tuple)) and len(c) >= 2): continue
                    try: _dc.append((int(c[0]), int(c[1])))
                    except (ValueError, TypeError): continue
                    if len(_dc) >= 40: break
                DIRECTED_COORDS = _dc
                try:
                    with open(DIRECTED_FILE, "w") as f: json.dump({"coords": DIRECTED_COORDS}, f)
                except Exception: pass
            try: _directed_fire(DIRECTED_COORDS)   # disparo inmediato
            except Exception: pass
            return self._send(200, json.dumps({"ok": True, "count": len(DIRECTED_COORDS), "coords": DIRECTED_COORDS}))
        if u.path == "/api/kick":
            if user[1] != "superadmin":
                return self._send(403, json.dumps({"error": "forbidden"}))
            target = (body.get("user") or "").strip()
            if not target or target not in _AUTH.get("users", {}):
                return self._send(404, json.dumps({"error": "user_not_found"}))
            had = target in _AUTH.get("sessions", {})
            end_session(target)   # borra sesion + last_seen -> su cookie deja de valer
            return self._send(200, json.dumps({"ok": True, "user": target, "had_session": had}))
        if u.path == "/api/scanner":
            global SCANNER_PAUSED
            action = (body.get("action") or "").lower()
            # "full_restart" (botón Restart Scanners): admin + superadmin.
            # El resto (pause / restart / hard_reset / nuclear_reset): SOLO superadmin.
            if action != "full_restart" and user[1] != "superadmin":
                return self._send(403, json.dumps({
                    "error": "forbidden",
                    "detail": "Solo SuperAdmin puede pausar o resetear los scanners"}))
            target_half = (body.get("half") or "").upper()   # opcional: "W", "E" o ""
            # Acción 'restart': fuerza reattach del agent en uno o ambos halves.
            # El watchdog lo detecta vía LAST_HEARTBEAT y reattachea solo si pasan
            # 60s sin recibir nada; aquí lo aceleramos seteando heartbeat a 0.
            if action == "restart":
                halves = [target_half] if target_half in ("W", "E") else ["W", "E"]
                forced = []
                for h in halves:
                    LAST_HEARTBEAT[h] = 0.0     # fuerza al watchdog a reattachar inmediatamente
                    forced.append(h)
                print(f"[scanner ctl] restart forzado en {forced}", flush=True)
                return self._send(200, json.dumps({"ok": True, "restarting": forced}))
            if action == "hard_reset":
                # Hard reset: ejecuta en thread propio, INDEPENDIENTE de frida_thread.
                # Antes la UI solo seteaba flags que frida_thread consultaba — si
                # ese hilo estaba colgado (dev.attach/script.load), el reset no
                # llegaba a ejecutarse. Ahora corre desde el handler HTTP.
                halves = [target_half] if target_half in ("W", "E") else ["W", "E"]
                forced = []
                for h in halves:
                    if _run_reset(h, "hard"):
                        forced.append(h)
                print(f"[scanner ctl] HARD-RESET (UI) lanzado en {forced}", flush=True)
                return self._send(200, json.dumps({"ok": True, "hard_resetting": forced}))
            if action == "nuclear_reset":
                # Nuclear reset: kill qemu entero + relanzar emulador. ~3-4 min.
                # Ejecuta en thread propio (igual que hard_reset).
                # SEGURIDAD: requiere half explícito ("W" o "E"), o "BOTH" para ambos.
                # No se acepta string vacío para evitar disparar ambos por error.
                if target_half == "BOTH":
                    halves = ["W", "E"]
                elif target_half in ("W", "E"):
                    halves = [target_half]
                else:
                    return self._send(400, json.dumps({
                        "error": "nuclear_reset requires half='W' | 'E' | 'BOTH' (no empty)"
                    }))
                forced = []
                for h in halves:
                    if _run_reset(h, "nuclear"):
                        forced.append(h)
                print(f"[scanner ctl] ☠ NUCLEAR-RESET (UI) lanzado en {forced}", flush=True)
                return self._send(200, json.dumps({"ok": True, "nuclear_resetting": forced}))
            if action == "full_restart":
                # Botón "Restart Scanners" = cerrar Evony en LOS DOS Pixel y volver a arrancarla
                # (+ frida-server fresco + reattach). Es exactamente el hard reset de ambas mitades,
                # así que reusa _run_reset (thread propio + lock, no se acumulan resets).
                # FIX 2026-08-10: antes lanzaba restart_v4_full.sh = cold-boot de los EMULADORES
                # 6000/6002. Ese script NO existe en el escáner PIXEL -> os.path.exists=False -> 500,
                # y el JS del botón se tragaba el error con un catch vacío y aun así decía
                # "Full restart launched" => el botón "no hacía nada" (reportado en vivo).
                # Una pulsación DEBE reiniciar las DOS mitades. Si una tiene un reset en curso
                # (RESET_LOCK del watchdog), NO se ignora: se ENCOLA y se reinicia en cuanto el
                # cerrojo se libere (bug 2026-08-10: con E congelada el botón solo tocó W).
                forced, queued = [], []
                for h in ("W", "E"):
                    if _run_reset(h, "hard"):
                        forced.append(h)
                    else:
                        threading.Thread(target=_queued_reset, args=(h,),
                                         daemon=True, name=f"queued-reset-{h}").start()
                        queued.append(h)
                print(f"[scanner ctl] ↻ RESTART SCANNERS (UI): ahora={forced or '-'} en cola={queued or '-'}", flush=True)
                _incident("-", "reinicio manual",
                          f"Restart Scanners pulsado: ahora={forced or '-'} · en cola={queued or '-'}")
                return self._send(200, json.dumps({"ok": True, "full_restart": True,
                                                   "restarting": forced, "queued": queued}))
            if action not in ("pause", "resume"):
                return self._send(400, json.dumps({"error": "action must be 'pause'|'resume'|'restart'"}))
            SCANNER_PAUSED = (action == "pause")
            if action == "resume":
                # anti-freeze: al reanudar, reinicia el baseline de objetos en ambos halves. Si no,
                # last_obj_ts quedaría viejo (de antes de la pausa) y el watchdog lo tomaría por FREEZE.
                for _h in ("W", "E"):
                    try: SCAN_STATS[_h]["last_obj_ts"] = time.time()
                    except Exception: pass
            sent = []
            for half, sc in SCRIPTS.items():
                if sc is None: continue
                try:
                    sc.post({"type": "ctl", "cmd": action})
                    sent.append(half)
                except Exception as e:
                    print(f"[scanner ctl] {half} post fail: {e}", flush=True)
            print(f"[scanner ctl] {action} -> {sent}", flush=True)
            return self._send(200, json.dumps({"ok": True, "paused": SCANNER_PAUSED, "halves": sent}))
        if u.path == "/api/send_coords":
            # Send Coords: whisper a [LAN] Lume con el top-20 por distancia de los
            # monstruos SELECCIONADOS por el usuario (body.names). Disponible para
            # cualquier usuario autenticado (admin + superadmin). El envio fisico lo
            # hace el agent (half W) via send_whisper.
            import uuid as _uuid
            # Selección del usuario: picks = [{name, level}] (nivel real obj.lv). Si
            # viene vacía -> fallback legacy (Cerberus/Bayard siempre + events detectados).
            pickset = set()
            for p in (body.get("picks") or []):
                try: pickset.add((str(p["name"]).strip(), int(p["level"])))
                except Exception: pass
            now = time.time()
            our_sv_sc = our_server()
            out = []
            _explicit = body.get("coords")   # coords EXPLÍCITAS (p.ej. Pyramids/reliquias, que NO son type=2): enviarlas tal cual
            _use_explicit = isinstance(_explicit, list) and bool(_explicit)
            if _use_explicit:
                for _c in _explicit[:60]:
                    try:
                        _wx = int(_c.get("x", 0) or 0); _wy = int(_c.get("y", 0) or 0)
                        if not _wx or not _wy: continue
                        out.append({"name": (str(_c.get("name", "") or "")[:40]), "level": int(_c.get("level", 0) or 0),
                                    "x": _wx, "y": _wy, "dist": math.hypot(_wx - SEND_COORDS_CX, _wy - SEND_COORDS_CY)})
                    except Exception: pass
            with LOCK:
                for o in OBJS.values():
                    if _use_explicit: break   # out ya construido con coords explícitas -> no resolver por picks
                    if int(o.get("t", 0)) != 2: continue
                    # SOLO monstruos de NUESTRO server (en SVS el escáner E ve 1954;
                    # esas coords no existen en nuestro mapa -> nunca enviarlas).
                    if o.get("srv") and int(o["srv"]) != our_sv_sc: continue
                    if not _onmap_ok(o, now): continue   # 2026-08-12: SPLIT por grupo (300/900) = /api/data (SEND COORDS envía lo mismo que ves en el buscador)
                    c = CFG.get(str(o["id"])) or {}
                    nm = c.get("name", "")
                    lv = int(o.get("lv", 0) or 0)
                    if lv <= 0: lv = int(c.get("level", 0) or 0)   # nivel efectivo (= /api/sc_monsters)
                    if pickset:
                        if (nm, lv) not in pickset: continue       # selección exacta nombre+nivel
                    elif nm in SEND_COORDS_NAMES:
                        pass
                    elif nm in SEND_COORDS_EVENTS and lv in SEND_COORDS_EVENTS[nm]:
                        pass
                    else:
                        continue
                    wx = int(o.get("wx", 0) or 0); wy = int(o.get("wy", 0) or 0)
                    if wx == 0 and wy == 0: continue
                    dist = math.hypot(wx - SEND_COORDS_CX, wy - SEND_COORDS_CY)
                    out.append({"name": nm, "level": lv, "x": wx, "y": wy, "dist": dist})
            # orden: distancia asc, luego nivel mas bajo asc (pool mezclado)
            out.sort(key=lambda r: (r["dist"], r["level"]))
            try: _sc_lim = int(body.get("limit", 20) or 20)
            except Exception: _sc_lim = 20
            top = out[:max(1, min(_sc_lim, 50))]   # Shortcuts: respeta el Results del usuario (cap de seguridad 50)
            if not top:
                return self._send(404, json.dumps({
                    "error": "no_targets",
                    "detail": "No monsters of the selected types are visible right now"}))
            # Destinatario: el nombre in-game está MAPEADO al usuario logueado
            # (campo player_name en users.json). Resolvemos su uid (preferente en
            # NUESTRA alianza: member-list o PLAYERS con guild propio) y el whisper le
            # llega a ÉL. Si el usuario no tiene player_name -> fallback a Lume.
            # CUALQUIER admin puede elegir el destinatario (recipient_uid de un miembro de
            # la alianza) -> el whisper va a ESE jugador. Sin selección, va a SU PROPIO
            # jugador, resuelto por player_uid (estable; el nombre cambia constantemente).
            try: sel_uid = int(body.get("recipient_uid", 0) or 0)
            except Exception: sel_uid = 0
            urec = _AUTH["users"].get(user[0]) or {}
            recipient_name = ""
            target_uid = 0
            if sel_uid > 0:
                target_uid = sel_uid
                with LOCK:
                    for gid in {g for g in (SELF["W"]["guild_id"], SELF["E"]["guild_id"]) if g > 0}:
                        for m in (GUILD_MEMBERS.get(gid) or {}).get("members", []):
                            if int(m.get("uid", 0) or 0) == sel_uid:
                                recipient_name = m.get("name", ""); break
                        if recipient_name: break
                if not recipient_name:
                    pp = PLAYERS.get(sel_uid) or PLAYERS.get(str(sel_uid)) or {}
                    recipient_name = pp.get("name", "") or f"u{sel_uid}"
            elif int(urec.get("player_uid", 0) or 0) > 0:
                # destinatario = el propio usuario, por uid estable
                target_uid = int(urec["player_uid"])
                with LOCK:
                    pp = PLAYERS.get(target_uid) or PLAYERS.get(str(target_uid)) or {}
                recipient_name = pp.get("name", "") or (urec.get("player_name") or f"u{target_uid}")
            else:
                # fallback legacy: resolver por player_name
                recipient_name = (urec.get("player_name") or "").strip()
                target_uid = SEND_COORDS_TARGET_UID
            if target_uid == SEND_COORDS_TARGET_UID and recipient_name:
                tl = recipient_name.lower()
                target_uid = 0
                with LOCK:
                    my_guilds = {g for g in (SELF["W"]["guild_id"], SELF["E"]["guild_id"]) if g > 0}
                    for gid in my_guilds:                      # 1) member-list de la alianza (fiable)
                        for m in (GUILD_MEMBERS.get(gid) or {}).get("members", []):
                            if (m.get("name") or "").strip().lower() == tl:
                                target_uid = int(m.get("uid", 0) or 0); break
                        if target_uid: break
                    if not target_uid:                         # 2) PLAYERS (prioriza mismo guild)
                        cand_any = 0
                        for p in PLAYERS.values():
                            if (p.get("name") or "").strip().lower() != tl: continue
                            uid = int(p.get("uid", 0) or 0)
                            if uid <= 0: continue
                            if int(p.get("gid", 0) or 0) in my_guilds: target_uid = uid; break
                            if not cand_any: cand_any = uid
                        if not target_uid: target_uid = cand_any
                if not target_uid:
                    return self._send(404, json.dumps({"error": "recipient_not_found",
                        "detail": f"No encontré a '{recipient_name}' en la alianza/mapa. ¿Está visible/escaneado?"}))
            # El linkifier de Evony solo hace clicable 1 de cada 2 coordenadas
            # cuando hay varias en un mismo mensaje (cuenta tokens y alterna), y los
            # "puentes" para forzarlas o bien dejan un objetivo de misclick malo
            # (0,0) o bien desplazan el destino. Solución limpia y sin quirks: enviar
            # CADA coordenada en su PROPIO whisper. Con una sola coord por mensaje
            # siempre es clicable, el destino es siempre correcto y no hay puentes
            # que tocar por error. Coste: 10 mensajes (uno por línea).
            lines = [f"{r['x']},{r['y']} {r['name']} {r['level']} ({round(r['dist'])} km)" for r in top]
            sc = SCRIPTS.get(SEND_COORDS_SENDER_HALF)
            if sc is None:
                return self._send(503, json.dumps({
                    "error": "agent_unavailable",
                    "detail": f"Scanner {SEND_COORDS_SENDER_HALF} no esta conectado"}))
            sent = 0; cids = []
            for idx, r in enumerate(top):
                cid = str(_uuid.uuid4())
                des = f"{r['name']} {r['level']} ({round(r['dist'])} km)"
                try:
                    # coord CLICABLE en iOS y Android: send_coord_message nativo (type=6), no texto plano
                    sc.post({"type": "ctl", "cmd": "send_coord_whisper",
                             "uid": target_uid, "x": int(r['x']), "y": int(r['y']),
                             "level": int(r['level']), "des": des, "client_id": cid})
                    sent += 1; cids.append(cid)
                except Exception as e:
                    print(f"[send_coords] post fail línea {idx}: {e}", flush=True)
                # delay entre mensajes: evita rate-limit del juego, respeta el orden
                # y da tiempo al agent a re-armar el recv (one-shot) entre envíos.
                if idx < len(top) - 1:
                    time.sleep(0.6)
            print(f"[send_coords] {sent}/{len(lines)} whispers -> uid={target_uid} "
                  f"recipient={recipient_name or 'Lume(default)'}", flush=True)
            if sent == 0:
                return self._send(500, json.dumps({"error": "post_failed",
                    "detail": "No se pudo enviar ningún whisper"}))
            return self._send(200, json.dumps({
                "ok": True, "count": sent, "uid": target_uid,
                "recipient": recipient_name,
                "client_ids": cids, "text": "\n".join(lines)}))
        if u.path == "/api/svs_share":
            # SOLO superadmin. Comparte por whisper el top-N de enemigos ABIERTOS
            # (atacables ahora) del hit-list. 1 coordenada por whisper (como Send
            # Coords) para que cada coord sea clicable. Destino: Lume (de prueba).
            if user[1] != "superadmin":
                return self._send(403, json.dumps({"error": "forbidden",
                    "detail": "Only SuperAdmin can share targets"}))
            import uuid as _uuid
            try: topn = int(body.get("limit", 10) or 10)
            except Exception: topn = 10
            topn = max(1, min(topn, 20))
            now = time.time()
            cand = []
            with LOCK:
                _ours, _foreign = foreign_breakdown(now)
                enemy_sv = effective_enemy_server(_foreign)
                if enemy_sv <= 0 and not (ENEMY_GUILD_TAGS or ENEMY_GUILD_IDS):
                    return self._send(409, json.dumps({"error": "no_enemy",
                        "detail": "No active enemy. Start an SVS or set an enemy server/tag first."}))
                for p in PLAYERS.values():
                    if now - p.get("ts", 0) > STALE_SECONDS * 4: continue
                    if not is_enemy(p, enemy_sv): continue
                    uid = int(p.get("uid", 0) or 0)
                    wx = int(p.get("wx", 0) or 0); wy = int(p.get("wy", 0) or 0)
                    if wx == 0 and wy == 0: continue
                    tier = int(p.get("shield", 0) or 0)
                    ei = SHIELD_ETA.get(uid)
                    eta = int(ei["end_time"] - now) if (ei and ei.get("end_time", 0) > now) else 0
                    if not (tier == 0 and eta == 0): continue   # solo ABIERTOS (atacables)
                    pw = int(p.get("power", 0) or 0); ppi = PLAYER_POWER.get(uid)
                    if ppi and ppi.get("power", 0) > 0: pw = int(ppi["power"])
                    elif MEMBER_INFO.get(uid, {}).get("power", 0) > 0: pw = int(MEMBER_INFO[uid]["power"])
                    cand.append({"name": p.get("name", ""), "tag": p.get("tag", ""),
                                 "x": wx, "y": wy, "pw": pw})
            cand.sort(key=lambda r: -r["pw"])   # más fuertes primero
            top = cand[:topn]
            if not top:
                return self._send(404, json.dumps({"error": "no_open_targets",
                    "detail": "No open (shieldless) enemies visible right now"}))
            def _fmt(r):
                ally = f"[{r['tag']}] " if r.get("tag") else ""
                pw_txt = f" - {round(r['pw']/1e6,1)}M" if r['pw'] > 0 else ""
                return f"{r['x']},{r['y']} {ally}{r['name']}{pw_txt}"
            lines = [_fmt(r) for r in top]
            sc = SCRIPTS.get(SEND_COORDS_SENDER_HALF)
            if sc is None:
                return self._send(503, json.dumps({"error": "agent_unavailable",
                    "detail": f"Scanner {SEND_COORDS_SENDER_HALF} not connected"}))
            sent = 0
            for idx, line in enumerate(lines):
                try:
                    sc.post({"type": "ctl", "cmd": "send_whisper",
                             "uid": SVS_SHARE_TARGET_UID, "text": line, "client_id": str(_uuid.uuid4())})
                    sent += 1
                except Exception as e:
                    print(f"[svs_share] post fail línea {idx}: {e}", flush=True)
                if idx < len(lines) - 1: time.sleep(0.6)
            print(f"[svs_share] {sent}/{len(lines)} whispers -> uid={SVS_SHARE_TARGET_UID}", flush=True)
            if sent == 0:
                return self._send(500, json.dumps({"error": "post_failed",
                    "detail": "Could not send any whisper"}))
            return self._send(200, json.dumps({"ok": True, "count": sent,
                "uid": SVS_SHARE_TARGET_UID, "text": "\n".join(lines)}))
        if u.path == "/api/enemy_config":
            # SOLO superadmin. body: {enemy_server:int, enemy_tags:[...], enemy_gids:[...]}
            # enemy_server=0 -> modo auto (server != nuestro). >0 -> fija ese server.
            if user[1] != "superadmin":
                return self._send(403, json.dumps({"error": "forbidden",
                    "detail": "Solo SuperAdmin puede editar la config de enemigo"}))
            set_enemy_cfg(body.get("enemy_server", 0), body.get("enemy_tags"), body.get("enemy_gids"))
            print(f"[enemy] cfg actualizada: server={ENEMY_SERVER_OVERRIDE or 'auto'} "
                  f"tags={sorted(ENEMY_GUILD_TAGS)} gids={sorted(ENEMY_GUILD_IDS)}", flush=True)
            return self._send(200, json.dumps({
                "ok": True, "our_server": our_server(),
                "enemy_server": ENEMY_SERVER_OVERRIDE,
                "mode": ("override" if ENEMY_SERVER_OVERRIDE > 0 else "auto"),
                "enemy_tags": sorted(ENEMY_GUILD_TAGS),
                "enemy_gids": sorted(ENEMY_GUILD_IDS)}))
        if u.path == "/api/focus":
            # Disponible para cualquier usuario autenticado (admin + superadmin).
            # body: {active:bool, half:"W"|"E", cx,cy:int, radius:int, tag:str}
            # PRESET "defense": ronda NUESTRA alianza (cobertura defensiva). Resuelve nuestro
            # tag desde el guild_id del escáner -> el centroide se calcula solo (como con enemigos).
            if (body.get("preset") or "") == "defense":
                my_g = next((g for g in (SELF["W"]["guild_id"], SELF["E"]["guild_id"]) if g > 0), 0)
                my_tag = ((GUILD_MEMBERS.get(my_g, {}) or {}).get("tag", "") or "").strip() if my_g else ""
                FOCUS["active"] = True
                h = (body.get("half") or FOCUS.get("half") or "W").upper()
                FOCUS["half"] = h if h in ("W", "E") else "W"
                FOCUS["tag"] = my_tag
                FOCUS["cx"] = 0; FOCUS["cy"] = 0
                try: FOCUS["radius"] = max(60, min(int(body.get("radius", 250) or 250), FOCUS_MAX_RADIUS))
                except Exception: FOCUS["radius"] = 250
                _urec = _AUTH["users"].get(user[0]) or {}
                FOCUS["by"] = (_urec.get("player_name") or user[0] or "").strip()
                save_focus_cfg()
                print(f"[focus] DEFENSE preset por {FOCUS['by']!r} -> tag={my_tag!r} half={FOCUS['half']} r={FOCUS['radius']}", flush=True)
                return self._send(200, json.dumps({"ok": True, **FOCUS, "preset": "defense", "resolved_tag": my_tag}))
            FOCUS["active"] = bool(body.get("active"))
            h = (body.get("half") or "W").upper()
            FOCUS["half"] = h if h in ("W", "E") else "W"
            try: FOCUS["cx"] = int(body.get("cx", 0) or 0)
            except Exception: FOCUS["cx"] = 0
            try: FOCUS["cy"] = int(body.get("cy", 0) or 0)
            except Exception: FOCUS["cy"] = 0
            try: FOCUS["radius"] = max(0, min(int(body.get("radius", 60) or 60), FOCUS_MAX_RADIUS))
            except Exception: FOCUS["radius"] = 60
            FOCUS["tag"] = (body.get("tag") or "").strip()
            # registrar quién fue el último en cambiarlo (player_name, o username)
            _urec = _AUTH["users"].get(user[0]) or {}
            FOCUS["by"] = (_urec.get("player_name") or user[0] or "").strip()
            save_focus_cfg()   # persistente + global (sobrevive reinicios)
            print(f"[focus] {'ON' if FOCUS['active'] else 'OFF'} half={FOCUS['half']} "
                  f"center=({FOCUS['cx']},{FOCUS['cy']}) r={FOCUS['radius']} tag={FOCUS['tag']!r}", flush=True)
            return self._send(200, json.dumps({"ok": True, **FOCUS}))
        if u.path == "/api/scanner_fps":
            if user[1] != "superadmin":   # M4: cambiar el fps de la flota = solo superadmin (como /api/scanner)
                return self._send(403, json.dumps({"error": "forbidden", "detail": "Solo SuperAdmin puede cambiar el fps del scanner"}))
            # Tunea EN CALIENTE el targetFrameRate del juego en los emuladores del scanner (ahorro de CPU).
            # V4 no necesita render: 30=full/original, ~10=equilibrio (CPU ~mitad, escaneo ~V3), menos = más
            # ahorro pero escaneo más lento. Persiste en scanner_config.json y lo aplica a los halves vivos.
            try: fps = max(0, min(60, int(body.get("fps"))))
            except Exception: return self._send(400, json.dumps({"error": "bad_fps"}))
            SCANNER_CFG["fps"] = fps; save_scanner_cfg()
            applied = {h: _apply_fps(h, fps) for h in ("W", "E") if FRIDA_SESSIONS.get(h)}
            return self._send(200, json.dumps({"ok": True, "fps": fps, "applied": applied}))
        if u.path == "/api/scanner_profile":
            if user[1] != "superadmin":   # M4: cambiar el perfil (region/server) de la flota = solo superadmin
                return self._send(403, json.dumps({"error": "forbidden", "detail": "Solo SuperAdmin puede cambiar el perfil del scanner"}))
            # Cambia el perfil de escáner ACTIVO en caliente (sin recompilar): aplica
            # region+server a los agentes vivos via scan_cfg. body: {name} o {save_as, profile}.
            name = (body.get("name") or "").strip()
            # opcional: guardar/actualizar un perfil ad-hoc (body.profile = {"W":{...},"E":{...}})
            if body.get("save_as") and isinstance(body.get("profile"), dict):
                SCANNER_CFG.setdefault("profiles", {})[str(body["save_as"]).strip()] = body["profile"]
                save_scanner_cfg()
                if not name: name = str(body["save_as"]).strip()
            if name not in SCANNER_CFG.get("profiles", {}):
                return self._send(400, json.dumps({"error": "unknown_profile",
                    "available": list(SCANNER_CFG.get("profiles", {}))}))
            SCANNER_CFG["active"] = name
            # AUTO-REVERT opcional: volver a otro perfil tras N horas (robusto: persistido
            # y comprobado por scanner_revert_thread; sobrevive reinicios del backend).
            try: rev_h = float(body.get("revert_hours", 0) or 0)
            except Exception: rev_h = 0
            if rev_h > 0:
                SCANNER_CFG["revert_to"] = str(body.get("revert_to") or "home")
                SCANNER_CFG["revert_at"] = time.time() + rev_h * 3600
            else:
                # aplicar un perfil sin revert (p.ej. volver a home manualmente) limpia el plan
                SCANNER_CFG.pop("revert_to", None); SCANNER_CFG.pop("revert_at", None)
            save_scanner_cfg()
            push_scan_cfg()   # aplica a ambos escáneres vivos en caliente
            ra = SCANNER_CFG.get("revert_at", 0)
            print(f"[scanner-cfg] perfil activo -> {name} (en caliente)"
                  + (f" · auto-revert a {SCANNER_CFG.get('revert_to')} en {rev_h}h" if ra else ""), flush=True)
            return self._send(200, json.dumps({"ok": True, "active": name,
                "live": {h: scan_cfg_for(h) for h in ("W", "E")},
                "revert_to": SCANNER_CFG.get("revert_to"), "revert_at": SCANNER_CFG.get("revert_at", 0)}))
        if u.path == "/api/watchlist":
            # Anade uid al watchlist. body = {"uid": int, "note": "...opcional..."}
            try:
                uid = int(body.get("uid", 0) or 0)
            except: uid = 0
            if uid <= 0:
                return self._send(400, json.dumps({"error": "uid required (int > 0)"}))
            with LOCK:
                WATCHLIST.add(uid)
                WATCHLIST_BLACKLIST.discard(uid)   # si lo anades manual, quita de blacklist
                note = (body.get("note") or "").strip()[:200]
                WATCHLIST_NOTES[uid] = {"note": note, "added_ts": int(time.time()), "source": "manual"}
            print(f"[watchlist] +{uid} manual ({note or 'no note'})", flush=True)
            return self._send(200, json.dumps({"ok": True, "uid": uid, "watchlist_size": len(WATCHLIST)}))
        if u.path == "/api/watchlist/rules":
            # Body: subconjunto de WATCHLIST_RULES. Solo claves conocidas se aceptan.
            allowed = {"enabled", "enemy_tags", "exclude_tags", "min_castle", "min_power_M", "require_shield_seen"}
            with LOCK:
                for k, v in (body or {}).items():
                    if k not in allowed: continue
                    if k in ("enemy_tags", "exclude_tags"):
                        if not isinstance(v, list): continue
                        WATCHLIST_RULES[k] = [str(x).strip().upper() for x in v if str(x).strip()]
                    elif k == "enabled":
                        WATCHLIST_RULES[k] = bool(v)
                    elif k == "require_shield_seen":
                        WATCHLIST_RULES[k] = bool(v)
                    else:
                        try: WATCHLIST_RULES[k] = float(v) if k == "min_power_M" else int(v)
                        except: pass
            print(f"[watchlist] rules updated: {WATCHLIST_RULES}", flush=True)
            return self._send(200, json.dumps(WATCHLIST_RULES))
        if u.path == "/api/watchlist/blacklist":
            # Descarta uid: lo quita del watchlist + impide que el worker lo re-anada.
            try: uid = int(body.get("uid", 0) or 0)
            except: uid = 0
            if uid <= 0:
                return self._send(400, json.dumps({"error": "uid required"}))
            with LOCK:
                WATCHLIST.discard(uid)
                WATCHLIST_NOTES.pop(uid, None)
                WATCHLIST_BLACKLIST.add(uid)
            print(f"[watchlist] BLACKLIST +{uid}", flush=True)
            return self._send(200, json.dumps({"ok": True, "uid": uid,
                                               "watchlist_size": len(WATCHLIST),
                                               "blacklist_size": len(WATCHLIST_BLACKLIST)}))
        if u.path == "/api/shield_history/clear":
            # Limpia SHIELD_HISTORY[uid] + SHIELD_ETA[uid] + SHIELD_LAST_TRANSITION[uid].
            # Util si el histo se envenena por flicker (mediana corrupta).
            try: uid = int(body.get("uid", 0) or 0)
            except: uid = 0
            if uid <= 0:
                return self._send(400, json.dumps({"error": "uid required"}))
            with LOCK:
                had_hist = uid in SHIELD_HISTORY
                SHIELD_HISTORY.pop(uid, None)
                SHIELD_ETA.pop(uid, None)
                SHIELD_LAST_TRANSITION.pop(uid, None)
            print(f"[shield-history] cleared uid={uid} (had_history={had_hist})", flush=True)
            return self._send(200, json.dumps({"ok": True, "uid": uid, "cleared_history": had_hist}))
        if u.path == "/api/protocol_stats/reset":
            with LOCK:
                PROTO_STATS.clear()
            return self._send(200, json.dumps({"ok": True}))
        if u.path == "/api/watchlist/reset_auto":
            # Borra todos los uids con source='auto'. Conserva los manuales y NO los anade a blacklist
            # (asi se pueden re-anadir si reglas cambian para incluirlos).
            removed = []
            with LOCK:
                for uid in list(WATCHLIST):
                    src = (WATCHLIST_NOTES.get(uid) or {}).get("source", "manual")
                    if src == "auto":
                        WATCHLIST.discard(uid)
                        WATCHLIST_NOTES.pop(uid, None)
                        removed.append(uid)
            print(f"[watchlist] reset_auto: removed {len(removed)} (kept {len(WATCHLIST)} manuals)", flush=True)
            return self._send(200, json.dumps({"ok": True, "removed": len(removed), "watchlist_size": len(WATCHLIST)}))
        return self._send(404, "{}")

    def do_DELETE(self):
        from urllib.parse import urlparse, parse_qs
        u = urlparse(self.path)
        if u.path == "/api/watchlist":
            params = {k: v[0] for k, v in parse_qs(u.query).items()}
            # Borrado en bloque por tag de alianza: ?tag=MRC elimina todos los del watchlist
            # cuyo tag coincida (case-insensitive). Útil para limpiar adds masivos por error.
            tag_q = (params.get("tag", "") or "").strip().upper()
            if tag_q:
                removed = []
                with LOCK:
                    for uid in list(WATCHLIST):
                        pl = PLAYERS.get(uid) or PLAYERS.get(str(uid)) or {}
                        if (pl.get("tag", "") or "").upper() == tag_q:
                            WATCHLIST.discard(uid)
                            WATCHLIST_NOTES.pop(uid, None)
                            removed.append(uid)
                print(f"[watchlist] bulk -{len(removed)} (tag={tag_q})", flush=True)
                return self._send(200, json.dumps({"ok": True, "tag": tag_q,
                                                   "removed": len(removed),
                                                   "watchlist_size": len(WATCHLIST)}))
            try: uid = int(params.get("uid", 0) or 0)
            except: uid = 0
            if uid <= 0:
                return self._send(400, json.dumps({"error": "uid o tag query param required"}))
            with LOCK:
                WATCHLIST.discard(uid)
                WATCHLIST_NOTES.pop(uid, None)
            print(f"[watchlist] -{uid}", flush=True)
            return self._send(200, json.dumps({"ok": True, "uid": uid, "watchlist_size": len(WATCHLIST)}))
        return self._send(404, "{}")

def prune_thread():
    """Purga objetos no re-vistos en TTL_SECONDS (auto-expira muertos -> mejor que iScout)."""
    while True:
        time.sleep(PRUNE_EVERY)
        now = time.time()
        cutoff = now - TTL_SECONDS
        with LOCK:
            dead = [k for k, o in OBJS.items() if o.get("ts", 0) < cutoff]
            for k in dead:
                del OBJS[k]
            # RAM CAP: si tras purgar por TTL aún hay > OBJS_MAX, evicta los de ts MÁS VIEJO.
            # heapq.nsmallest es O(N log over) -> barato en régimen (over pequeño); solo la 1ª vez
            # que se supera el cap hace una evicción grande. Los on-map (ts fresco) NO se tocan.
            if len(OBJS) > OBJS_MAX:
                over = len(OBJS) - OBJS_MAX
                for k, _ in heapq.nsmallest(over, OBJS.items(), key=lambda kv: kv[1].get("ts", 0)):
                    del OBJS[k]
            rdead = [k for k, o in RUINS.items() if o.get("ts", 0) < now - 900]   # ruinas/pirámides no re-vistas en 15min -> desaparecidas
            for k in rdead:
                del RUINS[k]
            pdead = [k for k, p in PLAYERS.items() if p.get("ts", 0) < cutoff]
            for k in pdead:
                del PLAYERS[k]
            # marchas expiradas (end_time <= now-30s): se eliminan
            mdead = [trp for trp, m in MARCHES.items() if int(m.get("te", 0)) <= now - 30]
            for trp in mdead:
                m = MARCHES.pop(trp, None)
                if m:
                    key = (int(m.get("tx", 0)), int(m.get("ty", 0)))
                    sub = MARCH_BY_TGT.get(key)
                    if sub is not None:
                        sub.pop(trp, None)
                        if not sub: MARCH_BY_TGT.pop(key, None)
            # sub-cities/shield-cache: purga sub-cities no re-vistas, y ETAs vencidas.
            scdead = [k for k, s in SUBCITIES.items() if s.get("ts", 0) < cutoff]
            for k in scdead:
                del SUBCITIES[k]
            shdead = [k for k, e in SHIELD_ETA.items() if int(e.get("end_time", 0)) <= int(now)]
            for k in shdead:
                del SHIELD_ETA[k]
                if isinstance(k, int):
                    SHIELD_ALERTED.discard(k)   # caducado: rearmar alerta si vuelve a aparecer
            # 2026-08-15: PLAYER_POWER YA NO se purga por TTL. El power se guarda GLOBALMENTE (aunque
            # quede viejo) hasta que se REFRESCA al abrir Monarch Power Ranking in-game (el hook
            # power_rank_reply sobrescribe PLAYER_POWER[uid] con el valor nuevo). Así no hay que reabrir
            # el ranking cada día para ver la columna Power.
            # MONSTERS_BY_TILE: cache LARGO (1h) para resolver targets de attacks
            mbtdead = [k for k, v in MONSTERS_BY_TILE.items() if v.get("ts", 0) < now - MONSTERS_TTL]
            for k in mbtdead:
                del MONSTERS_BY_TILE[k]
            # ── Datos conductuales/anti-cheat: crecen sin tope en nº de uids. Retención
            # LARGA (14 días) para que la actividad/cadencia madure, pero limpian muertos.
            ACT_RETAIN = 14 * 86400
            act_cut = now - ACT_RETAIN
            padead = [uid for uid, a in PLAYER_ACT.items() if float(a.get("last", 0) or 0) < act_cut]
            for uid in padead:
                PLAYER_ACT.pop(uid, None); ACTION_TIMES.pop(uid, None)
                SPEED_STATS.pop(uid, None); _ACT_SEEN_MARCH.discard(uid)
            # huérfanos (uid en ACTION_TIMES/SPEED_STATS sin PLAYER_ACT)
            for uid in [u for u in ACTION_TIMES if u not in PLAYER_ACT]:
                ACTION_TIMES.pop(uid, None)
            for uid in [u for u in SPEED_STATS if u not in PLAYER_ACT]:
                SPEED_STATS.pop(uid, None)
            # RELOCATIONS: purga uids cuyo último evento es muy viejo
            rldead = [uid for uid, r in RELOCATIONS.items()
                      if not r or float(r[-1].get("ts", 0) or 0) < act_cut]
            for uid in rldead:
                RELOCATIONS.pop(uid, None)
            # MEMBER_INFO/MEMBER_LASTSEEN: power/online de member-lists; >3 días sin refresco = stale
            mi_cut = now - 3 * 86400
            midead = [uid for uid, v in MEMBER_INFO.items() if float(v.get("ts", 0) or 0) < mi_cut]
            for uid in midead:
                MEMBER_INFO.pop(uid, None); MEMBER_LASTSEEN.pop(uid, None)
            # CAP DURO de seguridad: si PLAYER_ACT supera el tope, suelta los más antiguos
            ACT_MAX = 60000
            if len(PLAYER_ACT) > ACT_MAX:
                oldest = sorted(PLAYER_ACT.items(), key=lambda kv: float(kv[1].get("last", 0) or 0))
                for uid, _ in oldest[:len(PLAYER_ACT) - ACT_MAX]:
                    PLAYER_ACT.pop(uid, None); ACTION_TIMES.pop(uid, None); SPEED_STATS.pop(uid, None)
            if padead or rldead or midead:
                print(f"[prune] anticheat: -{len(padead)} PLAYER_ACT / -{len(rldead)} RELOC / "
                      f"-{len(midead)} MEMBER_INFO (quedan {len(PLAYER_ACT)} act, {len(RELOCATIONS)} reloc, "
                      f"{len(ACTION_TIMES)} cadencia, {len(SPEED_STATS)} speed)", flush=True)
        if dead or pdead or mdead or scdead or shdead:
            print(f"[prune] -{len(dead)} objs / -{len(pdead)} players / -{len(mdead)} marchas / "
                  f"-{len(scdead)} subcity / -{len(shdead)} shieldETA, "
                  f"quedan {len(OBJS)}/{len(PLAYERS)}/{len(MARCHES)}/{len(SUBCITIES)}/{len(SHIELD_ETA)}", flush=True)

def save_cache(include_objs=False):
    """Volcado atómico (write tmp + rename) de los caches en memoria a JSON.
    Las keys con tuplas (wx,wy) se convierten a "wx,wy" para serialización.
    include_objs=True (OPCIÓN B, solo desde el handler de SIGTERM al APAGAR): añade OBJS
    (la lista de monstruos) al payload. En el guardado PERIÓDICO va False -> OBJS NO se
    serializa cada ciclo -> cero coste/freezes durante el escaneo. Al arrancar, load_cache
    la recupera y el filtro de frescura la recorta a 'unos minutos' (farm 300s/event 2700s)."""
    try:
        with LOCK:
            payload = {
                "version": 1,
                "saved_at": time.time(),
                # 2026-08-13 ESTABILIDAD/MEMORIA: OBJS ya NO se persiste. Era ~el 95% del payload (~43MB)
                # -> el pico de serialización cada PERSIST_INTERVAL inflaba RSS (~2GB, medido) y disparaba
                # CPU (23-62%)/contención del GIL, contribuyendo al churn de freezes. Se re-rellena solo
                # escaneando en minutos; solo persistimos el histórico irrecuperable (shields/battles/etc).
                # load_cache usa d.get("OBJS", {}) -> arranca con OBJS vacío sin romperse.
                # "OBJS": {f"{k[0]},{k[1]}": v for k, v in OBJS.items()},
                "PLAYERS": {str(k): v for k, v in PLAYERS.items()},
                "MARCHES": {str(k): v for k, v in MARCHES.items()},
                "SUBCITIES": {str(k): v for k, v in SUBCITIES.items()},
                "MONSTERS_BY_TILE": {f"{k[0]},{k[1]}": v for k, v in MONSTERS_BY_TILE.items()},
                "PLAYER_POWER": {str(k): v for k, v in PLAYER_POWER.items()},
                "MEMBER_INFO": {str(k): v for k, v in MEMBER_INFO.items()},
                "MEMBER_LASTSEEN": {str(k): v for k, v in MEMBER_LASTSEEN.items()},
                "SHIELD_ETA": {(f"int:{k}" if isinstance(k, int) else k): v
                               for k, v in SHIELD_ETA.items()},
                "SHIELD_HISTORY": {str(k): v for k, v in SHIELD_HISTORY.items()},
                "WATCHLIST": sorted(WATCHLIST),
                "WATCHLIST_NOTES": {str(k): v for k, v in WATCHLIST_NOTES.items()},
                "WATCHLIST_BLACKLIST": sorted(WATCHLIST_BLACKLIST),
                "WATCHLIST_RULES": dict(WATCHLIST_RULES),
                "CFG": CFG, "WCFG": WCFG, "ITEMCFG": ITEMCFG, "STATE": {"cfg_n": STATE["cfg_n"]},
                # [respawns-disabled] no persistimos SPAWN_HISTORY (predictor desactivado)
                # "SPAWN_HISTORY": {f"{k[0]},{k[1]}": v for k, v in SPAWN_HISTORY.items()},
                "RELOCATIONS": {str(k): v for k, v in RELOCATIONS.items()},
                "PLAYER_ACT": {str(k): {"first": v["first"], "last": v["last"],
                                        "total": v["total"], "by_type": v["by_type"],
                                        "hod": v["hod"], "recent": list(v["recent"])}
                               for k, v in PLAYER_ACT.items()},
                "MEMBER_LASTSEEN": {str(k): v for k, v in MEMBER_LASTSEEN.items()},
                # OPCIÓN B: timestamps de lanzamiento por uid (para cadencia multi-día)
                "ACTION_TIMES": {str(k): list(v) for k, v in ACTION_TIMES.items() if v},
                "RALLY_LOG": list(RALLY_LOG),   # robo/contención de rallies (multi-día)
                "SPEED_STATS": {str(k): v for k, v in SPEED_STATS.items() if v.get("max", 0) > 0},
                # Intel PvP (antes se perdía en cada reinicio): batallas + scouts + kills PvE
                "BATTLE_LOG": list(BATTLE_LOG),
                "SCOUT_INTEL": {str(k): v for k, v in SCOUT_INTEL.items()},
                "MONSTER_KILLS": list(MONSTER_KILLS),
            }
            # OPCIÓN B (2026-08-17): OBJS (la lista de monstruos, ~95% del payload) SOLO se persiste
            # al apagar (include_objs=True desde el handler de SIGTERM). NUNCA en el guardado periódico.
            if include_objs:
                payload["OBJS"] = {f"{k[0]},{k[1]}": v for k, v in OBJS.items()}
        tmp = CACHE_FILE + ".tmp"
        with open(tmp, "w") as f:
            json.dump(payload, f)
        os.replace(tmp, CACHE_FILE)
    except Exception as e:
        print(f"[persist] save fail: {e}", flush=True)

def load_cache():
    """Carga el JSON al arrancar. Descarta entries con ts más viejas que PERSIST_TTL."""
    if not os.path.exists(CACHE_FILE):
        print("[persist] no cache previo, arranque limpio", flush=True)
        return
    try:
        with open(CACHE_FILE) as f:
            d = json.load(f)
        now = time.time()
        cutoff = now - PERSIST_TTL
        with LOCK:
            n_obj = n_pl = n_mch = n_sc = n_mbt = n_pwr = n_sh = 0
            for k_str, v in d.get("OBJS", {}).items():
                if v.get("ts", 0) < cutoff: continue
                wx, wy = k_str.split(",")
                OBJS[(int(wx), int(wy))] = v
                n_obj += 1
            for k_str, v in d.get("PLAYERS", {}).items():
                if v.get("ts", 0) < cutoff: continue
                PLAYERS[int(k_str)] = v
                n_pl += 1
            for k_str, v in d.get("MARCHES", {}).items():
                if int(v.get("te", 0)) <= now: continue  # marcha caducada
                trp = int(k_str); MARCHES[trp] = v
                key = (int(v.get("tx", 0)), int(v.get("ty", 0)))
                MARCH_BY_TGT.setdefault(key, {})[trp] = True
                n_mch += 1
            for k_str, v in d.get("SUBCITIES", {}).items():
                if v.get("ts", 0) < cutoff: continue
                SUBCITIES[int(k_str)] = v
                n_sc += 1
            for k_str, v in d.get("MONSTERS_BY_TILE", {}).items():
                if v.get("ts", 0) < cutoff: continue
                wx, wy = k_str.split(",")
                MONSTERS_BY_TILE[(int(wx), int(wy))] = v
                n_mbt += 1
            for k_str, v in d.get("PLAYER_POWER", {}).items():
                PLAYER_POWER[int(k_str)] = v   # SIN filtro de TTL: el power persiste entre reinicios hasta el próximo Ranking
                n_pwr += 1
            # MEMBER_INFO / MEMBER_LASTSEEN: power+online de member-lists polleadas
            # (incl. enemigos del 1939). Persistir evita esperar al re-poll tras reinicio.
            for k_str, v in d.get("MEMBER_INFO", {}).items():
                try: MEMBER_INFO[int(k_str)] = v
                except Exception: pass
            for k_str, v in d.get("MEMBER_LASTSEEN", {}).items():
                try: MEMBER_LASTSEEN[int(k_str)] = v
                except Exception: pass
            for k_str, v in d.get("SHIELD_ETA", {}).items():
                if int(v.get("end_time", 0)) <= int(now): continue
                k = int(k_str[4:]) if k_str.startswith("int:") else k_str
                SHIELD_ETA[k] = v
                n_sh += 1
            # SHIELD_HISTORY: cargar todo (lo usamos para mediana de duracion)
            n_sh_hist_pl = 0
            for k_str, v in d.get("SHIELD_HISTORY", {}).items():
                if not isinstance(v, list) or not v: continue
                SHIELD_HISTORY[int(k_str)] = v[-SHIELD_HISTORY_MAX:]
                n_sh_hist_pl += 1
            # WATCHLIST + notes + blacklist + rules
            for uid in d.get("WATCHLIST", []):
                try: WATCHLIST.add(int(uid))
                except: pass
            for k_str, v in (d.get("WATCHLIST_NOTES", {}) or {}).items():
                try: WATCHLIST_NOTES[int(k_str)] = v
                except: pass
            for uid in (d.get("WATCHLIST_BLACKLIST", []) or []):
                try: WATCHLIST_BLACKLIST.add(int(uid))
                except: pass
            saved_rules = d.get("WATCHLIST_RULES")
            if isinstance(saved_rules, dict):
                # merge: claves nuevas/desconocidas no se aceptan, valores preservan tipo
                for k in list(WATCHLIST_RULES.keys()):
                    if k in saved_rules: WATCHLIST_RULES[k] = saved_rules[k]
            # CLEANUP post-load: descartar SHIELD_ETA stale (tier=0 + no exact).
            # Necesario tras cambios de logica: el cache previo puede tener entries
            # huerfanas que no se invalidan via transition (porque prev_tier ya era 0
            # en PLAYERS cargado). Mantiene solo 'exact' (scout/mail) que tienen ground truth propio.
            stale = []
            for uid_k, info in list(SHIELD_ETA.items()):
                if not isinstance(uid_k, int): continue
                if info.get("confidence") == "exact": continue
                pl = PLAYERS.get(uid_k)
                tier = int((pl or {}).get("shield", 0) or 0) if pl else 0
                if tier == 0:
                    stale.append(uid_k)
            for uid_k in stale: SHIELD_ETA.pop(uid_k, None)
            if stale: print(f"[persist] cleaned {len(stale)} stale SHIELD_ETA entries (tier=0 + non-exact)", flush=True)
            # CFG, WCFG e ITEMCFG: cargar siempre (no caducan)
            for k, v in d.get("CFG", {}).items():
                CFG[str(k)] = v
            for k, v in d.get("WCFG", {}).items():
                WCFG[str(k)] = v
            for k, v in d.get("ITEMCFG", {}).items():
                ITEMCFG[str(k)] = v
            apply_cfg_manual()   # rellena ids que el cliente aún no tiene (p.ej. Pyrat)
            STATE["cfg_n"] = len(CFG)
            # [respawns-disabled] NO cargamos SPAWN_HISTORY desde disco (predictor
            # desactivado temporalmente; evita re-inflar memoria con datos viejos).
            n_sh_hist = 0
            # for k_str, v in d.get("SPAWN_HISTORY", {}).items():
            #     if not isinstance(v, list) or not v: continue
            #     if max(e.get("ts", 0) for e in v) < cutoff: continue
            #     wx, wy = k_str.split(",")
            #     SPAWN_HISTORY[(int(wx), int(wy))] = v
            #     n_sh_hist += 1
            # RELOCATIONS
            n_reloc = 0
            for k_str, v in d.get("RELOCATIONS", {}).items():
                if not isinstance(v, list) or not v: continue
                # filtrar eventos individuales viejos
                kept = [e for e in v if e.get("ts", 0) >= cutoff]
                if not kept: continue
                RELOCATIONS[int(k_str)] = kept
                n_reloc += 1
            # PLAYER_ACT: histórico de actividad (sin TTL — queremos que madure largo plazo)
            n_act = 0
            for k_str, v in (d.get("PLAYER_ACT", {}) or {}).items():
                try:
                    hod = list(v.get("hod", []) or [])
                    if len(hod) < 168: hod = hod + [0] * (168 - len(hod))
                    else: hod = hod[:168]
                    PLAYER_ACT[int(k_str)] = {
                        "first": float(v.get("first", 0) or 0),
                        "last": float(v.get("last", 0) or 0),
                        "total": int(v.get("total", 0) or 0),
                        "by_type": dict(v.get("by_type", {}) or {}),
                        "hod": hod,
                        "recent": _collections.deque(v.get("recent", []) or [], maxlen=ACT_RECENT_MAX),
                    }
                    n_act += 1
                except Exception:
                    pass
            # ACTION_TIMES: timestamps de lanzamiento por uid (cadencia multi-día, opción B)
            for k_str, v in (d.get("ACTION_TIMES", {}) or {}).items():
                try:
                    if isinstance(v, list) and v:
                        ACTION_TIMES[int(k_str)] = _collections.deque(
                            [int(x) for x in v], maxlen=ACTION_TIMES_MAX)
                except Exception:
                    pass
            # RALLY_LOG: eventos de rally a monstruos (robo/contención, multi-día)
            try:
                for e in (d.get("RALLY_LOG", []) or []):
                    if isinstance(e, dict) and e.get("ts"): RALLY_LOG.append(e)
            except Exception:
                pass
            # SPEED_STATS: velocidad máx de marcha por uid (tripwire speed-hack, multi-día)
            try:
                for k_str, v in (d.get("SPEED_STATS", {}) or {}).items():
                    if isinstance(v, dict): SPEED_STATS[int(k_str)] = v
            except Exception:
                pass
            # Intel PvP: restaurar batallas/scouts/kills (antes se perdían al reiniciar)
            try:
                for it in (d.get("BATTLE_LOG", []) or []):
                    if isinstance(it, dict):
                        BATTLE_LOG.append(it)
                        k = it.get("key", "")
                        if k: _BATTLE_SEEN.add(k)
                for k_str, v in (d.get("SCOUT_INTEL", {}) or {}).items():
                    if isinstance(v, dict): SCOUT_INTEL[int(k_str)] = v
                for it in (d.get("MONSTER_KILLS", []) or []):
                    if isinstance(it, dict): MONSTER_KILLS.append(it)
            except Exception:
                pass
            # MEMBER_LASTSEEN: status real de la member-list (epoch / centinela online).
            # Se refresca cada ~10min via poll; lo cargamos para no quedar en blanco al arrancar.
            for k_str, v in (d.get("MEMBER_LASTSEEN", {}) or {}).items():
                try: MEMBER_LASTSEEN[int(k_str)] = int(v)
                except Exception: pass
        age_h = (now - d.get("saved_at", now)) / 3600
        print(f"[persist] cache cargado (snapshot {age_h:.1f}h): "
              f"{n_obj} objs / {n_pl} players / {n_mch} marchas / "
              f"{n_sc} subcity / {n_mbt} monsters / {n_pwr} power / "
              f"{n_sh} shield / {n_sh_hist} spawn-hist / {n_reloc} reloc-uids / "
              f"{n_act} activity-uids / {len(CFG)} CFG / {len(WCFG)} WCFG", flush=True)
    except Exception as e:
        print(f"[persist] load fail: {e}", flush=True)

def persist_thread():
    while True:
        time.sleep(PERSIST_INTERVAL)
        save_cache()


def emulator_watchdog_thread():
    """Detecta un scanner DEGRADADO y lo reinicia automáticamente.

    Problema que resuelve: un emulador puede estar 'vivo' (frida attached, self uid OK)
    pero su cliente Evony procesar casi nada (visto 2026-05-27: E al 28% CPU, 36x menos
    objetos que W). Los watchdogs de reattach/hard-reset NO lo detectan porque frida sigue
    conectado y no hay error — simplemente el throughput cae en silencio.

    Heurística: cada WINDOW_S compara el throughput (objetos procesados = discoveries +
    respawns + redetects) de cada half en la ventana. Si uno cae por debajo de
    DEGRADED_RATIO del otro (y el sano tiene actividad significativa), el bajo está
    degradado → force-stop Evony + relaunch + settle (lo que arregló E manualmente).

    Salvaguardas anti-loop:
      - MIN_SAMPLES: solo actúa si el half sano procesó suficiente (descarta periodos
        muertos donde ambos están bajos legítimamente).
      - GRACE_AFTER_RESET: no re-evalúa un half hasta 4min tras reiniciarlo (tiempo a
        recuperar + reattach).
      - Skip si SCANNER_PAUSED (el usuario pausó manualmente).
      - El desbalance por ZONA (W tiene más Cerberus) NO dispara: en throughput total
        ambos halves sanos rinden similar (~1-1.5x); un degradado real es 5x+.
    """
    WINDOW_S = 120            # ventana de medición
    MIN_SAMPLES = 300         # objetos mínimos del half sano para comparar (descarta idle)
    DEGRADED_RATIO = 0.20     # si A < 20% de B (5x menos), A está degradado
    GRACE_AFTER_RESET = 240   # no re-evaluar un half hasta 4min tras reiniciarlo
    SERIAL = {h: sr for sr, h in SCANNERS}   # derivado de SCANNERS

    def _throughput(h):
        s = SCAN_STATS.get(h, {})
        return s.get("discoveries", 0) + s.get("respawns", 0) + s.get("redetects", 0)

    # baseline inicial tras un arranque holgado (deja que ambos estabilicen)
    time.sleep(WINDOW_S * 2)
    prev = {"W": _throughput("W"), "E": _throughput("E")}
    last_reset = {"W": 0, "E": 0}

    while True:
        time.sleep(WINDOW_S)
        if SCANNER_PAUSED:
            # actualizar baseline pero no actuar mientras esté pausado
            prev = {"W": _throughput("W"), "E": _throughput("E")}
            continue
        now = time.time()
        cur = {"W": _throughput("W"), "E": _throughput("E")}
        delta = {h: max(0, cur[h] - prev[h]) for h in ("W", "E")}
        prev = cur
        hi = "W" if delta["W"] >= delta["E"] else "E"
        lo = "E" if hi == "W" else "W"
        # ¿degradación real? el sano con actividad alta y el otro < 20% de él
        if delta[hi] >= MIN_SAMPLES and delta[lo] < DEGRADED_RATIO * delta[hi]:
            pct = (100 * delta[lo] // max(delta[hi], 1))
            # COORDINACIÓN: si frida_thread ya está reseteando/reattachando esta mitad,
            # NO interferir. Antes los dos watchdogs (este + el de reattach) disparaban a
            # la vez: uno mataba qemu mientras el otro hacía am start → "device not found"
            # → cada uno contaba fallos → se relanzaban emuladores mutuamente (thrash que
            # dejaba frida-server muerto y el cliente atascado en la city).
            if EXTERNAL_RESET.get(lo) or STATE[lo].get("sweep", "") in RECOVERING_STATES:
                print(f"[emu-watchdog] {lo} bajo ({delta[lo]} vs {hi}={delta[hi]}, {pct}%) "
                      f"pero frida_thread ya recuperando ({STATE[lo].get('sweep','')}) — no interfiero", flush=True)
                continue
            if (now - last_reset[lo]) < GRACE_AFTER_RESET:
                print(f"[emu-watchdog] {lo} bajo ({delta[lo]} vs {hi}={delta[hi]}, {pct}%) "
                      f"pero en grace period — esperando recuperación", flush=True)
                continue
            serial = SERIAL[lo]
            print(f"[emu-watchdog] ⚠ {lo} DEGRADADO: {delta[lo]} objs vs {hi}={delta[hi]} "
                  f"({pct}%) en {WINDOW_S}s → reiniciando Evony en {serial}", flush=True)
            try:
                import subprocess
                # 1) Magisk grant por si se perdió (su -c lo necesita)
                r_who = subprocess.run(["adb", "-s", serial, "shell", "su", "-c", "whoami"],
                                       capture_output=True, text=True, timeout=8)
                if "root" not in (r_who.stdout or ""):
                    subprocess.run(["adb", "-s", serial, "shell", "monkey", "-p",
                                    "com.topjohnwu.magisk", "-c",
                                    "android.intent.category.LAUNCHER", "1"],
                                   capture_output=True, timeout=10)
                    time.sleep(8)
                # 2) force-stop + relaunch Evony (resetea el estado degradado del cliente)
                subprocess.run(["adb", "-s", serial, "shell", "am", "force-stop",
                                "com.topgamesinc.evony"], capture_output=True, timeout=10)
                time.sleep(3)
                subprocess.run(["adb", "-s", serial, "shell", "am", "start", "-n",
                                "com.topgamesinc.evony/com.topgamesinc.androidplugin.UnityActivity"],
                               capture_output=True, timeout=10)
                # 3) asegurar frida-server LISTEN (helper canónico: root+binario+setsid+verify)
                _ensure_frida_server(serial, lo)
                last_reset[lo] = now
                print(f"[emu-watchdog] {lo} Evony relanzada — settle 30s, reattach automático del watchdog frida", flush=True)
                # marcar el half para que el watchdog frida lo reattache tras settle
                REATTACH_FAILS[lo] = 0
                STATE[lo]["sweep"] = "emu-watchdog-reset"
                time.sleep(30)   # settle: Unity + IL2CPP load antes de que frida reattache
                # tras settle, resetear baseline para no re-disparar inmediatamente
                prev = {"W": _throughput("W"), "E": _throughput("E")}
            except Exception as e:
                print(f"[emu-watchdog] {lo} reset fail: {e}", flush=True)

def priority_scan_thread():
    """Cada 10s detecta rallies/attacks a tiles SIN info en OBJS y envia un
    ctl:priority_jump al agente del half correspondiente. El agente saltara ahi
    en su proximo tick para que el broadcast pasivo capture el monster name.
    Resuelve los nombres "Boss rally · forming" en ~5s en lugar de ~50s del sweep.
    """
    # Debe coincidir con el agente: SPLIT_AXIS="X" corta por X en 606.
    SPLIT_AXIS = "X"
    SPLIT_X = 606
    SPLIT_Y = 606
    last_jump = {}   # (x,y) -> ts del ultimo priority_jump enviado (no repetir)
    while True:
        time.sleep(10)
        try:
            now = time.time()
            with LOCK:
                # Recoger tiles target de rallies/attacks activos SIN info en OBJS
                candidates = set()
                for trp, m in MARCHES.items():
                    mty = int(m.get("mty", 0))
                    if mty not in (10, 19, 20, 21, 2):  # solo attacks/rallies
                        continue
                    if int(m.get("te", 0)) <= now:
                        continue
                    # Si llevamos >120s sin update del broadcast, skip (probable stale)
                    if (now - float(m.get("ts_recv", 0) or 0)) > 120:
                        continue
                    tx, ty = int(m.get("tx", 0)), int(m.get("ty", 0))
                    if tx <= 0 or ty <= 0: continue
                    # Skip si OBJS ya tiene info del tile
                    if (tx, ty) in OBJS: continue
                    # Skip si tile tiene player castle (no es boss, no nos interesa scout)
                    tile_has_player = False
                    for p in PLAYERS.values():
                        if int(p.get("wx", 0)) == tx and int(p.get("wy", 0)) == ty:
                            tile_has_player = True; break
                    if tile_has_player: continue
                    # Throttle: no repetir el mismo tile en menos de 30s
                    last = last_jump.get((tx, ty), 0)
                    if (now - last) < 30: continue
                    candidates.add((tx, ty))
                    last_jump[(tx, ty)] = now
                    if len(candidates) >= 10: break   # max 10 por tick
            # Dispatch al agente del half correcto
            for (tx, ty) in candidates:
                if SPLIT_AXIS == "Y":
                    half = "W" if ty <= SPLIT_Y else "E"   # W=Norte, E=Sur
                else:
                    half = "W" if tx <= SPLIT_X else "E"   # W=Oeste, E=Este
                script = SCRIPTS.get(half)
                if script:
                    try:
                        script.post({"type": "ctl", "cmd": "priority_jump", "x": tx, "y": ty})
                        print(f"[priority-scan] {half} -> ({tx},{ty}) (rally target)", flush=True)
                    except Exception: pass
        except Exception as e:
            print(f"[priority-scan] err: {e}", flush=True)


def focus_scan_thread():
    """Focus Zone: cuando FOCUS['active'], mantiene al scanner del half elegido
    RONDANDO una zona (centro+radio, o el centroide de una alianza por tag) via
    priority_jump continuo -> recibe las marchas de esa zona sin parar (capta
    ataques de hasta ~2s). Opt-in; mientras active ese half no barre el resto.
    Reutiliza el priority_jump que el agente ya consume (cola cap 30)."""
    rot = 0
    while True:
        time.sleep(1.2)
        try:
            if not FOCUS.get("active"): continue
            half = FOCUS.get("half") or "W"
            sc = SCRIPTS.get(half)
            if sc is None: continue
            cx = int(FOCUS.get("cx") or 0); cy = int(FOCUS.get("cy") or 0)
            rad = max(0, min(int(FOCUS.get("radius") or 60), FOCUS_MAX_RADIUS))
            tag = (FOCUS.get("tag") or "").strip().lower()
            # Si hay un TAG de alianza, el Focus SIGUE su centroide dinámico (autofocus en
            # esa zona) — tiene prioridad sobre cx/cy. El centro manual (cx,cy) solo se usa
            # cuando NO hay tag.
            if tag:
                now = time.time(); xs = []; ys = []
                with LOCK:
                    for p in PLAYERS.values():
                        if now - p.get("ts", 0) > TTL_SECONDS: continue
                        if (p.get("tag") or "").strip().lower() != tag: continue
                        wx = int(p.get("wx", 0) or 0); wy = int(p.get("wy", 0) or 0)
                        if wx or wy: xs.append(wx); ys.append(wy)
                if xs:
                    cx = sum(xs) // len(xs); cy = sum(ys) // len(ys)
                    FOCUS["cx"] = cx; FOCUS["cy"] = cy   # cache para mostrar en la UI
            if cx <= 0 or cy <= 0: continue
            step = 35
            tiles = [(cx + dx, cy + dy)
                     for dy in range(-rad, rad + 1, step)
                     for dx in range(-rad, rad + 1, step)
                     if dx * dx + dy * dy <= rad * rad] or [(cx, cy)]
            n = len(tiles)
            for i in range(min(3, n)):                 # ~3 por ciclo, rotando -> cubre la zona
                tx, ty = tiles[(rot + i) % n]
                try: sc.post({"type": "ctl", "cmd": "priority_jump", "x": int(tx), "y": int(ty)})
                except Exception: pass
            rot = (rot + 3) % n
        except Exception as e:
            print(f"[focus] err: {e}", flush=True)


# ── ESCANEO DIRIGIDO ────────────────────────────────────────────────────────
# Re-inyecta coords FOCO (estructuras de periferia como las pirámides del evento, que el
# barrido normal tarda horas en alcanzar) como priority_jump a AMBOS agentes -> el sweep
# directo (v4Loop) las escanea PRIMERO con width mayor. Actualizable en caliente via
# POST /api/directed_scan {coords:[[x,y],...]}; persiste en disco (sobrevive reinicios).
DIRECTED_FILE = os.path.join(HERE, "directed_scan.json")
DIRECTED_COORDS = []   # DESACTIVADO: el worldmap dirigido devolvía el monstruo BASE del tile, no la pirámide. Las pirámides ahora vienen por get_ruins_list (store RUINS). Se puede re-poblar via /api/directed_scan para otras estructuras.
def load_directed():
    global DIRECTED_COORDS
    try:
        if os.path.exists(DIRECTED_FILE):
            d = json.load(open(DIRECTED_FILE)); cs = d.get("coords") if isinstance(d, dict) else d
            if isinstance(cs, list):
                DIRECTED_COORDS = [(int(c[0]), int(c[1])) for c in cs if isinstance(c, (list, tuple)) and len(c) >= 2][:40]
                print(f"[directed] {len(DIRECTED_COORDS)} coords cargadas de disco", flush=True)
    except Exception as e:
        print(f"[directed] load err: {e}", flush=True)
load_directed()
def _directed_fire(coords):
    for (x, y) in list(coords)[:30]:
        for h in ("W", "E"):
            sc = SCRIPTS.get(h)
            if sc is not None:
                try: sc.post({"type": "ctl", "cmd": "priority_jump", "x": int(x), "y": int(y)})
                except Exception: pass
def directed_scan_thread():
    _tick = 0
    while True:
        time.sleep(9)   # re-inyecta el foco cada 9s -> el sweep directo lo escanea PRIMERO (captura + mantiene fresco)
        try:
            if not DIRECTED_COORDS: continue
            _tick += 1
            # DIAG: qué ve el escáner en las coords foco AHORA (resultado del ciclo previo)
            with LOCK:
                _snap = [(x, y, OBJS.get((int(x), int(y)))) for (x, y) in DIRECTED_COORDS]
            _hit = sum(1 for (_x, _y, o) in _snap if o)
            print(f"[directed-diag] tick{_tick}: {_hit}/{len(_snap)} coords con objeto | agents W={SCRIPTS.get('W') is not None} E={SCRIPTS.get('E') is not None}", flush=True)
            for (x, y, o) in _snap:
                if o:
                    nm = (CFG.get(str(o.get("id"))) or {}).get("name", "") or "?"
                    print(f"[directed-diag]   ({x},{y}) t={o.get('t')} id={o.get('id')} lv={o.get('lv')} age={int(time.time()-float(o.get('ts',0) or 0))}s -> {nm!r}", flush=True)
            _directed_fire(DIRECTED_COORDS)
        except Exception as e:
            print(f"[directed-diag] err: {e}", flush=True)

def enemy_guild_poll_thread():
    """SVS: ordena al agente pollear las member-lists de las alianzas ENEMIGAS
    (las del server enemigo efectivo, con gid>0) cada ~2min. Esto trae el POWER
    real de los enemigos guilded (LOW, etc.) — validado que GetMemberList funciona
    cross-server. NO depende de la heurística de census del agente (que puede no
    incluir alianzas enemigas pequeñas). Los enemigos SIN guild (gid=0, cuentas
    farm) no tienen fuente pasiva de power: solo scout los revelaría."""
    last_sent = {}   # gid -> ts del último envío (no repetir más rápido que cada 90s)
    while True:
        time.sleep(20)
        try:
            now = time.time()
            with LOCK:
                _ours, _foreign = foreign_breakdown(now)
                enemy_sv = effective_enemy_server(_foreign)
                guilds = {}   # gid -> tag (de los enemigos efectivos con guild)
                for p in PLAYERS.values():
                    if now - p.get("ts", 0) > TTL_SECONDS: continue
                    if not is_enemy(p, enemy_sv): continue
                    gid = int(p.get("gid", 0) or 0)
                    if gid <= 0: continue
                    guilds[gid] = (p.get("tag") or "").strip()
            if not guilds: continue
            # throttle por gid (cada gid como mucho 1 vez / 90s)
            payload = [{"gid": g, "tag": t} for g, t in guilds.items()
                       if now - last_sent.get(g, 0) >= 90]
            if not payload: continue
            for g in guilds: last_sent[g] = now
            sent = 0
            for half, sc in SCRIPTS.items():
                if sc is None: continue
                try:
                    sc.post({"type": "ctl", "cmd": "poll_guild", "guilds": payload}); sent += 1
                except Exception: pass
            if sent:
                print(f"[enemy-poll] poll_guild -> {sent} scanners: "
                      f"{[(p['tag'],p['gid']) for p in payload]}", flush=True)
        except Exception as e:
            print(f"[enemy-poll] err: {e}", flush=True)


def scanner_revert_thread():
    """Auto-revert de perfil de escáner: si hay un revert_at programado y ya venció,
    vuelve al perfil indicado (p.ej. svs_split -> home tras el SVS) y lo aplica en
    caliente. Persistido en scanner_config.json -> sobrevive reinicios del backend."""
    while True:
        time.sleep(30)
        try:
            ra = float(SCANNER_CFG.get("revert_at", 0) or 0)
            if ra <= 0 or time.time() < ra:
                continue
            target = SCANNER_CFG.get("revert_to") or "home"
            if target not in SCANNER_CFG.get("profiles", {}):
                target = "home"
            SCANNER_CFG["active"] = target
            SCANNER_CFG.pop("revert_to", None); SCANNER_CFG.pop("revert_at", None)
            save_scanner_cfg()
            push_scan_cfg()
            print(f"[scanner-cfg] ⏰ AUTO-REVERT ejecutado -> perfil '{target}' (en caliente)", flush=True)
        except Exception as e:
            print(f"[scanner-cfg] revert err: {e}", flush=True)


def auto_watchlist_thread():
    """Cada 60s aplica WATCHLIST_RULES a PLAYERS y auto-anade los que cumplan.
    No re-anade los que estan en BLACKLIST. No pisa entries 'manual' existentes.
    """
    while True:
        time.sleep(60)
        if not WATCHLIST_RULES.get("enabled", False):
            continue
        try:
            enemy = set(t.upper() for t in WATCHLIST_RULES.get("enemy_tags", []))
            exclude = set(t.upper() for t in WATCHLIST_RULES.get("exclude_tags", []))
            min_castle = int(WATCHLIST_RULES.get("min_castle", 0) or 0)
            min_power = float(WATCHLIST_RULES.get("min_power_M", 0) or 0)  # en M
            require_shield = bool(WATCHLIST_RULES.get("require_shield_seen", False))
            added = 0
            now = int(time.time())
            with LOCK:
                # snapshot para no holdear el lock durante toda la iteracion
                ps_snapshot = list(PLAYERS.items())
                bl_snapshot = set(WATCHLIST_BLACKLIST)
                wl_snapshot = set(WATCHLIST)
                pw_snapshot = dict(PLAYER_POWER)
                sh_eta_snapshot = dict(SHIELD_ETA)
                sh_hist_keys = set(SHIELD_HISTORY.keys())
            to_add = []
            for uid, p in ps_snapshot:
                if uid in wl_snapshot or uid in bl_snapshot:
                    continue
                tag = (p.get("tag", "") or "").upper()
                if enemy and tag not in enemy:
                    continue
                if exclude and tag in exclude:
                    continue
                clv = int(p.get("clv", 0) or 0)
                if clv < min_castle:
                    continue
                # power: preferir PLAYER_POWER (exacto). Fallback a broadcast (suele ser 0).
                pw_info = pw_snapshot.get(uid) or {}
                pw_val = int(pw_info.get("power", 0) or p.get("power", 0) or 0)
                if (pw_val / 1e6) < min_power:
                    continue
                if require_shield:
                    has_shield_ever = (uid in sh_hist_keys) or (uid in sh_eta_snapshot) or (int(p.get("shield", 0) or 0) > 0)
                    if not has_shield_ever:
                        continue
                to_add.append(uid)
            if to_add:
                with LOCK:
                    for uid in to_add:
                        if uid in WATCHLIST or uid in WATCHLIST_BLACKLIST:
                            continue   # double-check tras re-adquirir lock
                        WATCHLIST.add(uid)
                        p = PLAYERS.get(uid) or {}
                        tag = (p.get("tag", "") or "").upper()
                        WATCHLIST_NOTES[uid] = {
                            "note": f"auto: tag={tag} C{p.get('clv', 0)}",
                            "added_ts": now,
                            "source": "auto",
                        }
                        added += 1
                if added:
                    print(f"[watchlist-auto] +{added} uids (total={len(WATCHLIST)}, rules={WATCHLIST_RULES})", flush=True)
        except Exception as e:
            print(f"[watchlist-auto] error: {e}", flush=True)

def main():
    load_users()   # cargar/seed-ear usuarios+secret antes de exponer la UI
    load_enemy_cfg()   # config enemigo SVS (override server + tags de alianza)
    load_focus_cfg()   # config Focus Zone (persistente y global)
    load_scanner_cfg() # perfiles de escáner (region+server por half; home/svs_split)
    load_cache()   # restaurar histórico antes de arrancar nada

    _seed_incidents_from_log()   # panel de incidencias: histórico real, no vacío tras cada deploy
    _install_exit_hooks()   # al morir, soltar las sesiones frida (si no, agente huérfano en el juego)
    for i, (serial, half) in enumerate(SCANNERS):
        threading.Thread(target=frida_thread, args=(serial, half), daemon=True).start()
        time.sleep(6)   # escalonado: W termina su setup antes de que arranque E
    threading.Thread(target=prune_thread, daemon=True).start()

    threading.Thread(target=persist_thread, daemon=True).start()
    threading.Thread(target=auto_watchlist_thread, daemon=True).start()
    threading.Thread(target=priority_scan_thread, daemon=True).start()
    threading.Thread(target=focus_scan_thread, daemon=True).start()
    threading.Thread(target=directed_scan_thread, daemon=True).start()
    threading.Thread(target=enemy_guild_poll_thread, daemon=True).start()
    threading.Thread(target=scanner_revert_thread, daemon=True).start()
    # V4: watchdog del emulador DESACTIVADO. En modo protobuf-directo la cámara se congela
    # (no hay sweep_pass), lo que el watchdog interpretaría como "degradado" y relanzaría Evony,
    # reseteando el agente antes del experimento. Para volver a barrido-por-cámara, reactivar.
    # threading.Thread(target=emulator_watchdog_thread, daemon=True).start()
    # Bind configurable via ISCOUT_BIND. Default 127.0.0.1 = hermético (solo localhost).
    # Split Mac+VPS: el launcher exporta ISCOUT_BIND=<IP-Tailscale> para que SOLO el
    # tailnet (incl. el VPS reverse-proxy) pueda llegar. adb/frida nunca se exponen.
    BIND_HOST = os.environ.get("ISCOUT_BIND", "127.0.0.1")
    srv = ThreadingHTTPServer((BIND_HOST, PORT), H)
    print(f"[web] PIXEL DUAL (W={SCANNER_W} / E={SCANNER_E}) puerto {PORT}  (Ctrl+C para parar)", flush=True)
    print(f"[web]   bind:    http://{BIND_HOST}:{PORT}", flush=True)
    print(f"[web]   local:   http://127.0.0.1:{PORT}", flush=True)
    try:
        srv.serve_forever()
    except KeyboardInterrupt:
        print("\n[web] guardando cache antes de salir...")
        save_cache()
        print("[web] parado")

if __name__ == "__main__":
    main()
