#!/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
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
import frida

HERE  = os.path.dirname(os.path.abspath(__file__))
AGENT = os.path.join(HERE, "agent.js")
PORT  = 8771               # v2 dual: puerto distinto a v1 (8770)
# 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 = 30      # guardar cada 30s
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"}
SUBCITY_QUALITY = {0: "white", 1: "green", 2: "blue", 3: "purple", 4: "gold", 5: "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",
}

# v2: dos escaneres, cada uno una mitad del mapa (corte vertical en X=650).
#   W = emulator-5554 (cuenta #1, Oeste X<=650)
#   E = emulator-5556 (cuenta #2, Este  X>=650)
SCANNERS = [("emulator-5554", "W"), ("emulator-5556", "E")]

LOCK   = threading.Lock()
FRIDA_SETUP_LOCK = threading.Lock()   # serializa attach/create_script/load de los 2 escaneres
CFG    = {}                 # id -> {name, level, type, power} (MonsterConfig)
WCFG   = {}                 # id -> {name, level, type} (WorldResourceConfig: farms/mines/etc)
OBJS   = {}                 # (wx,wy) -> {t,id,lv,wx,wy,ts}  (pool fusionado de ambos)
PLAYERS = {}                # uid -> {...}  (fusionado de ambos)
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_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)
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)
SELF = {"W": {"uid": 0, "guild_id": 0}, "E": {"uid": 0, "guild_id": 0}}   # uid+guild_id por scanner
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

# ── 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)
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
REATTACH_FAILS = {"W": 0, "E": 0}   # contador de reattaches fallidos consecutivos
HARD_RESET_THRESHOLD = 3            # tras N fallos seguidos, force-stop Evony del half
NUCLEAR_RESET_THRESHOLD = 6         # tras N fallos seguidos, kill qemu entero y relanzar
# AVD names + emulator binary para nuclear reset
NUCLEAR_AVD = {"W": ("evony_root", 5554), "E": ("evony_root_2", 5556)}
EMULATOR_BIN = "/opt/homebrew/share/android-commandlinetools/emulator/emulator"
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}
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)
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
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 _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): usa mediana del historico si hay, sino default 8h
            med = _shield_median_duration(uid)
            # SANIDAD: si la mediana es absurdamente corta (<10min), probablemente esta envenenada
            # por flicker pasado -> usa default. Eso da auto-recovery sin necesidad de wipe manual.
            if med and med < 600:
                med = None
            dur = med if med else SHIELD_DEFAULT_DURATION_S
            SHIELD_ETA[uid] = {
                "end_time": int(now) + int(dur),
                "src": "tier_transition",
                "confidence": "inferred_history" if med else "inferred_default",
                "activation_ts": int(now),
                "duration_assumed_s": int(dur),
                "ts": now,
            }
        return

    # ---- EXPIRACION/ROTURA: 1 -> 0 o 2 -> 0
    # SIEMPRE limpiamos SHIELD_ETA: broadcast es ground truth.
    if prev_tier in (1, 2) and new_tier == 0:
        # Archivar duracion real en historico (si tenemos activation_ts)
        # Solo archivar fuera del cooldown — el cooldown indica que probablemente fue flicker.
        if cur and not in_cooldown:
            act_ts = int(cur.get("activation_ts", 0) or 0)
            end_t  = int(cur.get("end_time", 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,
                        "tier": prev_tier,
                        "src": cur.get("src", "?"),
                        "confidence_at_activation": cur.get("confidence", "?"),
                    })
                    if len(h) > SHIELD_HISTORY_MAX:
                        del h[:-SHIELD_HISTORY_MAX]
            # Si tenia ETA exact y end_time aun futuro, fue rotura por ataque
            if cur.get("confidence") == "exact" and end_t > now:
                print(f"[shield-break] uid={uid} ETA exact era {end_t} ({end_t-int(now)}s left), rota ahora (probable ataque)", flush=True)
        SHIELD_ETA.pop(uid, None)
        return

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


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
    # Tomar la duracion mas reciente del historico como base
    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) - last_dur,   # asumimos activacion al final del ultimo ciclo
        "duration_assumed_s": extended_dur,
        "ts": now,
    }
    print(f"[undershot] uid={uid} tier={current_tier} inferencia previa expiro pero shield sigue. Extiendo +{extended_dur}s", flush=True)


# ---------------- Frida backend ----------------
def on_message(msg, data, half):
    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") == "config":
        with LOCK:
            for k, v in p["cfg"].items():
                CFG[str(k)] = v
            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") == "batch":
        now = time.time()
        with LOCK:
            for it in p["items"]:
                k = (it["wx"], it["wy"])
                it["half"] = half   # de qué scanner (W o E) llegó este item
                # 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,
                    }
                old = OBJS.get(k)
                if old is None:
                    # tile nunca visto -> descubrimiento nuevo
                    it["ts"] = now
                    it["first_ts"] = now
                    OBJS[k] = it
                    # respawn predictor: registrar PRIMERA aparicion (solo si es monstruo NPC)
                    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
                        # respawn predictor: registrar nuevo spawn (intervalo desde el anterior)
                        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:
                        old.update(it)
                        old["ts"] = now
                        old["half"] = half  # actualiza scanner que lo re-detectó
                        old.setdefault("first_ts", old.get("ts", now))
    elif p.get("kind") == "players":
        now = time.time()
        with LOCK:
            for it in p["items"]:
                uid = it["uid"]
                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))
                    if (old_wx != 0 or old_wy != 0) and (old_wx != new_wx or old_wy != new_wy):
                        # ¡Relocalización detectada!
                        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),
                        })
                        if len(r) > RELOCATIONS_MAX: del r[:-RELOCATIONS_MAX]
                # 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)
                # Anti-undershot: si tier sigue siendo 1/2 pero no tenemos SHIELD_ETA,
                # extender con duracion del ultimo ciclo + 50%
                if new_tier in (1, 2):
                    _shield_check_undershot(uid, new_tier, now)
                it["ts"] = now
                PLAYERS[uid] = it
    elif p.get("kind") == "marches":
        now = time.time()
        with LOCK:
            for it in p["items"]:
                trp = int(it.get("trp", 0))
                if trp == 0:
                    continue
                it["ts_recv"] = now
                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["tx"]), int(it["ty"]))
                MARCH_BY_TGT.setdefault(key, {})[trp] = True
    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":
        # Identidad del scanner: uid + guild_id. La usamos para clasificar tiles
        # ocupados como "in MY alliance" vs "external alliance".
        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") == "powers":
        # Power exacto per uid (de MsgDown.power_rank_reply). El usuario tiene que abrir
        # Rankings -> Power Ranking en el juego para que el cliente solicite la lista.
        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") == "sweep_done":
        STATE[half]["sweep"] = "done"
    elif p.get("kind") == "sweep_pass":
        global FIRST_PASS_TS
        now = time.time()
        _FP_DONE[half] = True
        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']}"

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}

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)
    STATE[half]["sweep"] = "hard-reset"
    try:
        pids = subprocess.run(["adb", "-s", serial, "shell", "pidof", "frida-server"],
                              capture_output=True, text=True, timeout=5).stdout.strip().split()
        for pid in pids:
            if pid: subprocess.run(["adb", "-s", serial, "shell", "kill", "-9", pid], timeout=5)
        subprocess.run(["adb", "-s", serial, "shell", "am", "force-stop", "com.topgamesinc.evony"], timeout=10)
        time.sleep(3)
        subprocess.run(["adb", "-s", serial, "root"], timeout=10)
        time.sleep(3)
        subprocess.run(["adb", "-s", serial, "shell",
                       "nohup", "/data/local/tmp/frida-server", ">/dev/null", "2>&1", "&"],
                      timeout=5)
        time.sleep(3)
        subprocess.run(["adb", "-s", serial, "shell", "am", "start", "-n",
                       "com.topgamesinc.evony/com.topgamesinc.androidplugin.UnityActivity"],
                      timeout=10)
        print(f"[{half}] HARD-RESET (UI) aplicado, esperando 35s a Evony", flush=True)
        time.sleep(35)
        print(f"[{half}] HARD-RESET (UI) completado, esperando reattach del watchdog", flush=True)
    except Exception as e:
        print(f"[{half}] HARD-RESET (UI) fail: {e}", flush=True)

def _nuclear_reset_impl(serial, half):
    """Kill qemu + relanzar AVD entero. ~3min. Idempotente."""
    import subprocess
    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", "auto", "-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 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:
            # 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. Retry 3 veces; si todas fallan -> escalar
                # (probablemente frida-server degradado, sólo HARD/NUCLEAR lo arregla).
                script = None
                for cs_try in range(3):
                    try:
                        script = session.create_script(src)
                        break
                    except frida.TransportError as e:
                        wait = 3 + cs_try * 2
                        print(f"[{half}] create_script try {cs_try+1} fail ({e}); retry {wait}s", flush=True)
                        time.sleep(wait)
                if script is None:
                    # 3 fallos seguidos de create_script == frida-server degradado.
                    # Marcar como fallo grave (suma HARD_RESET_THRESHOLD) para forzar
                    # HARD-RESET inmediato en lugar de seguir reintentando attach inútilmente.
                    REATTACH_FAILS[half] += HARD_RESET_THRESHOLD
                    print(f"[{half}] create_script falló 3 veces -> escalando a HARD-RESET (fails={REATTACH_FAILS[half]})", flush=True)
                    try: session.detach()
                    except Exception: pass
                    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
                # 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
                LAST_HEARTBEAT[half] = time.time()
                REATTACH_FAILS[half] = 0   # éxito: resetea contador de fallos
                print(f"[{half}] frida activo en {serial} (intento {attempt})", 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
                if age > HEARTBEAT_TIMEOUT:
                    # 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)
                    # 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
        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:
            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:
            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
                subprocess.Popen([EMULATOR_BIN, "-avd", avd, "-port", str(port),
                                  "-no-snapshot", "-writable-system", "-no-boot-anim",
                                  "-gpu", "auto", "-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)
                # 4) adb root (CRÍTICO: frida-server necesita root para acceder al proceso Evony)
                subprocess.run(["adb", "-s", serial, "root"], timeout=15)
                time.sleep(5)   # tiempo para que adb se reinicie como root
                # esperar a que el dispositivo vuelva tras 'adb root' (puede tardar)
                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)
                # 5) lanzar Evony
                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...", flush=True)
                time.sleep(35)
                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-server", flush=True)
            STATE[half]["sweep"] = "hard-reset"
            try:
                # kill frida-server local (solo del emulador relevante)
                import subprocess
                pids = subprocess.run(["adb", "-s", serial, "shell", "pidof", "frida-server"],
                                      capture_output=True, text=True, timeout=5).stdout.strip().split()
                for pid in pids:
                    if pid: subprocess.run(["adb", "-s", serial, "shell", "kill", "-9", pid], timeout=5)
                # force-stop Evony
                subprocess.run(["adb", "-s", serial, "shell", "am", "force-stop", "com.topgamesinc.evony"], timeout=10)
                time.sleep(3)
                # CRÍTICO: garantizar adb root antes de lanzar frida-server
                subprocess.run(["adb", "-s", serial, "root"], timeout=10)
                time.sleep(3)
                # relaunch frida-server
                subprocess.run(["adb", "-s", serial, "shell",
                               "nohup", "/data/local/tmp/frida-server", ">/dev/null", "2>&1", "&"],
                              timeout=5)
                time.sleep(3)
                # relaunch Evony
                subprocess.run(["adb", "-s", serial, "shell", "am", "start", "-n",
                               "com.topgamesinc.evony/com.topgamesinc.androidplugin.UnityActivity"],
                              timeout=10)
                print(f"[{half}] HARD-RESET aplicado, esperando 35s a Evony", flush=True)
                time.sleep(35)
                # 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, 19, 20, 21, 43}
    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"],
        "type":  o["t"],
        "power": c.get("power", 0),
        "x": o["wx"], "y": o["wy"], "id": o["id"],
        "group": group_of(c.get("name", "")),
        "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",
]

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

# Familias rapidas (display -> substrings que matchean el nombre)
# (display, keywords, grupo)  grupo: "boss" | "event"
FAMILIES = [
    ("Cerberus",        ["cerberus"],            "boss"),
    ("Knight Bayard",   ["bayard", "bayar knight"], "boss"),
    ("Harpy",           ["harpy"],               "boss"),
    ("Hydra",           ["hydra"],               "boss"),
    ("Warlord",         ["warlord"],             "boss"),
    ("Ghidorah",        ["ghidorah"],            "boss"),
    ("Nine-tails",      ["nine-tails", "nine tails"], "boss"),
    ("Bosses (Boss)",   ["(boss)"],              "boss"),
    ("Ymir",            ["ymir"],                "event"),
    ("Pan",             ["pan "],                "event"),
    ("Lava Turtle",     ["lava turtle"],         "event"),
    ("Sphinx",          ["sphinx"],              "event"),
    ("Witch",           ["witch"],               "event"),
    ("Golem",           ["golem"],               "event"),
    ("Mire Squid",      ["mire squid"],          "event"),
    ("Pumpkin",         ["pumpkin"],             "event"),
    ("Viking",          ["viking"],              "event"),
    ("Golden Goblin",   ["golden goblin"],       "event"),
    ("Arctic Barbarian",["arctic barbarian"],    "event"),
    ("Fire Spirit",     ["fire spirit"],         "event"),
]

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

def group_of(name):
    """Clasifica un monstruo por su nombre de config: Boss / Event / Normal."""
    n = (name or "").lower()
    for disp, kws, grp in FAMILIES:
        if any(k in n for k in kws):
            return "Boss" if grp == "boss" else "Event"
    if "(boss)" in n:
        return "Boss"
    return "Normal"

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 families(max_seen=None):
    """[{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."""
    cat = catalog()
    if max_seen is None: max_seen = STALE_SECONDS
    now = time.time()
    with LOCK:
        objs = [o for o in OBJS.values() if (now - o.get("ts", 0)) <= max_seen]
    live = {}
    for o in objs:
        c = CFG.get(str(o["id"])) or {}
        nm = c.get("name", "")
        if not nm:
            continue
        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

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
    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.
    # Default = STALE_SECONDS: solo lo que sigue existiendo en el mapa ahora mismo
    # (lo que ya no se re-detecta en ~3-4 vueltas desaparece de la lista).
    try: max_seen = float(params["max_seen"]) if params.get("max_seen") else STALE_SECONDS
    except: max_seen = STALE_SECONDS

    with LOCK:
        rows = [enrich(o) for o in OBJS.values()]
    out = []
    for r in rows:
        if only_type and str(r["type"]) != only_type:
            continue
        if ids and r["id"] not in ids:
            continue
        if max_seen is not None and r["seen_age"] > max_seen:
            continue
        if cx is not None and cy is not None and rad is not None:
            d = math.hypot(r["x"] - cx, r["y"] - cy)
            if d > rad:
                continue
            r["dist"] = round(d)
        else:
            r["dist"] = None
        out.append(r)

    # order: "desc" (default natural — level/power grande primero, newest reciente
    # primero) o "asc" (invertido). El frontend usa esto para los headers clicables.
    order = (params.get("order", "desc") or "desc").lower()
    rev = (order == "asc")   # invertimos los defaults
    if sort == "level":
        out.sort(key=lambda r: (r["level"], r["name"]) if rev else (-r["level"], r["name"]))
    elif sort == "power":
        out.sort(key=lambda r: r["power"] if rev else -r["power"])
    elif sort == "dist" and cx is not None:
        out.sort(key=lambda r: (r["dist"] if r["dist"] is not None else 9e9), reverse=rev)
    elif sort == "newest":
        # default: age asc (más reciente primero). asc invertido = más viejos primero
        out.sort(key=lambda r: r["age"], reverse=rev)
    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 = 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"
        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 and rad is not None:
            d = math.hypot(p["wx"] - cx, p["wy"] - cy)
            if 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"])
        if eta_info is None:
            ckey = f"coord:{p['wx']},{p['wy']}"
            eta_info = SHIELD_ETA.get(ckey)
            if eta_info is not None:
                # migra coord -> uid: ahora que conocemos el uid, lo movemos
                SHIELD_ETA[p["uid"]] = eta_info
                SHIELD_ETA.pop(ckey, None)
        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":
            SHIELD_ETA.pop(p["uid"], None)
            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
                SHIELD_ETA.pop(p["uid"], None)
        out.append({
            "name": p.get("name", ""), "level": lv, "castle": p.get("clv", 0),
            "power": pw, "power_src": pw_src, "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["wx"], "y": p["wy"], "uid": p["uid"], "dist": d,
            "age": int(now - p.get("ts", now)),
        })
    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 ----------------
HTML = """<!doctype html><html><head><meta charset=utf-8>
<title>Evony Scout · Dual</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}
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}
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-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{font-size:14px;color:#fca5a5;font-weight:600}
.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-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}
/* 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}
.famrow{display:flex;flex-wrap:wrap;gap:6px}
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}
.tabs{display:flex;gap:6px;margin-left:auto}
.tab{background:#1f2937;color:#cbd5e1;border:1px solid #2c3340;border-radius:8px 8px 0 0;padding:7px 16px;font-size:13px;cursor:pointer}
.tab.on{background:#2563eb;color:#fff;border-color:#2563eb}
.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}
.toast .close{position:absolute;top:6px;right:8px;cursor:pointer;color:#fed7aa;font-size:14px;border:none;background:transparent}
@keyframes tslidein{from{transform:translateX(360px);opacity:0}to{transform:translateX(0);opacity:1}}
</style></head><body>
<div id=toasts></div>
<header><h1>Evony Scout · Dual</h1><span class=stat id=stat>loading...</span>
<label style="display:inline">auto<input type=checkbox id=auto checked></label>
<button id=btn_scan class=sec onclick="toggleScanner()" title="Pausa los jumps de la camara para que puedas interactuar con el juego (scoutear, abrir perfiles). Reanuda cuando termines.">⏸ Pause Scanner</button>
<button class=sec onclick="restartHalf('W')" title="Reattach W (emulator-5554) agent. Use if W is stuck." style="font-size:11px;padding:5px 8px">↻ W</button>
<button class=sec onclick="restartHalf('E')" title="Reattach E (emulator-5556) agent. Use if E is stuck." style="font-size:11px;padding:5px 8px">↻ E</button>
<button class=sec onclick="restartHalf('')" title="Reattach BOTH agents (W and E). Use if both are stuck." style="font-size:11px;padding:5px 8px;background:#7c2d12;border-color:#ea580c">↻ Both</button>
<button class=sec onclick="hardReset('')" title="HARD RESET: force-stop Evony + frida-server + relanzar todo (~40s). Usar cuando el reattach normal no funcione." style="font-size:11px;padding:5px 8px;background:#7f1d1d;border-color:#b91c1c">⚠ Hard Reset</button>
<button class=sec onclick="nuclearReset('W')" title="NUCLEAR W: mata el proceso qemu del 5554 y relanza el emulador entero (~3-4 min). ÚLTIMO RECURSO." style="font-size:11px;padding:5px 8px;background:#450a0a;border-color:#7f1d1d;color:#fca5a5">☠ Nuke W</button>
<button class=sec onclick="nuclearReset('E')" title="NUCLEAR E: mata el proceso qemu del 5556 y relanza el emulador entero (~3-4 min). ÚLTIMO RECURSO." style="font-size:11px;padding:5px 8px;background:#450a0a;border-color:#7f1d1d;color:#fca5a5">☠ Nuke E</button>
<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_mon onclick="switchTab('mon')">Monsters</span>
  <span class=tab id=tab_pl onclick="switchTab('pl')">Players</span>
  <span class=tab id=tab_res onclick="switchTab('res')">Resources</span>
  <span class=tab id=tab_rel onclick="switchTab('rel')">Relics/Pyramids</span>
  <span class=tab id=tab_arc onclick="switchTab('arc')">Arctic Barbarians</span>
  <span class=tab id=tab_sc onclick="switchTab('sc')">Subcities</span>
  <span class=tab id=tab_rs onclick="switchTab('rs')">⏱ Respawns</span>
  <span class=tab id=tab_rl onclick="switchTab('rl')">📍 Relocations</span>
  <span class=tab id=tab_wl onclick="switchTab('wl')">⭐ Watchlist <span id=wl_count style="opacity:.7;font-size:11px"></span></span>
</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>

<div id=view_mon>
<div class=famgrp><h3>Boss Monsters</h3><div class=famrow id=fams_boss></div></div>
<div class=famgrp style="padding-bottom:10px"><h3>Event Monsters</h3><div class=famrow id=fams_event></div></div>
<div class=box>
 <div style="flex:1;min-width:340px"><label>Rally bosses (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>Type</label>
   <select id=type><option value="">All</option><option value="2" selected>Monsters (rally)</option><option value="3">Type 3</option><option value="1">Own castle</option><option value="7">Players</option></select></div>
 <div><label>Center X</label><input type=number id=cx value=603></div>
 <div><label>Center Y</label><input type=number id=cy value=603></div>
 <div><label>Radius (tiles, empty=whole map)</label><input type=number id=rad placeholder="whole map"></div>
 <div><label>Max age (min, empty=active only)</label><input type=number id=maxage placeholder="active" style="width:90px"></div>
 <div><button onclick=load()>Apply</button>
   <button class=sec onclick="SEL.clear();FAM.clear();renderChips();loadFamilies();load()">Clear</button></div>
</div>
<div class=atktoggle>
  <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>
<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 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>
<div id=atkpanel style="display:none">
  <div class=atkhdr>
    <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>
    </select>
    <span class=stat style="margin-left:auto;font-size:11px">⏱ live countdown</span>
  </div>
  <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>
<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=box>
 <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=603></div>
 <div><label>Center Y</label><input type=number id=p_cy value=603></div>
 <div><label>Radius (tiles, empty=whole map)</label><input type=number id=p_rad placeholder="whole map"></div>
 <div><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=box>
 <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=603></div>
 <div><label>Center Y</label><input type=number id=r_cy value=603></div>
 <div><label>Radius (tiles, empty=whole map)</label><input type=number id=r_rad placeholder="whole map"></div>
 <div><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_relics style="display:none">
<div class=box>
 <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=603></div>
 <div><label>Center Y</label><input type=number id=rl_cy value=603></div>
 <div><label>Radius (tiles, empty=whole map)</label><input type=number id=rl_rad placeholder="whole map"></div>
 <div><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=box>
 <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=603></div>
 <div><label>Center Y</label><input type=number id=ar_cy value=603></div>
 <div><label>Radius (tiles, empty=whole map)</label><input type=number id=ar_rad placeholder="whole map"></div>
 <div><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=box>
 <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=603></div>
 <div><label>Center Y</label><input type=number id=sc_cy value=603></div>
 <div><label>Radius (tiles, empty=whole map)</label><input type=number id=sc_rad placeholder="whole map"></div>
 <div><label>Sort</label><select id=sc_sort>
   <option value=level>Level</option><option value=quality>Quality</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=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>Boss Monsters</h3><div class=famrow id=fams_boss_rs></div></div>
 <div class=famgrp style="padding-bottom:10px"><h3>Event Monsters</h3><div class=famrow id=fams_event_rs></div></div>
 <div class=box>
  <div style="flex:1;min-width:340px"><label>Rally bosses (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=rl_cx type=number style="width:60px" placeholder="cx">
  Y:<input id=rl_cy type=number style="width:60px" placeholder="cy">
  Radius:<input id=rl_rad type=number style="width:60px" placeholder="rad">
  <button onclick=loadRelocations()>Refresh</button>
  <button class=sec onclick="document.getElementById('rl_cx').value='';document.getElementById('rl_cy').value='';document.getElementById('rl_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=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>
 <!-- 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><!-- /view_watchlist -->

<!-- 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}
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';}
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_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;
}

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();
   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>${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. Click ✖ to discard (goes to blacklist)">AUTO</span>'
         : '<span class=src_manual title="Added manually via ⭐">MANUAL</span>';
       const blacklistBtn = (x.source==='auto')
         ? `<span class=cp title="Discard (blacklist, won't auto-re-add)" style="color:#ef4444;padding:0 6px" onclick="blacklistUid(${x.uid})">✖</span>`
         : '';
       tr.innerHTML = `
         <td>${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="${x.shield_src||''}">${x.shield_confidence||'—'}</span></td>
         <td>${srcBadge}</td>
         <td><span class=stat>${x.note||''}</span></td>
         <td><span class="wstar wstar-on" title="Remove" onclick="toggleWatchlist(${x.uid},this);setTimeout(loadWatchlistView,200)">★</span>${blacklistBtn}</td>`;
       tb.appendChild(tr);
     }
   }
 } catch(e){ console.error('loadWatchlistView fail', e); }
}
// Banner global de alertas de shield expirando (cada 5s)
async function checkWatchlistAlerts(){
 try {
   const r = await fetch('/api/watchlist?_='+Date.now(), {cache:'no-store'});
   const j = await r.json();
   const inner = document.getElementById('wl_alerts_inner');
   const NOW = Math.floor(Date.now()/1000);
   const ALERT_THRESHOLD = 600; // 10 min
   const expiring = (j.rows||[]).filter(r => r.shield_eta > 0 && r.shield_eta <= ALERT_THRESHOLD);
   if(expiring.length === 0){ inner.innerHTML=''; return; }
   inner.innerHTML = expiring.map(r => {
     const conf = r.shield_confidence === 'exact' ? 'exact' : 'inferred';
     return `<div class=wbanner>
       <div>
         <div class=who>⚠ ${r.name||'?'}${r.tag?(' ['+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="this.parentElement.remove()">dismiss</button>
     </div>`;
   }).join('');
 } catch(e){}
}
setInterval(checkWatchlistAlerts, 5000);
// fire initial check tras 2s para que load_watchlist se haya hecho
setTimeout(checkWatchlistAlerts, 2000);

// ---- 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; }
}
async function blacklistUid(uid){
 if(!confirm("Discard uid="+uid+"? Goes to blacklist: won't auto-re-add.")) return;
 try {
   await fetch('/api/watchlist/blacklist', {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({uid})});
   WATCHLIST_UIDS.delete(uid);
   updateWatchlistCount();
   loadWatchlistView();
 } catch(e){ alert('blacklist failed: '+e); }
}
const SEL=new Map();              // id -> label seleccionado
const FAM=new Set();              // familias rapidas activas
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 r=await fetch('/api/attacks?_='+Date.now(),{cache:'no-store'});
 const j=await r.json();
 ATKS = j.attacks||[]; ATKS_TS = Date.now()/1000;
 renderAttacks();
}
function renderAttacks(){
 const panel=document.getElementById('atkpanel');
 if(!SHOWATK){panel.style.display='none'; return;}
 panel.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');
 // 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);
 document.getElementById('atkcount').textContent =
   `${list.length} shown · ${ATKS.length} total`;
 const tb=document.getElementById('atkrows'); tb.innerHTML='';
 for(const a of list){
  const tr=document.createElement('tr');
  const eta=a._eta;
  if(eta<30) tr.className='eta-imminent';
  const ph=a.phase;
  const phLab = ph==='wait'?'WAIT':ph==='way'?'MARCH':ph==='combat'?'COMBAT':ph==='scout'?'SCOUT':'SOLO';
  const who = a.name ? ((a.tag?('['+a.tag+'] '):'')+a.name) : ('u'+a.uid);
  const whoExtra = a.kind==='alliance' && a.count>1 ? ` +${a.count-1}` : '';
  const tgt = a.tname ? `${a.tname}${a.tlevel?(' Lv'+a.tlevel):''}` : `tile(${a.tx},${a.ty})`;
  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}</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.sy})</span></td>
    <td><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();} else {document.getElementById('atkpanel').style.display='none';}
}
// countdown vivo cada 1s (solo display, sin pedir al servidor)
setInterval(()=>{ if(SHOWATK) renderAttacks(); }, 1000);
async function loadFamilies(){
 try{
  // pasar el mismo max_seen que usa la tabla principal para que el conteo del
  // chip coincida con los items visibles (sin esto, el chip contaba el cache
  // acumulado de 24h y la tabla solo el último ciclo).
  const ma=document.getElementById('maxage').value;
  const ms = ma ? Math.round(parseFloat(ma)*60) : '';
  const r=await fetch('/api/families?_='+Date.now()+(ms?'&max_seen='+ms:''),{cache:'no-store'});
  const j=await r.json();
  const cb=document.getElementById('fams_boss'),ce=document.getElementById('fams_event');
  cb.innerHTML='';ce.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');load();};
   (f.group==='boss'?cb:ce).appendChild(b);}
 }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();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();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 + order: estado JS sincronizado con el dropdown y los headers clicables.
let SORT_ORDER = 'desc';   // 'asc' | 'desc'
function setSort(field, order){
 document.getElementById('sort').value = field;
 SORT_ORDER = order;
 updateSortArrows();
}
function onHeaderSort(field){
 const cur = document.getElementById('sort').value;
 // mismo campo: toggle order; campo distinto: cambiar sort y mantener order
 const newOrder = (cur === field) ? (SORT_ORDER === 'desc' ? 'asc' : 'desc') : 'desc';
 setSort(field, newOrder);
 load();
}
function updateSortArrows(){
 // Esta sirve solo a Monsters tab (clases th.sortableH originales). Players/Resources/Watchlist
 // usan funciones propias (updatePlayerSortArrows, updateResSortArrows, updateWlSortArrows)
 // para no colisionar.
 const cur = document.getElementById('sort').value;
 document.querySelectorAll('th.sortableH').forEach(th => {
  const active = th.dataset.sort === cur;
  th.classList.toggle('active', active);
  const arr = th.querySelector('.arrow');
  if (arr) arr.textContent = active ? (SORT_ORDER === 'desc' ? '▼' : '▲') : '⇅';
 });
}
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(()=>{
  alert(`${lines.length} filas copiadas al portapapeles`);
 }).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 ma=document.getElementById('maxage').value;
 const q=new URLSearchParams({ids,fams,type:document.getElementById('type').value,
  cx:document.getElementById('cx').value,cy:document.getElementById('cy').value,
  radius:document.getElementById('rad').value,sort:document.getElementById('sort').value,
  order:SORT_ORDER,
  limit:document.getElementById('lim').value,
  page:String(curPage)});
 if(ma) q.set('max_seen', String(Math.round(parseFloat(ma)*60)));
 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, server 2163). 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}</td><td class=lvl>${x.level}</td><td>${fmtPow(x.power)}</td>
   <td><span class="grp g-${g.toLowerCase()}">${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>${fmtAgo(x.age)}${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';
function switchTab(t){
 TAB=t;
 const views={mon:'view_mon', pl:'view_players', res:'view_resources',
              rel:'view_relics', arc:'view_arctic', sc:'view_subcities',
              rs:'view_respawns', rl:'view_relocations', wl:'view_watchlist'};
 const tabs ={mon:'tab_mon', pl:'tab_pl', res:'tab_res',
              rel:'tab_rel', arc:'tab_arc', sc:'tab_sc',
              rs:'tab_rs', rl:'tab_rl', wl:'tab_wl'};
 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==='res') loadResources();
 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 load();
}

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';}

// ── 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 cb = document.getElementById('fams_boss_rs');
  const ce = document.getElementById('fams_event_rs');
  if(!cb || !ce) return;
  cb.innerHTML=''; ce.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();
   };
   (f.group==='boss' ? cb : ce).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
   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-${g.toLowerCase()}">${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('rl_cx').value;
  const cy = document.getElementById('rl_cy').value;
  const rad = document.getElementById('rl_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 || `uid${x.uid}`;
   const ally = x.tag ? `[${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();
 } 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 = '';
 }
}
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;
 }
}
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';
 if (!confirm(`HARD RESET ${label}? This will:\\n- kill frida-server\\n- force-stop Evony\\n- relaunch everything\\n\\nTakes ~40s. Only use this if normal reattach doesn't work.`)) 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);
 }
}
refreshScannerBtn();

// ---- 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();
}
// 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(){
 // 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 {}
}
function showShieldAlert(a){
 const div = document.createElement('div');
 div.className = 'toast expiring';
 const txt = fmtLeft(a.left);
 const who = (a.tag?`[${a.tag}] `:'') + (a.name||'?');
 div.innerHTML = `<button class=close onclick="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: ${a.src}</div>`;
 document.getElementById('toasts').appendChild(div);
 // auto-dismiss tras 12s
 setTimeout(()=>div.remove(), 12000);
 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) {
     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_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}` : '';
         return `<div class=shitem>
           <span class=left>${fmtLeft(x.left)}</span>
           <span class=nm>${who}</span>
           ${confBadge(x.confidence)}
           <div class=meta>${coords} ${castle} · ${uidStr} · src=${x.src}</div>
         </div>`;
       }).join('');
     }
   }
 } catch (e) {}
}
refreshShields();
setInterval(refreshShields, 3000);
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_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. Extended +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>${x.tag}</span>` : '<span class=stat>—</span>';
  let pw;
  if (+x.power > 0) {
    const tip = x.power_src==='power_rank'
      ? 'Captured from Power Rankings (open Rankings → Power Ranking in-game to refresh)'
      : 'From broadcast';
    pw = `<span title="${tip}">${fmtPow(x.power)}</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>`;
  }
  tr.innerHTML=`<td>${star} ${x.name||'?'}</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);
 }
}

// ----- 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 || ('u'+x.occupy_uid);
 const tag = x.occupy_tag ? `[${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`;
}

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 power from client (GetPower calc)">${fmtPow(x.power)}</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==='pl'){  if(auto) await loadPlayers();   return; }
 if(TAB==='res'){ if(auto) await loadResources(true); 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; }
 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();})();
Promise.all([loadCatalog(),loadFamilies(),loadFamiliesRs(),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>"""

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 do_GET(self):
        from urllib.parse import urlparse, parse_qs
        u = urlparse(self.path)
        if u.path == "/" or u.path == "/index.html":
            return self._send(200, 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/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
            return self._send(200, json.dumps(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/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/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/scanner":
            # GET = estado actual; POST = cambiar (action=pause|resume)
            return self._send(200, json.dumps({"paused": SCANNER_PAUSED}))
        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:
                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):
                        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 y no se ha alertado aun
                        if 0 < left <= SHIELD_ALERT_THRESHOLD and k not in SHIELD_ALERTED:
                            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", "?"),
                            })
                            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 and rad is not None:
                        d = math.hypot(o["wx"] - cx, o["wy"] - cy)
                        if 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
            try: max_seen = float(params["max_seen"]) if params.get("max_seen") else STALE_SECONDS
            except: max_seen = STALE_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 OBJS.values():
                    tval = int(o.get("t", 0))
                    if tval not in RELIC_TYPES: continue
                    if (now - o.get("ts", 0)) > max_seen: continue
                    kind_name = RELIC_TYPES[tval]
                    if kind_filter and kind_filter != kind_name: continue
                    cfg = CFG.get(str(o["id"])) or {}
                    nm = cfg.get("name", "") or f"{kind_name} 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 and rad is not None:
                        d = math.hypot(o["wx"] - cx, o["wy"] - cy)
                        if 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 == "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
            try: max_seen = float(params["max_seen"]) if params.get("max_seen") else STALE_SECONDS
            except: max_seen = STALE_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 and rad is not None:
                        d = math.hypot(o["wx"] - cx, o["wy"] - cy)
                        if 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 and rad is not None:
                        d = math.hypot(s["wx"] - cx, s["wy"] - cy)
                        if 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 "")
                    out.append({
                        "id": sid, "name": s.get("name", "") or "?",
                        "level": lv, "power": pw_final, "power_src": pw_src,
                        "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": 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/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":
            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: lista separada por comas (igual formato que /api/data)
            ids = set()
            for s in qs.get("ids", [""])[0].split(","):
                s = s.strip()
                if s.isdigit(): ids.add(int(s))
            # fams: separadas por "||" (igual formato que /api/data)
            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, 19, 20, 21, 43}
            ALLY_MTYS   = {19, 20, 21}
            PHASE_NAME  = {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"}
                for (tx, ty), sub in MARCH_BY_TGT.items():
                    ms = []
                    for trp in sub.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
                        ms.append(m)
                    if not ms: continue
                    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 ""
                                tname  = f"[{tg}] {nm}" if tg else nm
                                tlevel = tpl.get("clv", 0) or tlevel
                                tgroup = "Player" if ttype == 1 else "SubCity"
                            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).
                        # 2) tg_id + CFG (raro pero posible).
                        # 3) PLAYERS por coords (castillo de jugador).
                        # 4) Label semantico segun mty (no se queda como "tile(X,Y)").
                        mhist = MONSTERS_BY_TILE.get((tx, ty))
                        if mhist and (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:
                            tpl = tile_pl.get((tx, ty))
                            if tpl:
                                nm = tpl.get("name", "") or f"u{tpl.get('uid', '?')}"
                                tg = tpl.get("tag", "") or ""
                                tname  = f"[{tg}] {nm}" if tg else nm
                                tlevel = tpl.get("clv", 0)
                                tgroup = "Player"
                                ttype  = 1
                        # Si tras todos los fallbacks aun no resolvimos, dar un label
                        # semantico segun el tipo de marcha en vez de "tile(X,Y)":
                        if not tname:
                            label_by_mty = {
                                2:  "Monster (unknown)",
                                19: "Boss rally (unknown)",
                                20: "Boss rally (marching)",
                                21: "Boss rally (combat)",
                                43: "Scout monster (unknown)",
                            }
                            tname  = label_by_mty.get(mty, f"Target tile")
                            tgroup = "Unknown"
                    out.append({
                        "tx": tx, "ty": ty,
                        "tname": tname, "tlevel": tlevel, "tgroup": tgroup, "ttype": ttype,
                        "tg_id": tg_id,   # debug: target_id del march, para diagnosticar resolucion
                        "kind": kind, "phase": PHASE_NAME.get(mty, "march"), "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)),
                        "sx":    int(rep.get("sx", 0)),
                        "sy":    int(rep.get("sy", 0)),
                    })
            out.sort(key=lambda a: a["eta"])
            return self._send(200, json.dumps({"now": int(now), "n": len(out), "attacks": out}))
        return self._send(404, "{}")

    def do_POST(self):
        from urllib.parse import urlparse, parse_qs
        u = urlparse(self.path)
        length = int(self.headers.get("Content-Length", "0") or "0")
        raw = self.rfile.read(length) if length > 0 else b""
        try: body = json.loads(raw) if raw else {}
        except: body = {}
        if u.path == "/api/scanner":
            global SCANNER_PAUSED
            action = (body.get("action") or "").lower()
            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 not in ("pause", "resume"):
                return self._send(400, json.dumps({"error": "action must be 'pause'|'resume'|'restart'"}))
            SCANNER_PAUSED = (action == "pause")
            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/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/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()}
            try: uid = int(params.get("uid", 0) or 0)
            except: uid = 0
            if uid <= 0:
                return self._send(400, json.dumps({"error": "uid 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]
            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
            # power cache: purga entradas no actualizadas en >2h (uids ya rankeados que ya no estan)
            pwdead = [uid for uid, p in PLAYER_POWER.items() if p.get("ts", 0) < now - 7200]
            for uid in pwdead:
                del PLAYER_POWER[uid]
            # 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]
        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():
    """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."""
    try:
        with LOCK:
            payload = {
                "version": 1,
                "saved_at": time.time(),
                "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()},
                "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, "STATE": {"cfg_n": STATE["cfg_n"]},
                "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()},
            }
        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():
                if v.get("ts", 0) < cutoff: continue
                PLAYER_POWER[int(k_str)] = v
                n_pwr += 1
            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 y WCFG: 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
            STATE["cfg_n"] = len(CFG)
            # SPAWN_HISTORY: filtrar entradas demasiado viejas (>PERSIST_TTL)
            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
        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"{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 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_cache()   # restaurar histórico antes de arrancar nada
    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()
    srv = ThreadingHTTPServer(("127.0.0.1", PORT), H)
    print(f"[web] http://127.0.0.1:{PORT}  v2 DUAL (W=emulator-5554 / E=emulator-5556)  (Ctrl+C para parar)", 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()
