#!/usr/bin/env python3
# ============================================================================
# bot_v1 — backend + UI (puerto 8780). INDEPENDIENTE de V3.
#   - UI simple: elegir qué monstruos (name+level) atacar, en modo rally o solo.
#   - Datos de monstruos: se piden a la API del ESCÁNER (http://<bind>:8773/api/data — Pixel),
#     firmando una cookie con el secret de su users.json (mismo Mac).
#   - Automatización: attacha el agente Frida (agent_bot.js) a emulator-6004 y,
#     al pulsar Apply, lanza rallies/solos sobre los monstruos seleccionados
#     ordenados por distancia a la ciudad del bot, en los slots libres.
#
# FLUJO de primer test:
#   1) Loguea la cuenta nueva en emulator-6004 (Switch Account en Evony).
#   2) "Capturar SOLO": haz 1 ataque solo manual a un monstruo -> se aprende el preset.
#   3) "Capturar RALLY": abre 1 rally de alianza manual -> se aprende el preset.
#   4) Marca monstruos + modo y pulsa Apply -> el bot replica en slots libres.
# ============================================================================
import os, sys, json, time, hmac, hashlib, base64, threading, traceback, math, random, uuid
import urllib.request
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import urlparse, parse_qs

HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, HERE)
import scanner_store        # APP HÍBRIDA: almacén del mapa alimentado por el propio agente

PORT = int(os.environ.get("BOT_PORT", "8791"))     # V2: 8791 para NO chocar con la V1 híbrida (:8790); ambas usan emulator-6008 -> correr UNA a la vez
# 0.0.0.0 = escucha en TODAS las interfaces -> accesible desde la red local
# (http://192.168.0.18:8783) y también por Tailscale (http://100.127.215.100:8783).
# ⚠️ SIN auth.json la app NO pide contraseña: cualquiera que llegue a este puerto puede
# controlar el bot, incluido el botón que compra buffs con GEMAS. Para cerrarlo otra vez:
# BOT_BIND=127.0.0.1 ./run.sh   (o crear auth.json con {"user": "...", "pass": "..."}).
BIND = os.environ.get("BOT_BIND", "0.0.0.0")
# README / tutorial: documento HTML autónomo servido en /readme y abierto desde el header (botón
# 📖 README) en un modal <iframe>. Se carga 1 vez al arrancar; actualizar = reemplazar readme.html + reiniciar.
def _load_readme():
    try:
        with open(os.path.join(HERE, "readme.html"), "r", encoding="utf-8") as _f:
            _b = _f.read()
        if "<!doctype" not in _b[:200].lower(): _b = "<!doctype html>\n" + _b
        return _b
    except Exception:
        return ("<!doctype html><meta charset=utf-8><body style='font-family:system-ui;background:#0d1117;"
                "color:#e6edf3;padding:40px'><h2>README not available</h2>"
                "<p>readme.html was not found on the server.</p></body>")
README_HTML = _load_readme()
def _bot_variant():
    """Version/variant tag for the header (LITE / V1 / V2), derived from the folder this backend runs
    from. No instance name (dasea/lume) -> just the code variant. Same code in all bots."""
    p = HERE.rstrip("/")
    return "LITE" if "bot_lite" in p else os.path.basename(p).replace("bot_", "").upper()

def _load_auth():                                      # credenciales del login: env -> auth.json (gitignored) -> sin auth
    u = os.environ.get("BOT_USER", ""); pw = os.environ.get("BOT_PASS", "")
    if not pw:
        try:
            a = json.load(open(os.path.join(os.path.dirname(os.path.abspath(__file__)), "auth.json")))
            u = u or a.get("user", ""); pw = a.get("pass", "")
        except Exception: pass
    return (u or "bot"), pw
AUTH_USER, AUTH_PASS = _load_auth()                    # AUTH_PASS vacío = auth OFF (uso local sin auth.json)
_SESSION_TOKEN = hmac.new(hashlib.sha256(AUTH_PASS.encode()).digest(), b"bot_pixel_session", hashlib.sha256).hexdigest() if AUTH_PASS else ""
EMULATOR = os.environ.get("BOT_EMULATOR", "emulator-6008")   # AVD evony_hibrid (cuenta única de la app)
AGENT_JS = os.path.join(HERE, "agent_bot.js")
V3_DIR = os.environ.get("V3_DIR", "/Users/danicosta/Desktop/Evony/evony-scout-v3-research")
V3_BASE = os.environ.get("V3_BASE", "")  # se autodetecta de tailscale si vacío

# ---------------------------------------------------------------- estado global
LOCK = threading.RLock()   # RLock (reentrant): logmsg()/save_state() also take LOCK, and several code paths call them while ALREADY holding it -> a plain Lock self-deadlocks (froze the whole bot). RLock lets the same thread re-acquire.
STATE = {
    "bot_city": {"x": 0, "y": 0},     # override manual; si 0,0 se usa la ciudad leída por el scan
    "max_slots": 6,                    # tope de slots a lanzar por Apply (se usa free_slots del scan)
    "msize_boost": True,               # AUTO-BOOST March Size: escala solo/rally/steal por el multiplicador REAL del buff mientras está activo (ON por defecto)
    "msize_bonus_base": None,          # bonus de March Size SIN item (GetResProduceBuffer con eta<=0); base para calcular el multiplicador real del buff
    "server_id": 1939,
    "margin": 1.2,                     # tropas a enviar: poder >= poder_monstruo * margin (calibrable)
    "win_ratio": 0.8,                  # ¿podemos GANAR? poder_preset >= poder_monstruo × win_ratio. El power de V3 sobreestima al monstruo, por eso <1.0. Tunable
    "steal_min_power": 0,              # STEAL: no robar monstruos por debajo de este poder (en MILLONES; 0 = sin mínimo). Ej 10 = no robar rallies < 10M
    "cap": 500000,                     # tope de tropas por marcha (seguridad)
    "auto": False,                     # auto-farm continuo (rellena slots al volver las marchas)
    "lane_disp": {},                   # per-preset target context (name/level/coords/steal) for the progress bar; persisted so returning marches keep their label after a backend restart
    "cooldown": 360,                   # s sin re-atacar el mismo monstruo (cubre el viaje ida/vuelta)
    "rally_time": 300,                 # duración de reunión del rally de alianza (seg; capturado=300)
    "steal": False,                    # RALLY STEALS: robar ataques activos de otras alianzas (pausa el farm)
    "target_tags": "",                 # RALLY STEALS: alianza(s) OBJETIVO a robar (coma-separadas; vacío = todas). Ej "NUD,TDL"
    "steal_sec_per_tile": 1.5,         # estimación de viaje (seg/tile). MEDIDO en vivo: Hydra dist301->457s real = ~1.52. Calibrable
    "steal_exclude": "",               # nombres de monstruo a NUNCA robar (manual, coma-separados; ej "Viking"). Robusto por nombre
    "steal_enemy_buffer": 90,          # seg que el rally enemigo tarda en marchar+aterrizar TRAS reunir (no medible desde V3; tunable)
    "steal_combat_margin": 25,         # seg que el enemigo tarda en MATAR el monstruo tras llegar (ventana extra para llegar antes de que muera; tunable). 0 = robo solo si llegamos antes que el enemigo
    "steal_win_margin": 0,             # seg de ventaja EXIGIDA para robar: feasible si their_kill > our_eta + margen. 0 = robar siempre que lleguemos nominalmente antes que su kill (agresivo, coge los empates a foto). Subir = más colchón, menos marchas desperdiciadas. Antes era 3 fijo
    "spt_meas": {},                    # general_id(str) -> seg/tile REAL medido (EMA), PERSISTIDO (sobrevive restarts). Calibra our_eta por general; vacío = usa steal_sec_per_tile
    "target_fps": 5,                   # fps a los que se LIMITA el juego (el bot no necesita frames: lee memoria + manda protobuf). Evony por defecto va a 30 -> el emulador quemaba ~74% CPU renderizando para nadie; a 5 baja a ~30% (medido 2026-07-03). 0/negativo = no tocar
    "auto_tick": 4,                    # seg del loop base. El ciclo de STEAL corre CADA tick (robar casi inmediato); pesadas/farm van throttled
    "farm_jitter": True,               # jitter humano en la cadencia de farm/join (intervalos aleatorios). El steal NO se jitterea (dispara al instante)
    "cooldown": 360,                   # EXPERT: seg antes de re-atacar el mismo monstruo. Clamp 60-1800
    "farm_seen_max": 120,              # EXPERT: máx seg desde la última vez visto para farmear roaming (=FARM_SEEN_MAX). Clamp 30-600
    "rally_seen_max": 600,             # EXPERT: idem boss/event de rally (=RALLY_SEEN_MAX). Clamp 120-1800
    "wipe_strikes": 2,                 # EXPERT: aniquilaciones antes de auto-bloquear (=WIPE_STRIKES). Clamp 1-5
    "wipe_frac": 0.9,                  # EXPERT: fracción de tropas perdidas que cuenta como aniquilación (=WIPE_FRAC). UI en %. Clamp 0.5-1.0
    "wipe_window_h": 6,                # EXPERT: horas de bloqueo por aniquilación (=WIPE_WINDOW/3600). Clamp 1-24
    "expert_targets_open": False,      # EXPERT: panel de Targets abierto (GLOBAL en servidor, NO localStorage)
    "expert_steals_open": False,       # EXPERT: panel de Steals abierto (GLOBAL en servidor)
    "share_rally": True,               # compartir al chat de alianza los rallies del bot (comportamiento por defecto de Evony con "Share to Alliance Chat" ON)
    "chat_session_id": 0,              # session_id del chat (UInt32) aprendido por el hook SendCoordMessage; necesario para el share. Se siembra con 1 share manual y persiste mientras el juego siga logueado
    "refill": False,                   # AUTO-REFILL stamina: usar items de stamina del bag cuando baje del umbral
    "refill_threshold": 500,           # recargar cuando stamina < este valor
    "refill_target": 2000,             # recargar consumiendo items hasta alcanzar >= este valor
    "heal": False,                     # AUTO-HEAL: curar heridos con recursos+tiempo (NUNCA gemas) cuando la cola esté libre
    "heal_threshold": 1,               # curar sólo si hay >= este nº de heridos (1 = cura siempre que haya alguno)
    "auto_truce": False,               # AUTO-TRUCE: renovar el escudo de paz (burbuja) con Truce Agreements antes de que caiga
    "truce_renew_h": 2,                # renovar cuando queden < estas horas de escudo (0 = renovar solo cuando ya cayó)
    "max_send": {},                    # general_id(str) -> tope de tropas por marcha (aprendido de update_max_send_num del server). Clampa las marchas para que no las rechace (varía por general+tier; depende de la cuenta).
    "max_send_conf": {},               # general_id(str) -> True if the cap is CONFIRMED (a march went out / set by hand) vs still converging (rejections)
}
AUTO_FIRED = {}           # (wx,wy) -> ts del último disparo (cooldown anti re-pegar al mismo monstruo)
AUTO_HOLD = {}            # (wx,wy) -> segundos que ese coord queda BLOQUEADO para CUALQUIER preset (= ventana real de la marcha: rally=reunión+viaje). Extiende la vida de AUTO_FIRED más allá del cooldown global (360s) para que un rally largo NO se re-dispare por OTRO preset a mitad de viaje -> anti-DUPLICADO cross-preset. Se purga junto a AUTO_FIRED
AUTO_LANES = {}           # preset_index -> {"target": (wx,wy), "ts": ts}  (1 marcha en vuelo por preset)
RALLY_MAX_HOLD = 1800     # tope de seguridad (s) que una lane RALLY queda ocupada aunque el monstruo siga vivo (rally atascado/contestado) -> anti-lane-zombie. Los rally NO aparecen en in_flight, así que la lane se retiene mientras el monstruo esté en el mapa (rally en curso), y este es el corte máximo
PENDING_SHARES = {}       # general_id -> {tx,ty,name,level,cid,ts,shared}  (rallies del bot pendientes de compartir al chat al aparecer su mass_id)
LANE_DUR = {}             # preset_index -> duración (s) del tramo de ida capturada de march_targets (indicador UI)
STEAL_PENDING = {}        # monster_id -> {tx,ty,ts,name,lane}  (robos disparados pendientes de verificar que salió la marcha)
STEAL_FIRED = {}          # (tx,ty) -> {ts,name,id,level}  (robos en curso: para mostrar "Stealing…"+ETA y NO sacarlos de la lista)
STEAL_SEEN = {}           # (tx,ty) -> ts de un robo CONFIRMADO lanzado: el monstruo MORIRÁ en la carrera (lo matamos o el rally enemigo) -> NO re-robar ese tile durante STEAL_SEEN_TTL. Evita re-robar un monstruo ya muerto cuando V3 reporta el ataque con lag (la causa de "se repite el steal aunque ya está muerto").
STEAL_SEEN_TTL = 1200     # s que un tile queda excluido tras un robo lanzado (cubre ida+vuelta de la marcha + lag de V3 + muerte del monstruo)
SKIP_SEEN = {}            # (tx,ty,atk_name,reason) -> ts: rally de alianza OBJETIVO que NO robamos, ya registrado en History (dedup; re-loguea tras 1200s si sigue)
# HARDCODE: monstruos que NUNCA se roban/atacan — solo los pueden matar los propios miembros de la alianza enemiga
# (intentar robarlos = marcha perdida). Royal Thief: Nightingale, Nell, Duke. Match por SUBSTRING (case-insensitive).
# Para añadir más: amplía esta tupla (o usa el campo manual 'steal_exclude' en la UI).
STEAL_HARD_EXCLUDE = ("royal thief", "nightingale", "nell", "duke")
def _spt_for(gid):
    """seg/tile de un general: el medido y PERSISTIDO en STATE['spt_meas'] (sobrevive restarts) o el config 'steal_sec_per_tile'."""
    v = (STATE.get("spt_meas") or {}).get(str(int(gid or 0)))
    return float(v) if v else float(STATE.get("steal_sec_per_tile", 1.5) or 1.5)
MONCFG = {}               # id_config -> {"name","level","power"}  (tabla estática del juego, volcada por el agente)
MONCFG_NL = {}            # (nombre_lower, nivel) -> id   (resolución FIABLE nombre+nivel)
MONCFG_N = {}             # nombre_lower -> id            (solo nombre: nivel AMBIGUO)
_MONCFG = {"last": 0.0}   # throttle de la petición dump_moncfg
ACCOUNT = {}              # cuenta leída por el agente (scan): troops/city/free_slots/generals
TEMPLATES = {}            # "solo"/"rally" -> dict del send_troop capturado
# Cola de marchas: cada preset = una marcha que el bot enviará al monstruo seleccionado más cercano.
PRESETS = [{"enabled": False, "mode": "solo", "general_id": 0, "assistant_id": 0, "dist_prio": True, "override": False, "override_pow": 0, "troops": [], "targets": []} for _ in range(6)]

def _troop_meta(tid):
    for t in ACCOUNT.get("troops", []):
        if int(t.get("id", 0)) == int(tid): return t
    return {"tier": 0, "power": 0}

def _gen_atk(gid):
    for g in ACCOUNT.get("generals_list", []):
        if int(g.get("id", 0)) == int(gid): return g.get("atk", 0)
    return 0

def _gen_name(gid):
    for g in ACCOUNT.get("generals_list", []):
        if int(g.get("id", 0)) == int(gid): return g.get("name") or ("#" + str(gid))
    return ("#" + str(gid)) if gid else "—"

# ---- persistencia (presets + config + selección sobreviven reinicios) ----
STATE_FILE = os.path.join(HERE, "bot_state.json")
def save_state():
    try:
        with open(STATE_FILE, "w") as f:
            json.dump({"presets": PRESETS, "state": STATE, "selection": SELECTION}, f, indent=1)
    except Exception as e:
        logmsg(f"save_state err: {e}")
def load_state():
    try:
        if not os.path.exists(STATE_FILE): return
        d = json.load(open(STATE_FILE))
        if isinstance(d.get("presets"), list) and d["presets"]:
            PRESETS[:] = d["presets"][:6]
        if isinstance(d.get("state"), dict):
            STATE.update({k: v for k, v in d["state"].items() if k in STATE})
        if isinstance(d.get("selection"), dict):
            SELECTION.update(d["selection"])
        for pp in PRESETS:                       # adapta presets guardados al tope de marcha conocido (por si se guardaron antes de aprenderlo)
            pp.setdefault("targets", [])   # presets guardados antes de la feature de target por-preset
            pp.setdefault("dist_prio", True)   # toggle prioridad-por-distancia (presets guardados antes de esta feature -> ON por defecto = comportamiento previo)
            pp.setdefault("override", False); pp.setdefault("override_pow", 0)   # override de winnability (aislado; OFF = comportamiento previo)
            try: _clamp_preset_to_cap(pp)
            except Exception: pass
        logmsg(f"state restored: {sum(1 for p in PRESETS if p.get('enabled'))} active presets, "
               f"{sum(1 for v in SELECTION.values() if v.get('on'))} marked monsters")
    except Exception as e:
        logmsg(f"load_state err: {e}")
PENDING_CAPTURE = {"label": None}      # etiqueta armada para la próxima captura
SELECTION = {}            # "name|level" -> {"name","level","mode":"solo"|"rally","on":bool}
AGENT = {"script": None, "ready": False, "last_hb": 0, "server": 0, "attached_at": 0, "connected": None, "stopped_kick": False}   # connected: 1=juego conectado, 0=kicked/desconectado, -1/None=desconocido | stopped_kick: instancia cerrada a propósito por kick sostenido (esperando "Reload")
LOGBUF = []               # ring de eventos para la UI
FIRES = []                # historial de disparos
# ---- HISTORY: registro detallado de acciones del bot (steal/solo/rally/join/heal/refill) para la pestaña History ----
HISTORY = []              # ring de eventos {ts, kind, ...}; persistido en bot_history.json
HIST_FILE = os.path.join(HERE, "bot_history.json")
_HIST = {"last_save": 0.0}
_ATK_ETA = {}             # (tx,ty) -> {"eta","ts","sped"}  detecta si el ENEMIGO usa speedups/gemas (su ETA baja más rápido que el reloj real)
def hist(kind, **kw):
    """Añade un evento al historial (ts+kind+campos), capa a 500, guarda (throttled). Devuelve el dict (mutable: el resultado de un robo se actualiza al confirmarse)."""
    ev = {"ts": time.time(), "kind": kind}; ev.update(kw)
    with LOCK:
        HISTORY.append(ev)
        if len(HISTORY) > 1500: del HISTORY[:len(HISTORY) - 1500]
    now = time.time()
    if now - _HIST["last_save"] > 3:
        _HIST["last_save"] = now
        try:
            with open(HIST_FILE, "w") as f: json.dump(HISTORY[-1500:], f)
        except Exception: pass
    return ev
def _seed_miss_seen():
    """Siembra _MISS_SEEN con los mail_id ya registrados: sin esto, tras un reinicio se reimportaban los mismos
    mails 'Target Disappeared' y aparecían filas MISS duplicadas en el History."""
    try:
        for e in HISTORY:
            if e.get("kind") == "miss" and e.get("mid"): _MISS_SEEN.add(str(e["mid"]))
    except Exception: pass

def _seed_fire_locks():
    """Siembra AUTO_FIRED y STEAL_SEEN (anti-DUPLICADO por-coord) desde el HISTORY persistido. Sin esto, CADA reinicio del
    backend borra estos dicts en memoria y el bot re-dispara/re-roba una coord que acaba de atacar. Caso real (dasea, 31-07):
    reinicié el backend justo entre dos steals y P3 re-robó @714,492 que P4 acababa de robar -> llegó al monstruo ya muerto.
    Solo cuenta eventos cuyo march SALIÓ (won/lost/gone/done/sent); ignora rejected/skip/cant_win (no ocuparon la coord)."""
    now = time.time()
    _cd = max(int(STATE.get("cooldown", 360) or 360), 360)
    try:
        for e in HISTORY:
            if e.get("kind") not in ("steal", "farm"): continue
            if e.get("result") not in ("won", "lost", "gone", "done", "sent"): continue
            tx, ty = e.get("tx"), e.get("ty")
            if tx is None or ty is None: continue
            k = (int(tx), int(ty)); ts = float(e.get("ts", 0) or 0)
            _hold = max(int(e.get("our_eta", 0) or 0) + 900, 1200) if e.get("mode") == "rally" else _cd   # RALLY: lock LARGO (~20min: cubre reunión+viaje), igual que _coord_cd al disparar. Sin esto, tras un restart solo valía el cooldown de 360s y el bot RE-LANZABA el rally a los ~6min (bug @523,719: 2º rally al Ymir ya muerto)
            if now - ts < _hold and ts > AUTO_FIRED.get(k, 0):
                AUTO_FIRED[k] = ts                                                     # cooldown por-coord
                if _hold > int(AUTO_HOLD.get(k, 0) or 0): AUTO_HOLD[k] = _hold          # + el hold largo (lo que faltaba re-sembrar) -> ningún preset re-dispara esa coord durante toda la ventana del rally
            if e.get("kind") == "steal" and now - ts < STEAL_SEEN_TTL and ts > STEAL_SEEN.get(k, 0):
                STEAL_SEEN[k] = ts                                                     # tile robado -> no re-robar (20min)
        logmsg(f"seed anti-duplicado: {len(AUTO_FIRED)} coords en cooldown, {len(STEAL_SEEN)} tiles robados recientes (restaurados del History)")
    except Exception as ex:
        logmsg(f"_seed_fire_locks err: {ex}")

def load_history():
    try:
        if os.path.exists(HIST_FILE):
            d = json.load(open(HIST_FILE))
            if isinstance(d, list): HISTORY[:] = d[-1500:]
    except Exception as e:
        print("[bot_web] load_history err:", e, flush=True)
    _seed_miss_seen()
    _seed_join_state()
    _seed_fire_locks()
def _save_hist():
    try:
        with open(HIST_FILE, "w") as f: json.dump(HISTORY[-1500:], f)
    except Exception: pass
_REPORTS = {"last": 0.0}
def _match_reports(rows):
    """Empareja los battle reports (sys_mail.monster_report del juego) con los eventos steal/farm del History -> resultado REAL.
    row = [monsterId, wx, wy, result(0=win), left_life(0=killed), dead, survived, mail_time]."""
    upd = 0
    with LOCK:
        for r in rows or []:
            try: mid, wx, wy, res, ll, dead, sur, mtime = (int(r[0]), int(r[1]), int(r[2]), int(r[3]), int(r[4]), int(r[5]), int(r[6]), int(r[7]))
            except Exception: continue
            best = None
            for ev in reversed(HISTORY):                      # el evento más reciente NO resuelto en ese tile, disparado ANTES del report
                if ev.get("kind") not in ("steal", "farm"): continue
                if ev.get("confirmed") and ev.get("result") != "gone": continue   # won/lost ya tiene veredicto REAL -> no tocar. PERO un 'gone' de _match_misses (mail 'Target Disappeared' SIN coords, emparejado por TIEMPO) SÍ se corrige: el battle report es COORD-EXACTO y manda -> arregla el "WON marcado como MISS/GONE" cuando un miss time-matched robó el evento
                if int(ev.get("tx", -1)) != wx or int(ev.get("ty", -1)) != wy: continue
                if mtime < int(ev.get("ts", 0)) - 120 or mtime - int(ev.get("ts", 0)) > 2400: continue
                _snt = int(ev.get("troops", 0) or 0)
                if _snt > 0 and sur > _snt * 1.1: continue    # los SUPERVIVIENTES no pueden exceder lo ENVIADO por este preset -> este report es de OTRA marcha (el mismo monstruo/coord farmeado varias veces cruzaba reports -> won/lost EQUIVOCADO). Descartar y seguir buscando el evento correcto
                best = ev; break
            if best is not None:
                won = (res == 0); killed = (ll == 0); was_gone = (best.get("result") == "gone")   # was_gone -> este report CORRIGE un 'disappeared' equivocado
                _snt = int(best.get("troops", 0) or 0); loss = max(0, _snt - sur) if _snt > 0 else max(0, dead)   # PÉRDIDAS reales = enviadas - supervivientes (contra monstruos casi todo son HERIDAS al hospital; 'dead' permanente suele venir 0, por eso antes salía siempre 'no losses')
                _lt = "no losses" if loss == 0 else f"{loss:,} lost"
                best["confirmed"] = True; best["result"] = "won" if won else "lost"
                best["dead"] = dead; best["survived"] = sur; best["left_life"] = ll; best["lost"] = loss
                best["note"] = ((f"killed it, {_lt}" if killed else f"hit it, monster survived · {_lt}") if won else f"lost the battle · {_lt}") + " · battle report" + (" · corrected from mistaken 'disappeared'" if was_gone else "")
                upd += 1
    return upd
def _overlimit_keys():
    """Claves 'mode|name|level' cuyo monstruo ha COSTADO tropas en batalla reciente -> ese target va al límite/por encima de las posibilidades de un preset con ese modo. La UI hace parpadear el pill como llamada de atención.
    Self-healing: mira solo los ÚLTIMOS 3 resultados confirmados por clave (HISTORY es viejo->nuevo, reversed = nuevo primero); si ya ninguno de los 3 tuvo pérdidas (p.ej. se reforzó el preset / se activó override), deja de avisar."""
    by = {}
    for ev in reversed(HISTORY):
        if ev.get("kind") not in ("farm", "steal") or not ev.get("confirmed"): continue
        if ev.get("result") not in ("won", "lost"): continue
        nm = ev.get("name")
        if not nm: continue
        k = f"{ev.get('mode', 'solo')}|{nm}|{int(ev.get('level', 0) or 0)}"
        lst = by.setdefault(k, [])
        if len(lst) < 3: lst.append(ev)
    return [k for k, evs in by.items()
            if any(int(e.get("lost", 0) or 0) > 0 or e.get("result") == "lost" for e in evs)]
_MISS_SEEN = set()
def _match_misses(misses):
    """'Target Disappeared' (Mail->System): el objetivo murió antes de llegar (stamina devuelta). El mail NO trae coords ->
    se empareja por TIEMPO (llega ~ al ETA de llegada de la marcha) con el steal/farm NO confirmado más cercano. dedup por mail_id."""
    upd = 0; now = time.time()
    with LOCK:
        for it in misses or []:
            try: mid = str(it[0]); T = int(it[1])
            except Exception: continue
            if mid in _MISS_SEEN: continue
            _MISS_SEEN.add(mid)
            best = None; bestd = 1e9
            for ev in HISTORY:
                if ev.get("kind") not in ("steal", "farm") or ev.get("confirmed"): continue
                fts = float(ev.get("ts", 0))
                if T < fts - 30 or T - fts > 900: continue          # el mail llega DESPUÉS de disparar, en ≤15 min
                eta = float(ev.get("our_eta", 0) or 0)
                d = abs((fts + eta) - T) if eta > 0 else (T - fts)   # lo más cerca posible del ETA de llegada esperado
                if d < bestd: bestd = d; best = ev
            if best is not None and bestd < 600:                     # el mail llega ~al ETA de llegada -> es ESTA marcha. Un miss = SIN battle report, asi que confirmar 'gone' no oculta ningun veredicto real. 600s (antes 240) cubre errores del estimado de velocidad del general.
                best["confirmed"] = True; best["result"] = "gone"
                approx = "" if bestd < 240 else f" · time-matched approx (±{int(bestd)}s)"
                best["note"] = "target disappeared before arrival — stamina refunded (System mail)" + approx
                upd += 1
            elif T > now - 1800:                                     # sin match fiable: fila propia, PERO enriquecida con el candidato mas probable (si hay) para que NO salga pelada -> el usuario ve que objetivo se perdio
                # FIX 2026-07-31 (filas FANTASMA): _MISS_SEEN vive solo en memoria, así que CADA reinicio del backend
                # reimportaba todos los mails 'Target Disappeared' del buzón como filas NUEVAS. Se veían ráfagas de
                # MISS con el MISMO segundo repetido (hasta 5 copias) que parecían "17 marchas cada 5s" y NUNCA se
                # enviaron (0 fires en el log). Ahora se guarda el mail_id y se deduplica contra el HISTORY PERSISTIDO.
                if any(e.get("kind") == "miss" and (str(e.get("mid") or "") == mid or
                       (not e.get("mid") and int(e.get("ts", 0)) == int(T))) for e in HISTORY):
                    continue
                entry = {"ts": float(T), "kind": "miss", "mid": mid, "result": "gone", "confirmed": True,
                         "note": "target disappeared before arrival — stamina refunded"}
                if best is not None:                                 # hay una marcha sin confirmar en la ventana de 15 min, aunque el tiempo no cuadre fino -> mostrarla como objetivo PROBABLE (no confirmo best: su report real aun puede reconciliar)
                    entry.update({"name": best.get("name"), "level": best.get("level"),
                                  "tx": best.get("tx"), "ty": best.get("ty"), "power": best.get("power"),
                                  "troops": best.get("troops"), "general": best.get("general"), "mode": best.get("mode")})
                    entry["note"] += f" · probable target (weak time-match, ±{int(bestd)}s)"
                else:
                    entry["note"] += " · unknown target (no unconfirmed march in the 15-min window)"
                HISTORY.append(entry)
                upd += 1
    if len(_MISS_SEEN) > 3000: _MISS_SEEN.clear()
    return upd
def _sweep_unconfirmed():
    """Sin battle report tras la ventana = resuelto sin confirmar:
       · SOLO/steal a 15 min -> 'gone' (no hubo combate, el objetivo ya no estaba).
       · RALLY a 20 min -> 'done' (el rally SÍ se lanzó/combatió, pero no llegó un battle report que confirme
         won/lost; NO es 'gone' porque sí hubo acción). Así un rally viejo no se queda colgado en 'pending'
         indefinidamente; si su report llega antes de los 20 min, sale WON/LOST normal."""
    now = time.time(); upd = 0
    with LOCK:
        for ev in HISTORY:
            if ev.get("kind") not in ("steal", "farm") or ev.get("confirmed"): continue
            age = now - float(ev.get("ts", 0))
            if ev.get("mode") == "rally":
                if age > 1200:
                    ev["confirmed"] = True; ev["result"] = "done"; ev["note"] = "rally completed · no battle report was captured to confirm win/loss"; upd += 1
            elif age > 900:
                ev["confirmed"] = True; ev["result"] = "gone"; ev["note"] = "no battle report — target was gone (no fight)"; upd += 1
    return upd
def _maybe_reports():
    """pide los battle reports al agente cada ~8s (frecuente = captura el veredicto REAL antes de que el mail rote
    de memoria en headless -> menos acciones sin confirmar 'done'/'pending')."""
    if not AGENT.get("ready"): return
    now = time.time()
    if now - _REPORTS["last"] < 8: return
    _REPORTS["last"] = now
    post_agent({"type": "ctl", "cmd": "read_reports"})

def logmsg(s):
    line = time.strftime("%H:%M:%S ") + s
    with LOCK:
        LOGBUF.append(line)
        if len(LOGBUF) > 400: del LOGBUF[:200]
    print("[bot_web] " + s, flush=True)

# ---------------------------------------------------------------- cliente V3 API
# Puerto del ESCÁNER que sirve los monstruos. Desde 2026-08-06 la producción es la instancia de
# los MÓVILES (evony-scout-pixel/, puerto 8773); el 8772 era el V4 de emuladores, ya retirado.
# Sobrescribible por entorno para volver atrás sin tocar el código:  SCANNER_PORT=8772 ./run_bot.sh
SCANNER_PORT = os.environ.get("SCANNER_PORT", "8773")

def _v3_base():
    global V3_BASE
    if V3_BASE: return V3_BASE
    try:
        import subprocess
        ip = subprocess.check_output(["tailscale", "ip", "-4"], timeout=5).decode().split()[0]
        V3_BASE = f"http://{ip}:{SCANNER_PORT}"
    except Exception:
        V3_BASE = f"http://127.0.0.1:{SCANNER_PORT}"
    return V3_BASE

def _v3_cookie():
    """Firma una cookie de sesión reutilizando el secret + un sid activo de V3."""
    with open(os.path.join(V3_DIR, "users.json")) as f:
        A = json.load(f)
    secret = A["secret"]; sess = A.get("sessions", {})
    if not sess: raise RuntimeError("V3 sin sesiones activas (logéate en V3 primero)")
    u = next(iter(sess)); s = sess[u]
    sid = s.get("sid") if isinstance(s, dict) else s
    tok = base64.urlsafe_b64encode(f"{u}|{int(time.time())+3600}|{sid}".encode()).decode()
    sig = hmac.new(secret.encode(), tok.encode(), hashlib.sha256).hexdigest()
    return f"iscout_session={tok}.{sig}"

def v3_get(path):
    req = urllib.request.Request(_v3_base() + path, headers={"Cookie": _v3_cookie()})
    return json.load(urllib.request.urlopen(req, timeout=25))

_MONS_XY = {}             # (x,y) -> monster dict from the last scan; lets the progress bar name a march by its coords when the lane has no name (e.g. after a backend restart)
_MONS_XY_ALL = {}         # idem pero con TODAS las filas del escáner (sin el filtro de frescura): se usa SOLO para poner nombre a un tile
                          # (barra de progreso / History). Nunca para decidir objetivos: para eso vale el filtrado _MONS_XY.

def _mon_by_lvpow(level, power):
    """Resuelve el monstruo por (nivel, poder) contra la tabla estática del juego (MONCFG).
    Hace falta porque el rally de alianza NO trae el nombre: guild_war.__target.__name devuelve siempre "1"
    (verificado en vivo), mientras que __level y __power sí son correctos. El poder identifica la config de
    forma prácticamente única (tolerancia 1% por el redondeo del float)."""
    try:
        lv = int(level or 0); pw = float(power or 0)
        if pw <= 0: return None
        best = None
        for mid, c in MONCFG.items():
            cp = float(c.get("power", 0) or 0)
            if cp <= 0: continue
            if abs(cp - pw) / max(cp, pw) > 0.01: continue
            if lv > 0 and int(c.get("level", 0) or 0) != lv: continue
            if best is None or abs(cp - pw) < abs(float(best[1].get("power", 0) or 0) - pw): best = (mid, c)
        if not best: return None
        return {"name": best[1].get("name"), "level": int(best[1].get("level", 0) or 0), "power": int(round(float(best[1].get("power", 0) or 0)))}
    except Exception:
        return None
FARM_SEEN_MAX = 120       # V4: máx seg desde la ÚLTIMA vez visto (seen_age) para considerar un monstruo farmeable (sigue en mapa). Más allá = probablemente ya no está -> marcha 'gone'. El bot elige el más CERCANO de los recién-vistos (mejor probabilidad de llegar). V3 no trae seen_age -> no filtra. Tunable
RALLY_SEEN_MAX = 600      # Boss/Event (targets de RALLY) son ESTACIONARIOS y persisten en el mapa; están en la PERIFERIA, re-barrida cada varios min -> con FARM_SEEN_MAX=180s se caían del set fresco y los rally se morían de hambre AUNQUE seguían en el mapa. Estos toleran verse más antiguos (viven mucho). Los roaming (solo) SÍ se mueven -> 180s estricto. Tunable

def fetch_monsters():
    """Todos los monstruos del mapa (name, level, x, y, group).

    APP HÍBRIDA: los datos ya NO vienen por HTTP del escáner externo (:8773) sino del
    almacén local, alimentado por el módulo escáner del propio agente. Misma cuenta,
    mismo cliente, mismo formato de filas -> el resto del bot no se entera del cambio.
    Además desaparece el truncado a 100k que dejaba sin nombre a la periferia.
    """
    rows = scanner_store.map_rows()
    out = []
    _all = {}
    for r in rows:
        x = int(r.get("x", 0) or 0); y = int(r.get("y", 0) or 0)
        if not x or not y: continue
        nm = r.get("name", "") or ""
        if nm[:2].lower() == "id" and nm[2:].isdigit(): continue   # objeto de mapa SIN nombre real (V3 no lo localizó) -> no es un monstruo
        _all[(x, y)] = {"name": nm, "level": int(r.get("level", 0) or 0), "x": x, "y": y,
                        "id": int(r.get("id", 0) or 0), "power": float(r.get("power", 0) or 0)}   # SOLO para nombrar (sin filtro de frescura)
        _slim = int(STATE.get("rally_seen_max", RALLY_SEEN_MAX) or RALLY_SEEN_MAX) if ((r.get("group", "") or "") in RALLY_GROUPS) else int(STATE.get("farm_seen_max", FARM_SEEN_MAX) or FARM_SEEN_MAX)   # EXPERT: de STATE   # Boss/Event (estacionarios, periferia) toleran ventana amplia; roaming (solo) NO -> se movería y saldría 'gone'
        if int(r.get("seen_age", 0) or 0) > _slim: continue  # V4: /api/data devuelve el histórico ON-MAP (hasta 45min). FARMEAR solo lo VISTO hace poco (seen_age = seg desde la ÚLTIMA vez visto): sin esto el bot marcha a fantasmas caducados -> 'gone'. OJO: NO usar 'fresh' (=recién APARECIDO, resaltado UI 120s desde 1ª vista) -> excluye monstruos cercanos aún presentes. V3 no trae seen_age -> no le afecta
        out.append({"name": nm, "level": int(r.get("level", 0) or 0),
                    "group": r.get("group", "") or "", "x": x, "y": y,
                    "id": int(r.get("id", 0) or 0), "power": float(r.get("power", 0) or 0)})
    global _MONS_XY, _MONS_XY_ALL
    _MONS_XY = {(m["x"], m["y"]): m for m in out}   # index by coords for the progress-bar name fallback
    _now = time.time()                                                    # ACUMULAR (no reemplazar) el mapa de nombres: el escáner
    for _k, _m in _all.items(): _m["_ts"] = _now; _MONS_XY_ALL[_k] = _m   # cambia su set on-map con el tiempo; un tile visto hace
    for _k in [k for k, m in list(_MONS_XY_ALL.items()) if _now - float(m.get("_ts", 0) or 0) > 2400]:  # unos min puede caer del
        _MONS_XY_ALL.pop(_k, None)                                        # barrido -> manteniéndolo 40 min seguimos nombrándolo
    _MONS_FETCH["ts"] = _now                                             # (solo etiquetar; para DECIDIR objetivos vale _MONS_XY)
    return out

_MONS_FETCH = {"ts": 0.0}
def _mons_fresh(ttl=25.0):
    """Asegura _MONS_XY/_MONS_XY_ALL frescos SIN machacar el escáner (caché con TTL). Imprescindible en bots SOLO-autojoin
    (como el Pixel): ni el ciclo farm ni el steal llaman a fetch_monsters, así que el mapa de nombres quedaba VACÍO y los
    joins salían como 'monster' sin nombre ni poder. En bots con solo/rally/steal no refetch-ea (esos ciclos ya lo hacen)."""
    if time.time() - _MONS_FETCH["ts"] < ttl and _MONS_XY_ALL: return
    try: fetch_monsters()
    except Exception as e: logmsg(f"_mons_fresh err: {e}")

_TILE_CACHE = {}          # (x,y) -> (ts, monster|None): caché corta (60s) de consultas dirigidas para no repetir en ráfaga
def _scanner_tile(tx, ty, max_seen=600):
    """Consulta DIRIGIDA al escáner por una coord concreta (cx/cy/radius, filtra ANTES de paginar -> esquiva el truncado
    de 100k). Imprescindible para nombrar rallies de PERIFERIA: el escáner tiene ~138k monstruos y el mapa general del bot
    solo carga el top-100k, dejando fuera la periferia. Devuelve {name,level,power} del tile EXACTO (el más fresco), o None.
    Se llama solo al disparar un join (poco frecuente); no en cada barra."""
    tx = int(tx or 0); ty = int(ty or 0)
    if not (tx and ty): return None
    c = _TILE_CACHE.get((tx, ty))
    if c and time.time() - c[0] < 60: return c[1]
    res = None
    try:
        # APP HÍBRIDA: consulta directa al almacén local. Ya no hace falta el rodeo por
        # HTTP con cx/cy/radius que existía para esquivar el truncado a 100k del escáner
        # externo: aquí tenemos el tile a mano.
        o = scanner_store.OBJS.get((tx, ty))
        if o and o["t"] == scanner_store.MONSTER_T and (time.time() - o["ts"]) <= max_seen:
            nm = scanner_store.name_of(o["id"], o["t"])
            c = scanner_store.CFG.get(str(o["id"])) or {}
            if nm and not nm.startswith("id"):
                res = {"name": nm, "level": int(o["lv"] or c.get("level", 0) or 0),
                       "power": int(float(c.get("power", 0) or 0))}
    except Exception as e:
        logmsg(f"_scanner_tile err: {e}")
    _TILE_CACHE[(tx, ty)] = (time.time(), res)
    return res

def fetch_attacks():
    """Ataques activos del mapa. Devuelve (lista_de_ataques, ally_tag_del_focus).

    APP HÍBRIDA: sale del almacén local. Las marchas (map_target_info) y los jugadores
    (user_summary) vienen en el MISMO reply de worldmap que los objetos, así que el
    módulo escáner los cosecha de paso y aquí se agrupan por objetivo.

    Alimenta la vista de ataques activos y el motor de STEAL (robar el kill). Se filtran
    los refuerzos internos de la propia alianza, que no son objetivos de robo.
    """
    # La cuenta no expone uid ni guild_id, pero sí las coords de la ciudad: el jugador
    # que ocupa ese tile SOY YO. Con eso salen uid y alianza para filtrar mis marchas.
    own_uid = int(ACCOUNT.get("uid", 0) or 0)
    own_gid = int(ACCOUNT.get("guild_id", 0) or 0)
    if not own_uid:
        own_uid, _g = scanner_store.uid_at(ACCOUNT.get("city_x", 0), ACCOUNT.get("city_y", 0))
        own_gid = own_gid or _g
    ally_tag = ""
    if own_uid:
        me = scanner_store.PLAYERS.get(own_uid) or {}
        ally_tag = me.get("tag", "") or ""
        if not own_gid:
            own_gid = int(me.get("gid", 0) or 0)
    return (scanner_store.attacks(own_uid=own_uid, own_gid=own_gid), ally_tag)

def _city():
    c = STATE["bot_city"]
    if c["x"] and c["y"]: return c["x"], c["y"]
    return int(ACCOUNT.get("city_x", 0) or 0), int(ACCOUNT.get("city_y", 0) or 0)

def dist_from_city(x, y):
    cx, cy = _city()
    if not cx or not cy: return 0.0
    return ((x - cx) ** 2 + (y - cy) ** 2) ** 0.5

def size_troops(monster_power):
    """Estrategia 'más fuertes, mínimas para ganar': coge tropas de mayor poder/unidad
    hasta que el poder combinado >= poder_monstruo * margin (tope 'cap' por seguridad)."""
    troops = ACCOUNT.get("troops") or []
    pool = sorted([t for t in troops if (t.get("power") or 0) > 0 and (t.get("num") or 0) > 0],
                  key=lambda t: -t["power"])
    target = (monster_power or 0) * STATE["margin"]
    cap = int(STATE["cap"]); out = []; acc_pow = 0.0; acc_units = 0
    for t in pool:
        if acc_pow >= target or acc_units >= cap: break
        need = target - acc_pow
        units = max(1, math.ceil(need / t["power"]))
        take = min(int(t["num"]), units, cap - acc_units)
        if take <= 0: continue
        out.append({"t": int(t["id"]), "n": int(take), "tier": t.get("tier"), "ppu": t.get("power")})
        acc_pow += take * t["power"]; acc_units += take
    return out, acc_pow, acc_units

# ---------------------------------------------------------------- agente (Frida)
def post_agent(obj):
    sc = AGENT.get("script")
    if not sc: logmsg("post_agent: agent not attached"); return False
    try: sc.post(obj); return True
    except Exception as e: logmsg(f"post_agent err: {e}"); return False

def on_agent_message(message, data):
    try:
        if message.get("type") == "send":
            p = message.get("payload") or {}
            k = p.get("kind")
            # ── MÓDULO ESCÁNER (app híbrida): el agente barre el mapa y vuelca aquí
            # lo que ve. Antes esto llegaba por HTTP del escáner externo (:8773).
            if k == "batch":
                scanner_store.ingest_batch(p.get("items") or [], p.get("server", 0))
                return
            elif k == "marches":
                scanner_store.ingest_marches(p.get("items") or [])
                # SCAN DIRIGIDO: si alguien marcha a un tile que no hemos barrido, no
                # sabemos qué monstruo hay y la fila se cae de Rally Steals. Pedimos esas
                # coords al agente (van por la cola del barrido: cadencia y silencio
                # respetados). Máximo 3 por lote para no saturar el canal de envío.
                for (ux, uy) in scanner_store.take_unknown_tiles(3):
                    post_agent({"type": "scan", "cmd": "priority", "x": ux, "y": uy})
                return
            elif k == "players":
                scanner_store.ingest_players(p.get("items") or [])
                return
            elif k == "config":
                scanner_store.ingest_config(p.get("cfg") or {})
                logmsg(f"[scan] config de monstruos: {p.get('n')} entradas")
                return
            elif k == "wcfg":
                scanner_store.ingest_wcfg(p.get("wcfg") or {})
                logmsg(f"[scan] config de recursos: {p.get('n')} entradas")
                return
            elif k == "scan_metrics":
                scanner_store.ingest_metrics(p)
                sw = p.get("sweep") or {}
                scanner_store.save_progress(sw.get("idx"), sw.get("order"), sw.get("pass"))   # persistir progreso (throttled)
                return
            elif k == "scan_boot":
                # CONTINUIDAD: el módulo escáner acaba de armarse (attach/reinicio) y pide
                # desde dónde retomar. Le devolvemos el índice persistido en disco.
                ridx = scanner_store.resume_index(p.get("order", 0))
                post_agent({"type": "scan", "cmd": "resume_idx", "idx": ridx})
                logmsg(f"[scan] barrido retomado en idx={ridx}")
                return
            elif k == "scan_status":
                logmsg(f"[scan] estado: {json.dumps(p, ensure_ascii=False)}")
                return

            if k == "heartbeat":
                AGENT["last_hb"] = time.time()
                if p.get("server"): AGENT["server"] = p["server"]
            elif k == "ready":
                AGENT["ready"] = True
                logmsg(f"agent READY: {p}")
            elif k == "account":
                _store_account(p)
                if "connected" in p: AGENT["connected"] = p.get("connected")   # estado de sesion del JUEGO (kicked si 0); leido en el scan del agente (cada 15s)
            elif k == "capture_full":
                logmsg("CAPTURE-FULL " + json.dumps(p.get("fields"), ensure_ascii=False))
            elif k == "capture":
                _store_capture(p)
            elif k == "buy_buff_result":   # City Buff "March Speed Increase" comprado con gemas (manual)
                logmsg(f"BUY_BUFF (gems) OK: item={p.get('item_id')}" if p.get("ok") else f"BUY_BUFF FAIL: {p.get('err')}")
            elif k == "chat_session":
                sid = int(p.get("session_id", 0) or 0)
                if sid and sid != int(STATE.get("chat_session_id", 0) or 0):
                    STATE["chat_session_id"] = sid; save_state()
                    logmsg(f"chat session_id aprendido: {sid} (los rallies del bot ya se comparten al chat de alianza)")
            elif k == "fire_result":
                logmsg(f"fire_result: {p.get('sent')}/{p.get('requested')} sent")
            elif k == "use_item_result":
                logmsg(f"use_item OK: id={p.get('item_id')} x{p.get('amount')}" if p.get("ok") else f"use_item FAIL: {p.get('err')}")
            elif k == "mail_claim_result":
                logmsg(f"MAIL: reward mails con recompensa = {p.get('found', 0)} (cobrando cada uno)")
                # guardar el resultado para que el botón "Get Stamina" pueda mostrarlo:
                # el cobro es ASÍNCRONO (se pide al agente y responde luego), así que la UI
                # dispara y después consulta este último resultado. 2026-08-11.
                _MAIL_LAST["ts"] = time.time(); _MAIL_LAST["found"] = int(p.get("found", 0) or 0)
            elif k == "use_truce_result":
                logmsg(f"AUTO-TRUCE applied OK: item id={p.get('item_id')} (bubble renewed)" if p.get("ok") else f"AUTO-TRUCE FAIL: {p.get('err')}")
            elif k == "join_result":
                logmsg(f"join_result: {p.get('sent')}/{p.get('requested')} rally(ies) de alianza")
            elif k == "rally_action":                        # resultado de BLITZ / CANCEL del rally (ambos SOLO manuales)
                _act = "BLITZ (gemas)" if p.get("action") == "blitz_rally" else "CANCEL rally"
                if p.get("ok"): logmsg(f"{_act} enviado OK: war_id={p.get('war_id')}" + (f" cost={p.get('cost')}" if p.get("cost") else ""))
                else: logmsg(f"{_act} FALLÓ: {p.get('err')}")
            elif k == "gen_states_result":
                logmsg(f"gen_states: {p.get('count')} generales, {p.get('nomarch')} en servicio (no marchables)")
            elif k == "wheel_credits":
                WHEEL["credits"] = int(p.get("credits", -1) or -1)   # balance de créditos del Wheel of Fortune
            elif k == "wheel_open":
                WHEEL["open"] = bool(p.get("open"))                   # ¿ventana de la rueda abierta? (requisito real del server)
                if p.get("vip") is not None: WHEEL["vip"] = int(p.get("vip"))   # General Blessing seleccionado (NO lo tocamos)
            elif k == "wheel_result":
                if not p.get("ok"): logmsg(f"WHEEL spin NOT sent: {p}")
            elif k == "heal_result":
                if p.get("ok"):
                    HEAL["last_msg"] = f"healing {p.get('count')} troops"; logmsg(f"heal_result OK: {p.get('count')} tropas en pos {p.get('pos')}")
                    hist("heal", count=int(p.get("count", 0) or 0), result="ok")
                else:
                    HEAL["last_msg"] = f"heal failed: {p.get('err')}"; logmsg(f"heal_result FAIL: {p.get('err')}")
                    hist("heal", count=0, result="fail", note=str(p.get("err", "")))
            elif k == "rally_card":
                # RALLY MONTHLY CARD — estado leído del juego (autoRallyData). active=auto-join ON.
                if p.get("ok"):
                    was = RALLY_CARD.get("active")
                    RALLY_CARD["supported"] = bool(p.get("supported"))
                    RALLY_CARD["active"] = bool(p.get("active"))
                    RALLY_CARD["expire"] = int(p.get("expire", 0) or 0)
                    RALLY_CARD["secs_left"] = int(p.get("secs_left", 0) or 0)
                    RALLY_CARD["owned"] = bool(p.get("owned"))                       # ¿posee la tarjeta? (membership 30031 presente)
                    RALLY_CARD["card_expire"] = int(p.get("card_expire", 0) or 0)    # epoch fin de la suscripción
                    RALLY_CARD["card_secs_left"] = int(p.get("card_secs_left", 0) or 0)
                    RALLY_CARD["ts"] = time.time()
                    if was is not None and was != RALLY_CARD["active"]:
                        logmsg(f"RALLY CARD auto-join {'ON' if RALLY_CARD['active'] else 'OFF'} ({RALLY_CARD['secs_left']//60}m restantes)" if RALLY_CARD["active"] else "RALLY CARD auto-join OFF")
                    # EXCLUYENTE: sólo en una TRANSICIÓN a ON (was is not True) -> evita pisar el AUTO recién activado (que envía card->off con ~1.4s de latencia).
                    # Cubre: usuario activa la tarjeta DENTRO del juego, o arranque con la tarjeta ya ON + AUTO persistido.
                    if RALLY_CARD["active"] and STATE.get("auto") and was is not True:
                        STATE["auto"] = False; STATE["steal"] = False; save_state()
                        logmsg("AUTO global OFF (la Rally Monthly Card pasó a ON; son excluyentes)")
            elif k == "rally_card_set_result":
                logmsg(f"RALLY CARD set on={p.get('on')} enviado OK" if p.get("ok") else f"RALLY CARD set FALLÓ: {p.get('err')}")
            elif k == "send_reply":
                # el server nos dice update_max_send_num = tope de tropas de esa marcha (por general+tier). Lo aprendemos
                # para CLAMPAR las marchas y que no las rechace (result=1). Clave = general_id (cada preset usa el suyo).
                gid = int(p.get("gid", 0) or 0); mx = int(p.get("max_send", 0) or 0)
                if gid and mx > 0:
                    ms = STATE.setdefault("max_send", {}); prev = ms.get(str(gid))
                    if prev != mx:
                        ms[str(gid)] = mx; STATE.setdefault("max_send_conf", {})[str(gid)] = True
                        adapted = sum(1 for pp in PRESETS if int(pp.get("general_id", 0) or 0) == gid and _clamp_preset_to_cap(pp))
                        save_state()
                        msg = (f"march rejected (over march limit) gen {gid}: max_send={mx:,}"
                               if int(p.get("result", 0) or 0) != 0 else f"max_send gen {gid} = {mx:,} (learned)")
                        if adapted: msg += f" -> adapted {adapted} preset(s) to the cap"
                        logmsg(msg)
                    if int(p.get("result", 0) or 0) != 0 and int(p.get("error_code", 0) or 0) == 7:
                        # RECHAZO POR TOPE de marcha (error_code 7): la marcha no salió por exceso de tropas -> limpia su
                        # pending para que se re-dispare clampado enseguida (sin esperar al timeout de 30s).
                        for mid in [m for m, pp in list(STEAL_PENDING.items()) if int(pp.get("gid", 0) or 0) == gid]:
                            STEAL_PENDING.pop(mid, None)
            elif k == "reports":
                # battle reports (Mail->Reports) -> resultado REAL de cada robo/farm del History (ganado/perdido + bajas)
                n = _match_reports(p.get("rows") or [])
                mm = _match_misses(p.get("misses") or [])      # 'Target Disappeared' (Mail->System) -> objetivo desapareció, stamina devuelta
                sw = _sweep_unconfirmed()
                if n or mm or sw:
                    _save_hist()
                    if n: logmsg(f"reports: {n} battle outcome(s) confirmed from Mail")
                    if mm: logmsg(f"reports: {mm} target-disappeared (stamina refunded) from Mail")
            elif k == "moncfg":
                rows = p.get("rows") or []
                MONCFG.clear(); MONCFG_NL.clear(); MONCFG_N.clear()
                for r in rows:
                    try:
                        mid = int(r[0]); nm = str(r[1]); lv = int(r[2]); pw = float(r[3])
                        if mid <= 0 or not nm: continue
                        MONCFG[mid] = {"name": nm, "level": lv, "power": pw}
                        nl = nm.strip().lower()
                        MONCFG_NL[(nl, lv)] = mid
                        MONCFG_N.setdefault(nl, mid)
                    except Exception: pass
                logmsg(f"moncfg: {len(MONCFG)} monster configs cargadas (resuelve id/nivel/power en steals)")
                # APP HÍBRIDA: el almacén del escáner reutiliza ESTAS configs para nombrar
                # lo que ve el barrido. Antes el módulo escáner hacía su propio volcado, lo
                # que duplicaba un trabajo pesado (1.920 entradas + localización) justo en el
                # arranque y contribuyó a tumbar el juego. Una sola fuente de verdad.
                scanner_store.ingest_config({str(k): v for k, v in MONCFG.items()})
            else:
                logmsg(f"agent: {p}")
        elif message.get("type") == "error":
            logmsg(f"agent ERROR: {message.get('description')}")
    except Exception as e:
        logmsg(f"on_agent_message err: {e}")

def _store_account(p):
    first = not ACCOUNT.get("city_x")
    with LOCK:
        ACCOUNT.clear(); ACCOUNT.update(p)
    # March Size boost: cuando NO hay buff activo (eta<=0), la lectura del bonus ES el baseline (sin item) -> recordarlo (persistente).
    # Y al expirar el buff, limpiar los generales marcados "boost falló" para reintentar en el próximo buff.
    try:
        _msb = (p.get("buffs") or {}).get("march_size") or {}
        if int(_msb.get("eta", 0) or 0) <= 0:
            _bo = float(_msb.get("bonus", 0) or 0)
            if _bo > 0 and abs(_bo - float(STATE.get("msize_bonus_base", 0) or 0)) > 1e-9:
                STATE["msize_bonus_base"] = _bo; save_state()
            if _BOOST_FAILED: _BOOST_FAILED.clear()
    except Exception:
        pass
    if first and p.get("city_x"):   # log the account summary ONLY on the first scan (not every ~15s)
        logmsg(f"account: city=({p.get('city_x')},{p.get('city_y')}) free_slots={p.get('free_slots')} "
               f"troops={len(p.get('troops',[]))} generals={p.get('generals')} maxMon={p.get('max_monster_level')}")
        # La cuenta YA está cargada -> AHORA sí se puede limitar el frame rate. Aplicarlo antes (al attachar) deja al
        # juego cargando a cámara lenta y NO llega a loguear: Unity carga los assets por frame (visto 2026-07-03).
        if not AGENT.get("fps_done") and AGENT.get("session"):
            AGENT["fps_done"] = True
            _apply_fps(AGENT["session"], STATE.get("target_fps", 5))

def _store_capture(p):
    label = PENDING_CAPTURE.get("label")
    if p.get("msg") == "send_troop":
        if label:
            sticky = bool(PENDING_CAPTURE.get("sticky"))
            with LOCK:
                TEMPLATES[label] = p
                if not sticky: PENDING_CAPTURE["label"] = None
            logmsg(f"✅ TEMPLATE '{label}' learned: type={p.get('march_type')} ttype={p.get('target_type')} troops={p.get('troops')}")
            if not sticky:
                post_agent({"type": "ctl", "cmd": "capture_off"})
            else:
                logmsg("   (STICKY: capture SIGUE ON para observar mensajes posteriores al rally-create, p.ej. el share)")
        else:
            logmsg(f"capture send_troop (no label armed): type={p.get('march_type')} ttype={p.get('target_type')} troops={p.get('troops')}")
    else:
        logmsg(f"🔎 MSG-DISCOVER up_msg.{p.get('msg')} scalars={json.dumps(p.get('scalars'), ensure_ascii=False)}")

# --- Throttle de frame rate (ahorro de CPU del emulador) ---------------------------------------
# El bot NO necesita que el juego renderice: lee el estado de MEMORIA (il2cpp) y manda protobuf por
# frida, sin pasar por el Update() de Unity. Medido 2026-07-03 en 6004: a los 30 fps por defecto el
# emulador escupía ~68 MB/s de comandos OpenGL al host (3 GB / 45s) PARA NADIE (headless) = ~74% CPU.
# Limitando a 5 fps -> ~30% CPU; a 1 fps -> ~23% y el stream de GL cae ÷30. OJO: si vSyncCount != 0
# Unity IGNORA targetFrameRate, así que hay que ponerlo a 0. No persiste en el juego (un reinicio de
# Evony lo devuelve a 30) -> se re-aplica tras cada attach.
FPS_JS = r"""
const lib = Process.getModuleByName("libil2cpp.so");
function ex(n) { const p = lib.findExportByName ? lib.findExportByName(n) : lib.getExportByName(n); if (!p) throw new Error("falta " + n); return p; }
const domain_get     = new NativeFunction(ex("il2cpp_domain_get"), 'pointer', []);
const thread_attach  = new NativeFunction(ex("il2cpp_thread_attach"), 'pointer', ['pointer']);
const asm_open       = new NativeFunction(ex("il2cpp_domain_assembly_open"), 'pointer', ['pointer', 'pointer']);
const asm_image      = new NativeFunction(ex("il2cpp_assembly_get_image"), 'pointer', ['pointer']);
const class_by_name  = new NativeFunction(ex("il2cpp_class_from_name"), 'pointer', ['pointer', 'pointer', 'pointer']);
const method_by_name = new NativeFunction(ex("il2cpp_class_get_method_from_name"), 'pointer', ['pointer', 'pointer', 'int']);
const dom = domain_get();
thread_attach(dom);   // imprescindible para llamar código gestionado desde el hilo de frida
function findClass(ns, name) {
  for (const a of ["UnityEngine.CoreModule", "UnityEngine"]) {
    try {
      const asm = asm_open(dom, Memory.allocUtf8String(a));
      if (asm.isNull()) continue;
      const k = class_by_name(asm_image(asm), Memory.allocUtf8String(ns), Memory.allocUtf8String(name));
      if (!k.isNull()) return k;
    } catch (e) {}
  }
  return null;
}
function m_of(k, n, argc) { const m = method_by_name(k, Memory.allocUtf8String(n), argc); return m.isNull() ? null : m; }
function getInt(m) { return new NativeFunction(m.readPointer(), 'int', ['pointer'])(m); }
function setInt(m, v) { new NativeFunction(m.readPointer(), 'void', ['int', 'pointer'])(v, m); }
const App = findClass("UnityEngine", "Application");
const QS  = findClass("UnityEngine", "QualitySettings");
const sVS = QS ? m_of(QS, "set_vSyncCount", 1) : null;
const gTF = App ? m_of(App, "get_targetFrameRate", 0) : null;
const sTF = App ? m_of(App, "set_targetFrameRate", 1) : null;
if (sVS) setInt(sVS, 0);
if (sTF) setInt(sTF, __FPS__);
send({fps: gTF ? getInt(gTF) : null});
"""

def _apply_fps(session, fps):
    """Limita los fps del juego (ver nota de FPS_JS). Best-effort: si falla, el bot funciona igual, solo gasta más CPU."""
    try:
        fps = int(fps or 0)
        if fps <= 0: return False
        got = {}
        s = session.create_script(FPS_JS.replace("__FPS__", str(fps)))
        s.on("message", lambda m, d: got.update(m.get("payload") or {}) if m.get("type") == "send" else None)
        s.load()
        try: s.unload()      # el valor queda puesto en el juego; el script ya no hace falta
        except Exception: pass
        logmsg(f"fps throttle: targetFrameRate={got.get('fps')} (menos render = menos calor y batería en el móvil)")
        return True
    except Exception as e:
        logmsg(f"fps throttle failed: {e}")
        return False

def attach_agent():
    """Asegura frida-server + Evony en el dispositivo (BOT_EMULATOR) y attacha agent_bot.js."""
    import frida, subprocess
    def sh(args, t=10):
        try: return subprocess.run(args, capture_output=True, timeout=t, text=True)
        except Exception: return None
    # ¿está vivo frida-server? Se comprueba SIN root: `ps` no necesita privilegios y ver el
    # proceso basta. Antes se miraba el LISTEN :27042 con `su -c netstat`, y como
    # ensure_agent() se llama en cada attach/reattach/watchdog, Magisk sacaba el diálogo
    # "Superuser Request" una y otra vez encima del juego. Ahora `su` sólo se usa cuando
    # de verdad hay que ARRANCAR frida-server, que es raro.
    r = sh(["adb", "-s", EMULATOR, "shell", "ps -A 2>/dev/null | grep frida-server"])
    if not (r and "frida-server" in (r.stdout or "")):
        logmsg(f"starting frida-server on {EMULATOR} (setsid)...")
        sh(["adb", "-s", EMULATOR, "shell", "su", "-c", "setsid /data/local/tmp/frida-server </dev/null >/dev/null 2>&1 &"])
        time.sleep(3)
    # Evony en PRIMER PLANO (no basta con que el proceso exista).
    # MÓVIL FÍSICO: cualquier app que robe el foco (un navegador, un diálogo del sistema) deja Evony
    # en segundo plano -> Unity PAUSA la app y el juego PIERDE su conexión TCP con el servidor. El
    # proceso sigue vivo y el SCAN sigue funcionando (lee memoria local), así que el bot PARECE sano
    # mientras las marchas ya no salen: es exactamente el síntoma "FIRE ✅ pero active_marches=0".
    # Verificado en vivo 2026-07-29 durante el login de Tailscale: con Opera delante, CERO conexiones
    # al servidor del juego; al traer Evony al frente, ESTABLISHED a 76.9.213.22:443 y .43:443.
    # Por eso se mira el FOCO (mCurrentFocus), no la mera presencia en el dump de actividades.
    top = sh(["adb", "-s", EMULATOR, "shell", "dumpsys window | grep mCurrentFocus"])
    if not (top and "com.topgamesinc.evony" in (top.stdout or "")):
        logmsg(f"Evony no está en primer plano en {EMULATOR} -> lanzando / trayendo al frente...")
        sh(["adb", "-s", EMULATOR, "shell", "am", "start", "-n",
            "com.topgamesinc.evony/com.topgamesinc.androidplugin.UnityActivity"])
        time.sleep(20)
    with open(AGENT_JS) as f: src = f.read()
    for attempt in range(6):
        try:
            dev = frida.get_device(EMULATOR, timeout=15)
            try: session = dev.attach("Evony")
            except frida.ProcessNotFoundError:
                logmsg("Evony not found, retrying launch...");
                sh(["adb", "-s", EMULATOR, "shell", "am", "start", "-n",
                    "com.topgamesinc.evony/com.topgamesinc.androidplugin.UnityActivity"]); time.sleep(15); continue
            script = session.create_script(src)
            script.on("message", on_agent_message)
            script.load()
            AGENT["script"] = script
            AGENT["session"] = session      # se guarda para poder re-aplicar los fps desde /api/config
            AGENT["fps_done"] = False       # el throttle NO se aplica aquí: el juego aún está cargando y a pocos fps
                                            # tardaría eternidades en loguear. Se aplica al primer scan (_store_account)
            logmsg(f"✅ agent attached to {EMULATOR}")
            AGENT["attached_at"] = time.time()   # watchdog: if the agent never reaches 'ready' after this, the scene didn't load -> escalate
            post_agent({"type": "ctl", "cmd": "set_server", "server": STATE["server_id"]})
            time.sleep(9); post_agent({"type": "ctl", "cmd": "scan"})   # leer cuenta tras cargar
            return True
        except Exception as e:
            logmsg(f"attach try {attempt+1} fail: {e}"); time.sleep(4)
    logmsg(f"❌ could not attach agent to {EMULATOR}")
    return False

def _restart_evony():
    """Reinicia SOLO el juego en el emulador (force-stop + relaunch). Lo usa el WATCHDOG cuando Evony se congela."""
    import subprocess
    def sh(args, t=12):
        try: return subprocess.run(args, capture_output=True, timeout=t, text=True)
        except Exception: return None
    logmsg("WATCHDOG: Evony congelado -> force-stop + relaunch del juego")
    sh(["adb", "-s", EMULATOR, "shell", "am", "force-stop", "com.topgamesinc.evony"])
    time.sleep(2)
    sh(["adb", "-s", EMULATOR, "shell", "am", "start", "-n",
        "com.topgamesinc.evony/com.topgamesinc.androidplugin.UnityActivity"])
    time.sleep(18)   # esperar la carga del juego antes de re-attach
    try: hist("watchdog", result="ok", note="Evony frozen -> auto-restarted (force-stop + relaunch)")
    except Exception: pass

def _cold_boot_age():
    """Seconds since the last watchdog-triggered cold boot (flag-file); huge if never. Anti-loop guard."""
    try: return time.time() - float(open("/tmp/bot_coldboot_%d.ts" % PORT).read().strip())
    except Exception: return 1e9

def _cold_boot():
    """COLD BOOT the emulator (resets the hung EGL context) when a HOT relaunch of Evony didn't start the render
    (see the 'Attaching... forever' failure mode). Launches full_restart.sh DETACHED so it survives the backend
    restarting itself (full_restart kills+relaunches it). Writes (a) a flag-file timestamp (cold-boot anti-loop)
    and (b) the EXTERNAL watchdog STAMP (dasea, COOLDOWN 180s) so it won't fire a concurrent start_bot.sh
    (two cold boots of the same emulator = disaster)."""
    import subprocess
    now = int(time.time())
    for pth in ("/tmp/bot_coldboot_%d.ts" % PORT, "/tmp/bot_watchdog_last_restart"):
        try:
            with open(pth, "w") as f: f.write(str(now))
        except Exception: pass
    logmsg("WATCHDOG: COLD BOOT the emulator (full_restart.sh, detached) -- hot relaunch didn't start the render")
    try: hist("watchdog", result="cold_boot", note="hot relaunch didn't recover Evony -> cold boot (EGL reset)")
    except Exception: pass
    try:
        subprocess.Popen(["bash", os.path.join(HERE, "full_restart.sh")], start_new_session=True,
                         stdout=open("/tmp/bot_coldboot_%d.log" % PORT, "a"), stderr=subprocess.STDOUT,
                         env={**os.environ, "BOT_BIND": BIND})
    except Exception as e:
        logmsg("cold boot Popen err: %s" % e)

def _watchdog_action(now, script, ready, hb, att, last_hot, cold_age):
    """Pure decision for the Evony watchdog (testable). Returns None | 'reattach' | 'hot' | 'cold'.
    'not operational' = attached but no recent heartbeat (freeze) OR attached >90s without ever reaching 'ready'
    (zombie: the Unity scene never loaded). Escalation: reattach -> hot relaunch -> cold boot (once a hot relaunch
    already happened and >90s later it is STILL not ready, the EGL context is hung; only a cold boot fixes it)."""
    if not script: return None
    stale = (now - hb) if hb else 0
    never_ready = bool(att and not ready and (now - att) > 90)
    # FREEZE = heartbeat caducado >30s (3 latidos perdidos). Margen holgado: un pico legítimo
    # de GC no deja al agente 3 latidos mudo. Bajado de 40->30 (2026-08-17) para recuperar antes.
    if not ((hb and stale > 30) or never_ready): return None
    hot_ago = now - last_hot
    # cold boot si un reinicio reciente del juego NO arrancó el render (contexto EGL colgado)
    if last_hot and hot_ago > 90 and not ready and cold_age > 900:
        return "cold"
    # FREEZE -> reinicio DIRECTO del juego (force-stop + relaunch), SIN re-attach previo: en un
    # proceso wedged el re-attach nunca funciona y perdía ~2,9 min en 6 timeouts. Cooldown 120s
    # (no re-disparar mientras el juego recarga). Total freeze->recuperado ~1-1,5 min (antes ~4).
    if hot_ago > 120:
        return "hot"
    return None   # dentro del cooldown -> el juego está recargando, esperar

DEADLOCK_SECS = 1800   # 30 min SIN ningún cambio en las marchas (a slots llenos) = sesión degradada con marchas ZOMBIE (timers a 00:00:00 que nunca resuelven). Umbral alto a propósito: ningún lote legítimo de marchas queda idéntico 30min (los solos/rally van completando y cambian el set) -> cero falsos positivos
def _march_deadlocked(now, ready, auto, free_slots, active_marches, stuck_since, last_restart):
    """Decisión pura (testable): True si el agente está SANO (ready) y AUTO on, pero las marchas llevan atascadas a
    SLOTS LLENOS (free_slots==0, active_marches>0) > DEADLOCK_SECS SIN progreso (el set de coords+conteo no cambia ->
    ni un fire ni un slot liberado) = las marchas quedaron zombie (00:00:00) y el ciclo auto hace `if active>=cap: return`
    para siempre. El watchdog de heartbeat NO lo pilla (el juego renderiza y el agente late). Anti-loop: no re-dispara
    antes de DEADLOCK_SECS desde el último restart de juego."""
    if not (ready and auto): return False
    if free_slots != 0 or (active_marches or 0) <= 0: return False
    if (now - stuck_since) <= DEADLOCK_SECS: return False
    if last_restart and (now - last_restart) <= DEADLOCK_SECS: return False
    return True

KICK_CLOSE_SECS = 60   # si el usuario lleva logueado desde OTRO dispositivo (connected==0) este tiempo, cerramos la instancia para ahorrar recursos
_SCANNER_EMUS = {"emulator-6000", "emulator-6002"}   # emuladores del ESCÁNER V3/V4 -> JAMÁS cerrarlos
def _stop_instance_kick(manual=False):
    """Cierra el juego/emulador cuando el usuario lleva KICK_CLOSE_SECS logueado desde OTRO dispositivo (kick sostenido).
    Emulador propio del bot (emulator-6004/6006) -> apaga el emulador (libera RAM/CPU del PC). Dispositivo físico (Pixel)
    -> force-stop de la app (libera batería). Deja el BACKEND vivo y marca stopped_kick para NO re-attachar (si no, el bot
    reabriría el juego y te volvería a kickear). Se recupera con 'Reload BOT Server' (full restart)."""
    import subprocess
    AGENT["stopped_kick"] = True
    try: open(os.path.join(os.path.dirname(os.path.abspath(__file__)), ".bot_stopped"), "w").write(str(int(time.time())))   # sentinel STOP: el watchdog NO reabre/revive mientras exista (lo borra "Reload BOT Server")
    except Exception: pass
    # DETACH LIMPIO de frida ANTES de cerrar: si el proceso/emulador desaparece con la sesión aún viva, frida-core
    # ABORTA y se lleva el backend por delante (visto: 'emu kill' mató el backend de lume). Con el detach previo,
    # frida suelta la sesión y el backend SOBREVIVE (que es todo el objetivo: seguir vivo para el "Reload").
    try:
        _sc = AGENT.get("script")
        if _sc:
            try: _sc.unload()
            except Exception: pass
    except Exception: pass
    try:
        _se = AGENT.get("session")
        if _se:
            try: _se.detach()
            except Exception: pass
    except Exception: pass
    AGENT["script"] = None; AGENT["ready"] = False; AGENT["session"] = None
    time.sleep(1)   # un instante para que frida suelte la sesión antes de matar el proceso
    _pfx = "STOP manual (UI)" if manual else f"KICK sostenido ({KICK_CLOSE_SECS}s, sesión en otro dispositivo)"
    try:
        if EMULATOR in _SCANNER_EMUS:
            logmsg(f"STOP: {EMULATOR} es del ESCÁNER -> NO se cierra (seguridad)"); return
        # APP HÍBRIDA: cerrar SÓLO el juego, NO matar el emulador. Antes se hacía 'emu kill'
        # y entonces Reload tenía que hacer un cold boot completo (emulador + frida con
        # diálogo de root de Magisk) -> lento y molesto. Cerrando sólo Evony, el emulador y
        # frida-server siguen vivos y 'Reload Bot' relanza el juego al instante, sin root.
        # El emulador queda caliente (RAM), aceptable en el banco de pruebas.
        subprocess.run(["adb", "-s", EMULATOR, "shell", "am", "force-stop", "com.topgamesinc.evony"], capture_output=True, timeout=10)
        logmsg(f"{_pfx} -> Evony cerrada en {EMULATOR} (emulador + frida siguen vivos). Backend sigue; pulsa 'Reload Bot' para volver.")
    except Exception as e:
        logmsg(f"_stop_instance_kick err: {e}")

def agent_thread():
    wd = {"last_game_restart": 0.0, "dl_sig": None, "dl_since": 0.0, "seen_conn": False}
    while True:
        now = time.time()
        # AUTO-CLOSE por KICK sostenido: usuario en OTRO dispositivo -> cerrar juego/emulador para no gastar
        # recursos (backend sigue -> "Reload BOT Server" para volver).
        # GATE "estuvo dentro" (2026-08-21): SOLO se arma el cierre si el juego llego a estar LOGUEADO
        # (connected==1) en esta sesion y LUEGO cayo a 0 durante KICK_CLOSE_SECS. Sin este gate, el
        # connected==0 de la VENTANA DE CARGA (tras un Reload/relaunch, _isLogin aun false) se confundia
        # con un kick y cerraba el juego a los ~3s, ANTES de completar el login -> nunca ganaba la sesion
        # (Evony = "last-in-wins") -> bucle. Con el gate: tras un Reload el juego carga, se loguea y gana;
        # solo un kick REAL (estuvo dentro -> 0 sostenido) cierra.
        _conn = AGENT.get("connected")
        if not AGENT.get("script"):
            wd["kick_since"] = 0.0; wd["seen_conn"] = False   # sin agente (cerrado/re-attachando): nada que evaluar
        elif _conn == 1:
            wd["seen_conn"] = True; wd["kick_since"] = 0.0     # LOGUEADO: a partir de aqui un 0 sostenido si es kick
        elif _conn == 0 and wd.get("seen_conn"):
            if not wd.get("kick_since"): wd["kick_since"] = now
            elif now - wd["kick_since"] >= KICK_CLOSE_SECS and not AGENT.get("stopped_kick"): _stop_instance_kick()
        else:
            wd["kick_since"] = 0.0                              # connected None (desconocido) o 0-pero-cargando -> NO es kick
        if AGENT.get("stopped_kick"):
            time.sleep(8); continue          # instancia cerrada a propósito (kick) -> ni re-attach ni watchdog hasta "Reload BOT Server"
        if not AGENT.get("script"):
            ok = False
            try: ok = attach_agent()
            except Exception as e: logmsg(f"agent_thread err: {e}")
            # PROCESO WEDGED: si attach_agent falla del todo (6 timeouts), el proceso del juego
            # está en deadlock de il2cpp/GC y frida no puede reinyectar NI con un attach fresco.
            # El re-attach nunca lo cura -> hay que reiniciar el JUEGO para tener un proceso nuevo.
            # Cooldown de 90s para no reiniciar en bucle mientras el juego vuelve a cargar.
            if not ok and (now - wd.get("last_game_restart", 0)) > 90:
                logmsg("WATCHDOG: re-attach imposible (proceso wedged / deadlock il2cpp) -> force-stop + relaunch del juego")
                wd["last_game_restart"] = now
                try: _restart_evony()
                except Exception as e: logmsg(f"restart_evony (wedged) err: {e}")
        # WATCHDOG de Evony. Escalada: re-attach frida -> relaunch de Evony EN CALIENTE (freeze de un juego ya
        # cargado) -> COLD BOOT del emulador si el relaunch en caliente NO arranca el render en ~90s (contexto EGL
        # colgado; ver 'MODO DE FALLO Attaching...' en las notas). 'No operativo' cubre el freeze (dejo de latir)
        # y el zombie (attached pero la escena nunca cargo -> nunca 'ready').
        now = time.time()
        act = _watchdog_action(now, AGENT.get("script"), AGENT.get("ready"), AGENT.get("last_hb") or 0,
                               AGENT.get("attached_at") or 0, wd["last_game_restart"], _cold_boot_age())
        if act == "cold":
            logmsg("WATCHDOG: el relaunch en caliente no arranco el render -> COLD BOOT del emulador")
            wd["last_game_restart"] = now
            try: _cold_boot()
            except Exception as e: logmsg(f"cold boot err: {e}")
            AGENT["script"] = None; AGENT["ready"] = False; AGENT["last_hb"] = now; AGENT["attached_at"] = now
        elif act == "hot":
            logmsg("WATCHDOG: Evony no operativo -> relaunch de Evony en caliente")
            wd["last_game_restart"] = now
            try: _restart_evony()
            except Exception as e: logmsg(f"restart_evony err: {e}")
            AGENT["script"] = None; AGENT["ready"] = False; AGENT["last_hb"] = now; AGENT["attached_at"] = now
        elif act == "reattach":
            logmsg("WATCHDOG: sin heartbeat / escena no cargada -> re-attach"); AGENT["script"] = None; AGENT["ready"] = False
        # WATCHDOG 2 — DEADLOCK de marchas zombie (agente sano, pero slots llenos sin progreso; el de heartbeat no lo pilla)
        try:
            _sig = ((ACCOUNT.get("active_marches") or 0),
                    frozenset((int(m.get("wx", 0)), int(m.get("wy", 0))) for m in (ACCOUNT.get("march_targets") or [])))
            if _sig != wd.get("dl_sig"): wd["dl_sig"] = _sig; wd["dl_since"] = now   # cambió el set/conteo de marchas -> HAY progreso (fire o slot liberado)
            if AGENT.get("connected") != 0 and _march_deadlocked(now, AGENT.get("ready"), STATE.get("auto"), ACCOUNT.get("free_slots"),
                                 ACCOUNT.get("active_marches"), wd["dl_since"], wd["last_game_restart"]):
                _st = int(now - wd["dl_since"])
                logmsg(f"WATCHDOG: DEADLOCK de marchas ({ACCOUNT.get('active_marches')} atascadas a slots llenos {_st}s sin progreso) -> relaunch de Evony")
                wd["last_game_restart"] = now
                try: hist("watchdog", result="deadlock", note=f"march deadlock: {ACCOUNT.get('active_marches')} marchas atascadas a slots llenos {_st}s sin progreso -> auto-restart del juego")
                except Exception: pass
                try: _restart_evony()
                except Exception as e: logmsg(f"restart_evony(deadlock) err: {e}")
                AGENT["script"] = None; AGENT["ready"] = False; AGENT["last_hb"] = now; AGENT["attached_at"] = now
                wd["dl_since"] = now
        except Exception as e:
            logmsg(f"deadlock-watch err: {e}")
        time.sleep(8)

# ---------------------------------------------------------------- lógica del bot
# Ataque SOLO a monstruo (aprendido de la captura): march_type=2, target_type=2. No requiere captura.
SOLO_DEFAULT = {"march_type": 2, "target_type": 2}
RALLY_DEFAULT = {"march_type": 19, "target_type": 2}   # rally de alianza (capturado 2026-06-14: __type=19)

_BOOST_FAILED = set()   # generales cuya marcha boosteada rechazó el server ESTE buff -> saltar boost (se limpia al expirar el buff)
def _msize_mult():
    """Multiplicador REAL del buff March Size = (1+bonus_actual)/(1+bonus_base). bonus = GetResProduceBuffer(26) (fracción del bonus
    TOTAL de March Size, leído por el agente); base = ese mismo bonus SIN el item (capturado cuando eta<=0). El % del item es ADITIVO al
    bonus permanente enorme, así que el aumento real es (1+now)/(1+base), no el nominal. 1.0 si boost OFF / sin buff / sin baseline."""
    if not STATE.get("msize_boost", True): return 1.0
    ms = (ACCOUNT.get("buffs") or {}).get("march_size") or {}
    if int(ms.get("eta", 0) or 0) <= 0: return 1.0
    base = STATE.get("msize_bonus_base"); now = float(ms.get("bonus", 0) or 0)
    if base is None or float(base) <= 0 or now <= 0: return 1.0
    m = (1.0 + now) / (1.0 + float(base))
    return m if m > 1.0 else 1.0

def _eff_troops(p):
    """Tropas del preset CLAMPADAS al tope de marcha del general (lo que realmente saldría). El server rechaza marchas que excedan
    update_max_send_num (aprendido en STATE['max_send'][gid]). 0.98 = margen. Con el buff March Size (boost ON) ESCALA las tropas por el
    multiplicador real y sube el tope proporcionalmente, topando cada tropa en las unidades que posees. Cubre solo/rally/steal."""
    troops = [{"t": int(t["t"]), "n": int(t["n"])} for t in (p.get("troops") or []) if int(t.get("n", 0) or 0) > 0]
    gid = int(p.get("general_id", 0) or 0); ms = (STATE.get("max_send") or {})
    cap = int(ms.get(str(gid), 0) or 0)
    mult = _msize_mult() if (p.get("mode") in ("solo", "rally", "steal") and gid not in _BOOST_FAILED) else 1.0
    if mult > 1.0:
        for t in troops:                                           # escala cada tropa, topando en lo que posees de ese tipo
            owned = int(_troop_meta(t["t"]).get("num", 0) or 0)
            nn = int(t["n"] * mult); t["n"] = min(nn, owned) if owned > 0 else nn
        if cap > 0: cap = int(cap * mult)                          # tope de marcha buffeado
    if cap <= 0:
        return troops   # march cap of THIS general is UNKNOWN -> PROBE: send what's configured (scaled if boosted). The auto-fire detects
                        # whether the server rejects the march (never appears in-flight) and LEARNS the real max_send, retrying with less.
    total = sum(t["n"] for t in troops); lim = int(cap * 0.98)
    if total > lim and total > 0:
        f = lim / total
        for t in troops: t["n"] = max(1, int(t["n"] * f))
    return troops

def _autojoin_troops():
    """AUTO-JOIN marches with a SINGLE T1 soldier — a token to join the alliance rally, NEVER the preset's real army.
    Pick the cheapest T1 the account owns; fall back to the lowest-tier/lowest-power troop it has."""
    owned = [t for t in (ACCOUNT.get("troops") or []) if int(t.get("num", 0) or 0) > 0]
    if not owned: return []
    owned.sort(key=lambda t: (int(t.get("tier", 9) or 9), float(t.get("power", 0) or 0)))
    return [{"t": int(owned[0].get("id", 0) or 0), "n": 1}]

def _preset_pow(p):
    """Poder de combate REAL de un preset = el de las tropas que DE VERDAD marcharían (clampadas al tope del general)."""
    return sum(t["n"] * (_troop_meta(t["t"]).get("power") or 0) for t in _eff_troops(p))

def _preset_cap(p):
    """Tope de tropas por marcha de ESTE preset (max_send aprendido del general). 0 = aún no conocido."""
    return int((STATE.get("max_send") or {}).get(str(int(p.get("general_id", 0) or 0)), 0) or 0)

OVERRIDE_MAX_MULT = 3     # tope del override = poder REAL del preset x3 (la "recomendación" que muestra la UI)
def _clamp_override_pow(p):
    """El override_pow no puede exceder la recomendación (poder real del preset x OVERRIDE_MAX_MULT). Se clampa también
    aquí, no solo en la UI, para que no se pueda colar por API ni quedar obsoleto al bajar las tropas del preset.
    Motivo: un override inflado hace que el bot apunte a monstruos que NO puede batir con sus propias tropas — caso real
    (lume, 30/07): override de 1B en un preset de 551.9M -> 22 rallies a Ymir con el ejército entero perdido."""
    try:
        if not p.get("override"): return
        cur = int(p.get("override_pow", 0) or 0)
        mx = int(round(_preset_pow(p) * OVERRIDE_MAX_MULT))
        if mx > 0 and cur > mx:
            p["override_pow"] = mx
            logmsg(f"override_pow recortado a la recomendación: {cur:,} -> {mx:,} (⚔×{OVERRIDE_MAX_MULT})")
    except Exception as e:
        logmsg(f"_clamp_override_pow err: {e}")

def _clamp_preset_to_cap(p):
    """Reduce las tropas GUARDADAS del preset a 0.98×max_send[general] (si se conoce) para que se ADAPTE al tope de la
    cuenta logueada (el server rechaza marchas que lo excedan). Reparte proporcionalmente. Devuelve True si cambió."""
    cap = _preset_cap(p)
    if cap <= 0: return False
    lines = [t for t in (p.get("troops") or []) if int(t.get("n", 0) or 0) > 0]
    total = sum(int(t["n"]) for t in lines); lim = int(cap * 0.98)
    if total <= lim or total <= 0: return False
    f = lim / total; changed = False
    for t in (p.get("troops") or []):
        n = int(t.get("n", 0) or 0)
        if n > 0:
            nn = max(1, int(n * f))
            if nn != n: t["n"] = nn; changed = True
    return changed

def _can_win(p, mpow):
    """Heurística: ¿el preset puede matar al monstruo? poder_efectivo >= poder_monstruo × win_ratio.
    mpow<=0 (poder desconocido) => no bloquea. OVERRIDE (AISLADO): si p['override'] y p['override_pow']>0, usa ESE poder
    efectivo (multiplicadores reales: bestias espirituales, dragones, buffs, refuerzos del rally de alianza…) en vez del
    poder crudo de las tropas -> permite fijar objetivos más fuertes de lo que indica el poder de marcha. NO cambia las
    tropas que se envían (el server resuelve el combate con tus multiplicadores). OFF = idéntico al comportamiento previo."""
    mpow = float(mpow or 0)
    if mpow <= 0: return True
    pw = _preset_pow(p)
    if p.get("override") and int(p.get("override_pow", 0) or 0) > 0:
        pw = int(p.get("override_pow"))
    return pw >= mpow * float(STATE.get("win_ratio", 0.8) or 0.8)

def _can_reach(p, t):
    """¿ESTE preset (su general) llega a tiempo? our_eta con la velocidad REAL del general vs cuándo muere el monstruo."""
    sp = _spt_for(int(p.get("general_id", 0) or 0))
    oe = round(int(t.get("dist", 0) or 0) * sp)
    tk = int(t.get("their_kill", 0) or 0)
    return oe > 0 and tk > oe + 3

def _preset_availability():
    """Por preset habilitado: ¿hay tropas IDLE suficientes para lanzarlo? Las tropas en el Hospital (heridas),
    marchando o ya usadas por otro preset NO están en idle. Reparte greedy por orden de preset; marca 'short'
    (no lanzable ahora) los que no se completan y que NO están ya marchando. -> {idx: {ok, marching, short:[{id,name,need,have}]}}"""
    out = {}
    troops = ACCOUNT.get("troops") or []
    if not troops: return out
    idle = {int(t.get("id", 0) or 0): int(t.get("num", 0) or 0) for t in troops}   # tropas disponibles ahora, por tipo
    busy_gens = {int(m.get("gid", 0) or 0) for m in ACCOUNT.get("march_targets", []) if int(m.get("gid", 0) or 0) > 0}
    for i, p in enumerate(PRESETS):
        if not (p.get("enabled") and p.get("troops")): continue
        g = int(p.get("general_id", 0) or 0)
        if g and g in busy_gens:                       # ya marchando -> sus tropas están fuera (no idle) -> OK, no avisar
            out[i] = {"ok": True, "marching": True, "short": []}
            continue
        short = []
        for line in p.get("troops", []):
            t = int(line.get("t", 0) or 0); need = int(line.get("n", 0) or 0)
            if t <= 0 or need <= 0: continue
            have = idle.get(t, 0)
            if have < need:
                short.append({"id": t, "name": (_troop_meta(t).get("name") or f"type {t}"), "need": need, "have": have})
            idle[t] = max(0, have - need)               # reserva lo disponible para los siguientes presets
        out[i] = {"ok": (not short), "marching": False, "short": short}
    return out

def _make_target(p, m):
    """Construye el dict de una marcha desde un preset (p) + un monstruo (m)."""
    mode = p.get("mode", "solo")
    tpl = TEMPLATES.get(mode) or (SOLO_DEFAULT if mode == "solo" else RALLY_DEFAULT)
    raw = [{"t": int(t["t"]), "n": int(t["n"])} for t in p.get("troops", []) if int(t.get("n", 0) or 0) > 0]
    if not raw: return None
    # CLAMP al tope de marcha del general (aprendido de update_max_send_num): si el preset pide más tropas de las que
    # admite la marcha, el server la RECHAZA (result=1) y no sale nada. _eff_troops reduce proporcionalmente para que quepa.
    troops = _eff_troops(p)
    if not troops: return None
    gid0 = int(p.get("general_id", 0) or 0)
    cap = int((STATE.get("max_send") or {}).get(str(gid0), 0) or 0)
    clamped = sum(t["n"] for t in troops) < sum(t["n"] for t in raw)
    tpow = sum(tt["n"] * (_troop_meta(tt["t"]).get("power") or 0) for tt in troops)
    mpow = float(m.get("power", 0) or 0)
    wr = float(STATE.get("win_ratio", 0.8) or 0.8)
    win = (mpow <= 0) or (tpow >= mpow * wr)       # ¿basta el poder del preset para ganar? (win_ratio, calibrable)
    return {
        "wx": m["x"], "wy": m["y"], "name": m["name"], "level": int(m.get("level", 0) or 0), "mode": mode,
        "dist": dist_from_city(m["x"], m["y"]), "mpower": m.get("power", 0),
        "troops": troops, "win": win,
        "troops_h": [{"tier": _troop_meta(tt["t"]).get("tier", 0), "n": tt["n"]} for tt in troops],
        "tpow": round(tpow), "tunits": sum(tt["n"] for tt in troops),
        "march_type": (tpl or {}).get("march_type", 2), "target_type": (tpl or {}).get("target_type", 2),
        "subtype": int(m.get("id", 0) or 0), "tactics_id": 1,
        "rally_time": int(STATE.get("rally_time", 300)) if mode == "rally" else 0,
        "target_user_id": int(m.get("id", 0) or 0) if mode == "rally" else 0,
        "target_troop_id": 0, "general_id": int(p.get("general_id", 0) or 0),
        "assistant": int(p.get("assistant_id", 0) or 0), "gen_atk": _gen_atk(p.get("general_id", 0)),
        "gen_name": _gen_name(p.get("general_id", 0)),
        "server_id": STATE["server_id"], "has_tpl": bool(tpl), "clamped": clamped, "send_cap": cap,
    }

FIRE_KEYS = ("wx", "wy", "march_type", "target_type", "subtype", "level", "tactics_id",
             "target_troop_id", "troops", "general_id", "assistant", "server_id",
             "rally_time", "target_user_id")

def _lane_hold(t):
    """Segundos que una lane queda OCUPADA tras disparar antes de poder re-disparar ese preset (y, vía `taken` + AUTO_FIRED/AUTO_HOLD,
    antes de que CUALQUIER otro preset re-dispare esa MISMA coord -> anti-DUPLICADO CROSS-PRESET, la causa real de las marchas dobles).
    RALLY: reunión (rally_time, con SUELO de 300s por si el preset viene con rally_time=0) + VIAJE al target (dist × seg-por-tile del
    general) + margen -> cubre TODO el trayecto hasta que el rally MATA al monstruo. Los rally NO aparecen en in_flight, así que durante
    el viaje el ÚNICO guard es este hold; debe ser INMUNE a los huecos de cobertura de V3 (la periferia se re-barre cada ~7-10min: si el
    monstruo cae del scan un rato NO se debe liberar la lane, porque hasta que pase este tiempo el rally NO puede haberlo matado).
    SOLO: 30s (aparece rápido en in_flight; a partir de ahí lo cubre el scan)."""
    t = t or {}
    if t.get("mode") != "rally":
        return 30
    rt = int(t.get("rally_time", 0) or 0)
    dist = float(t.get("dist", 0) or 0)
    spt = _spt_for(int(t.get("general_id", 0) or 0)) or 1.5
    return int(max(rt, 300) + dist * spt + 90)   # margen reducido 240->90 (2026-07-26, prueba): re-disparo rally más rápido. El lock por-coord (AUTO_HOLD ~20-25min) + 'general ocupado' (busy_gens) ya evitan duplicados sin necesitar un margen grande

def _coord_cd(t):
    """Segundos que una COORDENADA queda bloqueada para TODOS los presets tras dispararla (vida de AUTO_FIRED/AUTO_HOLD).
    Va MÁS ALLÁ de la ventana de la marcha (_lane_hold): añade una supresión de RE-HIT para NO volver a mandar a la MISMA
    coord justo después de que se libere -> cubre el caso real que quedaba: un boss CONTESTADO (nuestra marcha llega y ya no
    está = 'gone') o un respawn que reaparece en V3 a los pocos minutos, y OTRO preset lo re-dispara (los 'casi siempre se
    repiten' del historial: mismo Sphinx/Phoenix con generales distintos). Se DESACOPLA del hold de la lane (que sigue = ventana,
    para que el preset quede libre y farmee OTROS targets): aquí solo bloqueamos ESA coord un poco más. RALLY: ventana + 720s,
    suelo 20min. SOLO: cooldown estándar (los solo roamean a coords distintas, no repiten la misma)."""
    t = t or {}
    if t.get("mode") == "rally":
        return max(_lane_hold(t) + 720, 1200)
    return max(_lane_hold(t), int(STATE.get("cooldown", 360) or 360))

# Grupos (taxonomía de V3, visible en la columna "Grupo") que se atacan con RALLY de alianza;
# el resto van con ataque SOLO. Basta con elegir el modo en el PRESET: el bot enruta cada preset
# a los monstruos de su tipo. Ajustable si algún grupo debe cambiar de bando.
RALLY_GROUPS = {"Boss", "Event"}

def _is_rally_target(m):
    return (m.get("group") or "") in RALLY_GROUPS

def _selected_keys():
    return {k for k, v in SELECTION.items() if v.get("on")}

def _preset_march_view(now):
    """Per-preset march status (EXACT, like the in-game slots). Maps each active march to its preset by
    general (march.__general_id == preset.general_id) and uses the real server end_time (te, Unix epoch)
    for the remaining seconds. Phase = returning if heading to our city, else outbound. dur = current leg
    total (te-ts) for the progress bar. Returns {idx: {phase, remaining, dur, x, y}}."""
    cx, cy = _city()
    by_gen, by_war = {}, {}
    for m in ACCOUNT.get("march_targets", []):
        g = int(m.get("gid", 0) or 0)
        if g > 0 and g not in by_gen: by_gen[g] = m
        w = int(m.get("war_id", 0) or 0)
        if w > 0 and w not in by_war: by_war[w] = m
    def _entry(m):
        te = int(m.get("te", 0) or 0)
        rem = max(0, te - int(now)) if te else int(m.get("dur", 0) or 0)
        wx, wy = int(m.get("wx", 0) or 0), int(m.get("wy", 0) or 0)
        atcity = bool(cx and cy and wx == cx and wy == cy)
        mty = int(m.get("mtype", 0) or 0)
        if mty in (19, 20) and atcity: phase = "gathering"          # rally REUNIÉNDOSE (target = tu ciudad)
        else: phase = "returning" if atcity else "outbound"
        return {"phase": phase, "remaining": rem, "dur": int(m.get("dur", 0) or 0), "x": wx, "y": wy}

    out, used = {}, set()
    for i, p in enumerate(PRESETS):
        if not p.get("enabled"): continue                           # preset APAGADO -> nunca muestra marcha (antes colgaba una marcha ajena en un preset rally/off)
        g = int(p.get("general_id", 0) or 0)
        m = by_gen.get(g) if g else None                            # SOLO/RALLY/autojoin: el juego rellena __general_id -> mapeo exacto (el token de autojoin trae su general real)
        if not m and p.get("mode") == "autojoin" and g:
            # Rally UNIDO con __general_id=0 (algunas fases): 2º intento por el war_id que registró ESTE general.
            # _JOIN_WAR se indexa por GENERAL (estable), no por índice: reordenar presets ya no descoloca la barra.
            jw = _JOIN_WAR.get(g)
            if jw:
                _c = by_war.get(jw["war_id"])
                if _c is not None and id(_c) not in used: m = _c     # y nunca robar una marcha ya asignada a otro preset por general
        if not m: continue
        used.add(id(m)); e = _entry(m)
        bz = BLITZ_AT.get(i)                                        # BLITZ recién lanzado: el rally YA va en camino aunque
        if bz:                                                      # el juego siga publicando el registro viejo ('gathering')
            _age = now - float(bz.get("ts", 0))
            if _age > BLITZ_BRIDGE or e.get("phase") != "gathering":
                BLITZ_AT.pop(i, None)                               # llegó el dato real (o caducó) -> manda el dato real
            else:
                _eta = int(bz.get("eta", 0) or 0)
                e["phase"] = "outbound"; e["blitz"] = True
                if _eta > 0: e["dur"] = _eta; e["remaining"] = max(0, int(_eta - _age))
        lane = AUTO_LANES.get(i)                              # live lane: target name/level/coords (in gathering/returning the march points at the city, not the monster)
        disp = None if lane else (STATE.get("lane_disp") or {}).get(str(i))   # persisted target context: used only after a backend restart, when AUTO_LANES (memory) is empty
        if p.get("mode") == "autojoin":                       # FIX: un preset en AUTO-JOIN no farmea un monstruo propio; su
            lane = None; disp = None                          # marcha es un refuerzo a un rally ajeno. Heredar el lane_disp de
            e.pop("name", None); e.pop("level", None)         # cuando el preset era solo/rally hacía que la barra dijese cosas
            jw = _JOIN_WAR.get(g)                             # como "Golden Goblin L7" en un autojoin. Se etiqueta con el rally.
            if jw:
                # contexto GUARDADO al unirse: disponible SIEMPRE, aunque el rally ya no esté en WarListList
                if jw.get("tx"): e["tx"] = int(jw["tx"]); e["ty"] = int(jw.get("ty", 0) or 0)
                if jw.get("name"): e["name"] = jw["name"]
                if int(jw.get("level", 0) or 0) > 0: e["level"] = int(jw["level"])
                if int(jw.get("power", 0) or 0) > 0: e["power"] = int(jw["power"])
                if jw.get("leader"): e["leader"] = jw["leader"]
                if jw.get("tag"): e["tag"] = jw["tag"]
                for _w in (ACCOUNT.get("guild_wars") or []):   # si el rally SIGUE vivo, refrescar con lo que diga el juego
                    if int(_w.get("war_id", 0) or 0) == int(jw.get("war_id", 0) or 0):
                        e["tx"] = int(_w.get("tx", 0) or 0); e["ty"] = int(_w.get("ty", 0) or 0)
                        _m = _MONS_XY.get((e["tx"], e["ty"])) or _MONS_XY_ALL.get((e["tx"], e["ty"]))   # respaldo del escáner
                        if _m: e["name"] = _m.get("name"); e["level"] = int(_m.get("level", 0) or 0)
                        if int(_w.get("mlevel", 0) or 0) > 0: e["level"] = int(_w.get("mlevel"))
                        if int(_w.get("mpower", 0) or 0) > 0: e["power"] = int(_w.get("mpower"))
                        _c = _mon_by_lvpow(e.get("level"), e.get("power"))                              # nombre por (nivel,poder)
                        if _c and _c.get("name"): e["name"] = _c["name"]
                        if _w.get("lname"): e["leader"] = str(_w.get("lname"))      # quién CONVOCA el rally
                        if _w.get("ltag"): e["tag"] = str(_w.get("ltag"))           # tag corto de su alianza
                        break
            if not e.get("name") and int(e.get("tx", 0) or 0):     # RESPALDO: aún sin nombre pero con coords -> resolver por el escáner
                _mm = _MONS_XY.get((int(e["tx"]), int(e["ty"]))) or _MONS_XY_ALL.get((int(e["tx"]), int(e["ty"])))
                if _mm:
                    e["name"] = _mm.get("name"); e["level"] = int(_mm.get("level", 0) or 0)
                    if _mm.get("power") and not e.get("power"): e["power"] = int(_mm.get("power") or 0)
                if not e.get("name"):                              # y si el rally traía nivel/poder, por MONCFG
                    _cc = _mon_by_lvpow(e.get("level"), e.get("power"))
                    if _cc and _cc.get("name"): e["name"] = _cc["name"]
            # FASE de un AUTO-JOIN: hay DOS momentos distintos y el destino de la marcha los distingue.
            #   destino = ciudad del LÍDER  -> el token viaja/espera a que el rally se forme      -> Gathering
            #   destino = el MONSTRUO       -> el rally YA SALIÓ y el ejército va al objetivo     -> Marching
            #   destino = MI ciudad         -> vuelta a casa (lo resuelve _entry)                 -> Returning
            # Antes se forzaba Gathering para CUALQUIER marcha de ida y seguía diciendo Gathering
            # con el rally ya en camino (visto en vivo: mtype=21, destino == objetivo, quedando 7s).
            if e.get("phase") == "outbound":
                _dst = (int(e.get("x", 0) or 0), int(e.get("y", 0) or 0))
                _tg = (int(e.get("tx", 0) or 0), int(e.get("ty", 0) or 0))
                if not (_tg[0] and _dst == _tg): e["phase"] = "gathering"
        if disp and e.get("phase") == "outbound" and (int(disp.get("tx", -1)), int(disp.get("ty", -1))) != (e["x"], e["y"]):
            disp = None                                      # an outbound march heading elsewhere than the remembered target is a different march (e.g. manual) -> don't mislabel it
        if lane and lane.get("target"):                      # target coords from the live lane
            e["tx"] = int(lane["target"][0]); e["ty"] = int(lane["target"][1])
        elif disp and disp.get("tx") is not None:            # from the persisted context (returning/gathering after a restart)
            e["tx"] = int(disp["tx"]); e["ty"] = int(disp["ty"])
        elif e.get("phase") == "outbound":                   # last resort: an outbound march still points AT the target tile
            e["tx"] = e["x"]; e["ty"] = e["y"]
        if lane and lane.get("name"):
            e["name"] = lane["name"]; e["level"] = int(lane.get("level", 0) or 0)
        elif disp and disp.get("name"):                      # name/level from the persisted context (returning after a restart)
            e["name"] = disp["name"]; e["level"] = int(disp.get("level", 0) or 0)
        elif e.get("phase") == "outbound":                   # no context: match the monster coords with the last scan
            _mm = _MONS_XY.get((int(m.get("wx", 0) or 0), int(m.get("wy", 0) or 0)))
            if _mm: e["name"] = _mm.get("name"); e["level"] = int(_mm.get("level", 0) or 0)
        if lane and lane.get("steal"):                       # steal: also surface who we stole the monster from (attacker + alliance)
            e["steal"] = True; e["atk_name"] = lane.get("atk_name"); e["atk_tag"] = lane.get("atk_tag")
        elif disp and disp.get("steal"):                     # steal context after a restart
            e["steal"] = True; e["atk_name"] = disp.get("atk_name"); e["atk_tag"] = disp.get("atk_tag")
        out[str(i)] = e

    # FALLBACK (solo para la BARRA, no para decidir nada): quedan marchas de rally que el juego NO etiqueta de ninguna
    # forma utilizable (gid=0 y __union_war_id=0: visto en mtype=19/20 durante la reunión). Se reparten EN ORDEN entre
    # los presets autojoin que se quedaron sin marcha, para que al menos se vea el progreso. Es una heurística: con
    # varios autojoin a la vez, la barra puede no corresponder al preset exacto, pero el conjunto sí es real.
    RALLY_MTY = (10, 19, 20, 21)
    orphans = [m for m in ACCOUNT.get("march_targets", [])
               if id(m) not in used and int(m.get("mtype", 0) or 0) in RALLY_MTY]
    free_aj = [i for i, p in enumerate(PRESETS)          # sin exigir troops: el autojoin va con token 1×T1 (ver _auto_join_cycle)
               if p.get("enabled") and p.get("mode") == "autojoin" and str(i) not in out]
    for i, m in zip(free_aj, orphans):
        e = _entry(m)
        if e.get("phase") == "outbound":                     # name the autojoin march by its monster coords too
            e["tx"] = e["x"]; e["ty"] = e["y"]               # autojoin outbound: the tile it points at IS the target
            _mm = _MONS_XY.get((int(m.get("wx", 0) or 0), int(m.get("wy", 0) or 0)))
            if _mm: e["name"] = _mm.get("name"); e["level"] = int(_mm.get("level", 0) or 0)
        out[str(i)] = e
    return out

def _elig_sort_key(m, p, want_rally):
    """Prioridad de un monstruo candidato para ESTE preset (tupla menor = se ataca ANTES):
      1) ORDEN DE TIPO (drag&drop de los pills): índice de 'name|level' en p['targets'] -> el 1º de la lista tiene
         prioridad ABSOLUTA sobre el resto, y así sucesivamente. Preset sin targets asignados ('all default') ->
         índice 0 para todos (sin prioridad de tipo -> se comporta como antes).
      2) preferencia grupo->modo (rally->Boss/Event) — desempate suave, como antes.
      3) p['dist_prio'] ON (por defecto): el más CERCANO primero. OFF: el más PODEROSO primero (sin usar distancia).
    Nota: los monstruos del mismo 'name|level' tienen el mismo poder, así que dentro de un tipo el desempate real es la
    distancia (ON) — idéntico al comportamiento previo para presets de un solo tipo (p.ej. Golden Goblin)."""
    tgts = p.get("targets") or []
    key = f"{m['name']}|{m['level']}"
    try: tprio = tgts.index(key)
    except ValueError: tprio = len(tgts)
    grp = 0 if (_is_rally_target(m) == want_rally) else 1
    if p.get("dist_prio", True):
        return (tprio, grp, dist_from_city(m["x"], m["y"]) or 1e9)
    return (tprio, grp, -int(m.get("power", 0) or 0))

def _preset_queue(idx, n=5):
    """INFORMATIVO ('View Queue'): los próximos ~n monstruos que ESTE preset (solo/rally) atacaría, en su MISMA prioridad
    (_elig_sort_key) y con sus MISMOS filtros (tipo marcado, can_win, no atacado por otros, no wiped). Usa el set FRESCO
    cacheado del escáner (_mons_fresh, TTL 25s) -> barato aunque se pulse seguido; NO se calcula en cada poll."""
    try:
        if idx < 0 or idx >= len(PRESETS): return []
        p = PRESETS[idx]
        if p.get("mode") not in ("solo", "rally") or not p.get("enabled"): return []
        selkeys = _selected_keys()
        if not selkeys: return []
        keys = (set(p.get("targets") or []) & selkeys) or selkeys
        _mons_fresh()
        atkc = _attacked_coords(); wiped = _wiped_keys()
        want_rally = (p.get("mode", "solo") == "rally")
        elig = sorted([m for m in _MONS_XY.values()
                       if int(m.get("id", 0) or 0) > 0
                       and f"{m['name']}|{m['level']}" in keys
                       and (m["x"], m["y"]) not in atkc
                       and f"{p.get('mode','solo')}|{m['name']}|{int(m.get('level',0) or 0)}" not in wiped
                       and _can_win(p, m.get("power", 0))],
                      key=lambda m: _elig_sort_key(m, p, want_rally))
        _lt = {tuple(l["target"]): i for i, l in AUTO_LANES.items() if l.get("target")}   # (x,y) -> preset con lane activa
        def _blk(x, y):
            xy = (x, y)
            if xy in _lt: return "in progress (P%d)" % (_lt[xy] + 1)
            if xy in KILL_PENDING: return "killed · awaiting respawn"
            if xy in AUTO_FIRED: return "recently hit (cooldown)"
            return None
        return [{"name": m.get("name"), "level": int(m.get("level", 0) or 0),
                 "dist": int(round(dist_from_city(m["x"], m["y"]))), "power": int(m.get("power", 0) or 0),
                 "x": int(m["x"]), "y": int(m["y"]), "blocked": _blk(int(m["x"]), int(m["y"]))}
                for m in elig[:n]]
    except Exception as e:
        logmsg(f"_preset_queue err: {e}"); return []

def build_targets():
    """Manda los presets ACTIVOS a los monstruos SELECCIONADOS, por orden de distancia mínima:
    preset 1 -> monstruo más cercano, preset 2 -> 2º más cercano, etc. 1 marcha por slot."""
    if not ACCOUNT.get("troops"): return [], "account not read yet (waiting for agent scan)"
    selkeys = _selected_keys()
    if not selkeys: return [], "no monsters selected (mark at least one target)"
    _bg = {int(m.get("gid", 0) or 0) for m in ACCOUNT.get("march_targets", []) if int(m.get("gid", 0) or 0)} | set(_JOIN_GEN.keys()) | set(_JOIN_WAR.keys())
    queue = [p for p in PRESETS if p.get("enabled") and p.get("troops") and p.get("mode") not in ("steal", "autojoin")
             and int(p.get("general_id", 0) or 0) not in _bg]   # V3: fuera los presets cuyo general YA marcha
    queue.sort(key=lambda p: 0 if (set(p.get("targets") or []) & selkeys) else 1)   # presets with the monster ASSIGNED (target pill) pick before "all (default)" presets
    if not queue: return [], "no active farm presets (solo/rally) with troops — steal-mode presets are used by Rally Steals"
    mons = [m for m in fetch_monsters() if int(m.get("id", 0) or 0) > 0]
    targets = []; taken = set()
    lane_targets = {tuple(l["target"]): i for i, l in AUTO_LANES.items() if l.get("target")}   # (x,y) -> preset con lane activa (V3)
    atkc_b = _attacked_coords()        # mismo criterio que el AUTO: fuera los que ya ataca otra alianza
    wiped_b = _wiped_keys()            # y fuera los que nos han aniquilado el ejército
    for i, p in enumerate(queue):
        want_rally = (p.get("mode", "solo") == "rally")            # rally->Boss/Event ; solo->resto (por grupo)
        keys = (set(p.get("targets") or []) & selkeys) or selkeys   # per-preset target: only the assigned+selected types, else all selected (default)
        elig = sorted([m for m in mons if f"{m['name']}|{m['level']}" in keys
                       and (m["x"], m["y"]) not in taken
                       and (m["x"], m["y"]) not in atkc_b            # no farmear lo que sale en Rally Steals
                       and f"{p.get('mode','solo')}|{m['name']}|{int(m.get('level',0) or 0)}" not in wiped_b
                       and _can_win(p, m.get("power", 0))],          # no enviar a batalla perdida
                      key=lambda m: _elig_sort_key(m, p, want_rally))
        if not elig: continue
        m = elig[0]; taken.add((m["x"], m["y"]))
        t = _make_target(p, m)
        if not t: continue
        t["preset"] = i + 1
        _xy = (int(m["x"]), int(m["y"]))                 # V3: motivo por el que el AUTO no dispararía (para Preview + no disparar)
        _busy = lane_targets.get(_xy)
        if _busy is not None: t["blocked"] = "in progress (P%d)" % (_busy + 1)
        elif _xy in KILL_PENDING: t["blocked"] = "killed · awaiting respawn"
        elif _xy in AUTO_FIRED: t["blocked"] = "recently hit (cooldown)"
        targets.append(t)
    if not targets:
        return [], "no marked monster is winnable with the active presets (raise Win ratio, mark a beatable target, or check troop power)"
    return targets, None

def _plan_view(fire):
    return [{"name": t["name"], "level": t["level"], "mode": t["mode"], "x": t["wx"], "y": t["wy"],
             "dist": round(t["dist"]), "mpower": round(t["mpower"]), "tpow": t["tpow"],
             "tunits": t["tunits"], "troops_h": t["troops_h"], "has_tpl": t["has_tpl"],
             "march_type": t.get("march_type"), "rally_time": t.get("rally_time"),
             "subtype": t.get("subtype"), "target_user_id": t.get("target_user_id"),
             "general_id": t.get("general_id"), "gen_atk": t.get("gen_atk"), "gen_name": t.get("gen_name")} for t in fire]

def apply_and_fire(dry=False, only_xy=None):
    """dry=True -> solo el plan (Preview). only_xy=(x,y) -> DISPARO INDIVIDUAL de ese objetivo
    (botón Attack por fila del Preview): permite decidir monstruo a monstruo en vez de lanzar el plan
    entero. Se identifica por COORDENADAS, no por índice de fila: el plan se recalcula en cada llamada
    (los monstruos se mueven o desaparecen), así que un índice podría apuntar ya a OTRO monstruo."""
    targets, err = build_targets()
    if err: return {"ok": False, "error": err}
    free = int(ACCOUNT.get("free_slots", STATE["max_slots"]) or 0)
    n = min(len(targets), free, int(STATE["max_slots"]))
    fire = targets[:n]
    plan = _plan_view(fire)
    if dry:
        return {"ok": True, "dry": True, "free_slots": free, "candidates": len(targets), "plan": plan}
    if only_xy is not None:
        tx, ty = int(only_xy[0]), int(only_xy[1])
        if free <= 0:
            return {"ok": False, "plan": plan, "free_slots": free, "error": "no free march slots"}
        sel = [t for t in targets if int(t["wx"]) == tx and int(t["wy"]) == ty and not t.get("blocked")]
        if not sel:
            return {"ok": False, "plan": plan, "free_slots": free,
                    "error": f"({tx},{ty}) is no longer a candidate (or already in progress) — press Preview again"}
        fire = sel[:1]
    else:
        fire = [t for t in fire if not t.get("blocked")]   # V3: Attack general -> fuera los ya en curso / muertos / en cooldown
    if not fire:
        return {"ok": False, "error": f"0 targets (free slots={free}, candidates={len(targets)})", "plan": plan}
    missing = sorted({t["mode"] for t in fire if not t["has_tpl"]})
    if missing:
        return {"ok": False, "plan": plan, "free_slots": free,
                "error": f"missing preset template(s): {', '.join(missing)} — press Capture {missing[0].upper()} and do 1 manual attack"}
    payload = [{k: t[k] for k in FIRE_KEYS} for t in fire]
    ok = post_agent({"type": "ctl", "cmd": "fire", "targets": payload})
    with LOCK:
        now = time.time()
        for t in fire:
            FIRES.append({"t": int(now), "name": t["name"], "level": t["level"], "mode": t["mode"],
                          "x": t["wx"], "y": t["wy"], "dist": round(t["dist"]), "tunits": t["tunits"]})
            pi = int(t.get("preset", 0)) - 1   # registra la lane: el AUTO no re-disparará este preset hasta que vuelva
            if pi >= 0:
                AUTO_LANES[pi] = {"target": (int(t["wx"]), int(t["wy"])), "ts": now, "seen": 0, "dur": 0, "hold": _lane_hold(t), "name": t.get("name"), "level": int(t.get("level", 0) or 0)}
                STATE["lane_disp"][str(pi)] = {"name": t.get("name"), "level": int(t.get("level", 0) or 0), "tx": int(t["wx"]), "ty": int(t["wy"])}; save_state()   # persist target context (survives restarts)
                AUTO_FIRED[(int(t["wx"]), int(t["wy"]))] = now; AUTO_HOLD[(int(t["wx"]), int(t["wy"]))] = _coord_cd(t)
        if len(FIRES) > 200: del FIRES[:100]
    logmsg(f"APPLY: sent {len(fire)} (free={free}, of {len(targets)} candidates)")
    return {"ok": ok, "fired": len(fire), "candidates": len(targets), "plan": plan}

BLITZ_AT = {}             # preset_idx -> {"ts","eta","war_id"}: blitz recien lanzado. Puente para la barra de progreso
                          # mientras el juego actualiza su lista LOCAL de marchas (tarda unos segundos en pasar el rally
                          # de 'reuniendo' a 'en camino'). Se descarta solo: en cuanto el dato REAL deja de decir
                          # gathering manda el dato real, y en cualquier caso caduca a los BLITZ_BRIDGE segundos.
BLITZ_BRIDGE = 25
def _preset_lane_target(idx):
    """(tx,ty) del objetivo de la lane de ese preset, de memoria o del contexto persistido. None si no hay."""
    try: idx = int(idx)
    except Exception: return None
    lane = AUTO_LANES.get(idx) or {}
    tgt = lane.get("target")
    if tgt: return (int(tgt[0]), int(tgt[1]))
    disp = (STATE.get("lane_disp") or {}).get(str(idx)) or {}
    if disp.get("tx") is not None: return (int(disp.get("tx", 0) or 0), int(disp.get("ty", 0) or 0))
    return None

def _rally_eta(idx):
    """Segundos de VIAJE estimados del rally de ese preset: distancia desde mi ciudad x seg-por-tile de SU general
    (el mismo cálculo que usa el bot al disparar). Solo se usa como puente visual hasta que llega el dur real."""
    try:
        tgt = _preset_lane_target(idx)
        if not tgt: return 0
        p = PRESETS[int(idx)] if 0 <= int(idx) < len(PRESETS) else {}
        spt = _spt_for(int(p.get("general_id", 0) or 0)) or 1.5
        return int(round((dist_from_city(tgt[0], tgt[1]) or 0) * spt))
    except Exception:
        return 0

def _my_rally_war_id(idx):
    """war_id (mass_id) del rally que ESTE preset tiene en curso. El rally propio NO trae el war_id en la marcha
    (union_war_id=0), así que se usa el MISMO emparejamiento que el share: la entrada de guild_wars liderada por MI
    ciudad (lwx,lwy) cuyo objetivo (tx,ty) coincide con el target de la lane del preset. Devuelve (war_id, error)."""
    try: idx = int(idx)
    except Exception: return 0, "preset inválido"
    tgt = _preset_lane_target(idx)
    if not tgt: return 0, "ese preset no tiene ningún rally en curso"
    cx = int(ACCOUNT.get("city_x", 0) or 0); cy = int(ACCOUNT.get("city_y", 0) or 0)
    if not (cx and cy): return 0, "aún no se ha leído la ciudad (espera al scan)"
    for w in (ACCOUNT.get("guild_wars") or []):
        try:
            if int(w.get("lwx", 0) or 0) != cx or int(w.get("lwy", 0) or 0) != cy: continue   # solo los rallies que LIDERO yo
            if (int(w.get("tx", 0) or 0), int(w.get("ty", 0) or 0)) != (int(tgt[0]), int(tgt[1])): continue
            wid = int(w.get("war_id", 0) or 0)
            if wid > 0: return wid, ""
        except Exception: continue
    return 0, f"no encuentro tu rally a {tgt[0]},{tgt[1]} en la lista de la alianza (¿ya marchó, o aún no aparece?)"

def _share_cycle():
    """Comparte al chat de alianza los rallies que el bot ha lanzado (comportamiento por defecto de Evony con el ajuste ON).
    Empareja cada rally lanzado (por general_id) con su marcha (que trae war_id = mass_id) y manda 'share_rally' al agente,
    que construye el send_coord_message y llama a ChatDataManager.SendCoordMessage. Requiere el session_id (lo aprende el
    hook del agente con cualquier share manual; persiste mientras el juego siga logueado)."""
    try:
        if not STATE.get("share_rally", True) or not PENDING_SHARES: return
        # El mass_id NO está en la marcha (union_war_id=0 para el rally propio); vive en guild_wars (WarListList):
        # la entrada liderada por MI ciudad (lwx,lwy) con el objetivo del rally (tx,ty) trae war_id = mass_id.
        gw = ACCOUNT.get("guild_wars", []) or []
        cx = int(ACCOUNT.get("city_x", 0) or (STATE.get("bot_city") or {}).get("x", 0) or 0)
        cy = int(ACCOUNT.get("city_y", 0) or (STATE.get("bot_city") or {}).get("y", 0) or 0)
        mass_by_tgt = {}
        for w in gw:
            try:
                if int(w.get("lwx", 0) or 0) == cx and int(w.get("lwy", 0) or 0) == cy:
                    mid = int(w.get("war_id", 0) or 0)
                    if mid > 0: mass_by_tgt[(int(w.get("tx", 0) or 0), int(w.get("ty", 0) or 0))] = mid
            except Exception: pass
        sid = int(STATE.get("chat_session_id", 0) or 0)
        now = time.time()
        for gid, info in list(PENDING_SHARES.items()):
            if now - float(info.get("ts", 0)) > 600:              # limpiar entradas viejas (compartidas o caducadas sin mass_id)
                PENDING_SHARES.pop(gid, None); continue
            if info.get("shared"): continue
            wid = mass_by_tgt.get((int(info["tx"]), int(info["ty"])), 0)
            if wid <= 0: continue                                 # aún no aparece en guild_wars con mass_id
            if not sid: continue                                  # sin session_id todavía -> esperar (1 share manual lo siembra vía el hook)
            des = (f"Lv{info['level']} {info['name']}" if info.get("name") else "")
            post_agent({"type": "ctl", "cmd": "share_rally", "wx": int(info["tx"]), "wy": int(info["ty"]), "mass_id": wid,
                        "des": des, "format": str(info.get("cid", "") or ""), "server_id": int(STATE.get("server_id", 0) or 0),
                        "session_id": sid, "client_id": str(uuid.uuid4()), "channel": 1, "level": int(info.get("level", 0) or 0)})
            info["shared"] = True
            logmsg(f"share_rally -> mass_id={wid} ({info['tx']},{info['ty']}) '{des}'")
    except Exception as e:
        logmsg(f"_share_cycle err: {e}")

KILL_PENDING = {}          # V3 cuarentena: (x,y) -> ts_retorno; tiles rally ya matados, bloqueados hasta confirmar muerte/respawn
SOLO_SEEN_GRACE = 45       # V3: margen (s) tras ver una marcha SOLO en in_flight antes de dar la lane por vuelta
NET_DEGRADED_SECS = 60     # V3-B: >Ns con 0 marchas reales mías tras disparar rallies -> sesión de red caída -> liberar lanes fantasma
NET_RETRY_COOLDOWN = 45    # V3-B: pausa del auto-fire tras detectar red degradada
_NET_PAUSE_UNTIL = [0.0]   # V3-B: hasta cuándo pausa el auto-fire (lista de 1 para mutar sin 'global')

def _rally_report_result(tile, t_ref):
    """Resultado del BATTLE REPORT (mail) del rally más reciente a ese tile. Devuelve 'won'/'lost'/'gone'/'done'
    si el report ya llegó y _match_reports lo confirmó, o None si aún sin confirmar. CLAVE de la cuarentena V3:
    el report se genera en el COMBATE (antes de que el general vuelva a casa), así que al RETORNO ya sabemos si el
    monstruo murió SIN esperar al re-barrido del escáner (lento). 'lost' = sobrevivió -> re-atacable ya."""
    tx, ty = int(tile[0]), int(tile[1])
    for ev in reversed(HISTORY):
        if ev.get("kind") != "farm": continue
        if int(ev.get("tx", 0) or 0) != tx or int(ev.get("ty", 0) or 0) != ty: continue
        if float(ev.get("ts", 0) or 0) < t_ref - 1200: break   # más viejo que este rally (>20min antes del retorno)
        return ev.get("result") if ev.get("confirmed") else None
    return None

# Grupos (taxonomía de V3, visible en la columna "Grupo") que se atacan con RALLY de alianza;
# el resto van con ataque SOLO. Basta con elegir el modo en el PRESET: el bot enruta cada preset
# a los monstruos de su tipo. Ajustable si algún grupo debe cambiar de bando.
RALLY_GROUPS = {"Boss", "Event"}

def _auto_fire_cycle():
    """Auto-farm POR PRESET: cada preset activo es una 'lane' con 1 marcha en vuelo. Un preset SOLO
    se re-envía cuando SU marcha ha vuelto = su objetivo ya no está en las marchas activas (march_targets).
    Así NUNCA se envían dos marchas idénticas del mismo preset a la vez."""
    if not (STATE.get("auto") and ACCOUNT.get("troops")): return
    selkeys = _selected_keys()
    queue = [(i, p) for i, p in enumerate(PRESETS) if p.get("enabled") and p.get("troops") and p.get("mode") not in ("steal", "autojoin")]
    queue.sort(key=lambda ip: 0 if (set(ip[1].get("targets") or []) & selkeys) else 1)   # presets with the monster ASSIGNED (target pill) fire before "all (default)" presets
    if not selkeys or not queue: return
    mt = ACCOUNT.get("march_targets", [])
    in_flight = {(int(m.get("wx", 0)), int(m.get("wy", 0))): int(m.get("dur", 0) or 0) for m in mt}
    active = int(ACCOUNT.get("active_marches", 0) or 0)
    now = time.time()
    for k in [k for k, ts in list(AUTO_FIRED.items())
              if k not in KILL_PENDING and now - ts > max(int(STATE["cooldown"]), int(AUTO_HOLD.get(k, 0) or 0))]:
        AUTO_FIRED.pop(k, None); AUTO_HOLD.pop(k, None)   # el lock por-coord dura lo que la marcha REAL (rally largo) -> ningún otro preset re-dispara a mitad de viaje. Los que están en CUARENTENA (KILL_PENDING) NO caducan por tiempo aquí -> se liberan al confirmar la muerte (justo abajo)
    # V3 CUARENTENA: targets rally cuyo general YA volvió a casa. Se mantienen bloqueados (AUTO_FIRED) HASTA que
    # el monstruo caiga de _MONS_XY (frescos del escáner ≤RALLY_SEEN_MAX = muerte/desaparición confirmada) o al
    # tope RALLY_MAX_HOLD. Al confirmarse se liberan -> se puede re-farmear el respawn. 0 duplicados sobre ese tile.
    if KILL_PENDING:
        _mons_fresh()   # asegura _MONS_XY fresco (caché 25s) sin machacar el escáner
        for k, t_ret in list(KILL_PENDING.items()):
            _res = _rally_report_result(k, t_ret)   # BATTLE REPORT del mail: won/lost/gone/done o None (sin report aún)
            _m = _MONS_XY.get(k)
            # ¿el escáner RE-VIÓ el tile DESPUÉS del retorno? -> lo que muestra es el estado REAL (respawn/vivo), no el fantasma del que acabamos de matar
            _seen_post = (_m is not None) and (now - int(_m.get("seen_age", 0) or 0)) > t_ret
            if _res == "lost":                     # el mail dice que SOBREVIVIÓ -> re-atacable YA (wiped_keys ya bloquea si nos aniquiló)
                _release = True
            elif _res in ("won", "gone", "done"):  # el mail confirma la MUERTE -> re-atacable solo si aparece un RESPAWN real (visto tras el retorno), nunca el fantasma
                _release = _seen_post
            else:                                  # sin report todavía -> mismo criterio anti-fantasma (avistamiento posterior al retorno)
                _release = _seen_post
            if _release or (now - t_ret) > RALLY_MAX_HOLD:
                KILL_PENDING.pop(k, None); AUTO_FIRED.pop(k, None); AUTO_HOLD.pop(k, None)
    active_idx = {i for i, p in enumerate(PRESETS) if p.get("enabled") and p.get("troops")}   # TODOS los presets activos (no solo los de este ciclo) -> no borrar las lanes de otros modos
    for k in [k for k in list(AUTO_LANES) if k not in active_idx]:
        AUTO_LANES.pop(k, None)
    # CAP global: si la cuenta ya tiene >= marchas que presets activos, el cupo está lleno -> no disparar.
    # (cubre re-arranque del backend con rallies en vuelo: lanes vacías pero el juego ya reporta las marchas)
    cap_n = int(STATE.get("max_slots", 6) or 6)   # nunca más marchas que presets ni que slots
    if active >= cap_n: return
    GRACE = 25  # fallback; cada lane usa su propio 'hold' (el rally cubre su reunión)
    mons = None; atkc = None; wiped = None; fired = []
    busy_gens = {int(m.get("gid", 0) or 0) for m in mt if int(m.get("gid", 0) or 0) > 0}   # generales YA en una marcha (gid real del agente)
    busy_gens |= set(_JOIN_GEN.keys()) | set(_JOIN_WAR.keys())   # + comprometidos a un TOKEN de autojoin: el rally reporta gid=0 en la REUNIÓN, así que sin esto un general recién unido a un rally se re-desplegaba en un SOLO -> 2 marchas del mismo general
    # B — RED DEGRADADA (FIRE ✅ pero no salen): tras reiniciar el juego la sesión de red tarda en estar lista; el bot dispara pero
    # los rallies NO llegan al server -> lanes fantasma que bloquean el auto ~5min. Si hay lanes pero CERO marchas reales MÍAS
    # (0 generales marchando + 0 marchas + ningún rally liderado por mi ciudad en guild_wars) pasado NET_DEGRADED_SECS desde el
    # último disparo -> los rallies no salieron -> liberar las lanes fantasma YA + pausar. Seguro: solo con 0 marchas reales, no duplica nada.
    if AUTO_LANES:
        _cx = int(ACCOUNT.get("city_x", 0) or 0); _cy = int(ACCOUNT.get("city_y", 0) or 0)
        _my_war = any(int(w.get("lwx", 0) or 0) == _cx and int(w.get("lwy", 0) or 0) == _cy for w in (ACCOUNT.get("guild_wars") or [])) if (_cx and _cy) else False
        _newest = max((l.get("ts", 0) for l in AUTO_LANES.values()), default=0)
        if (not busy_gens) and (not mt) and (not _my_war) and _newest and (now - _newest) > NET_DEGRADED_SECS:
            logmsg(f"AUTO: {len(AUTO_LANES)} lane(s) SIN ninguna marcha real tras {int(now - _newest)}s -> sesión de red degradada (reinicio) -> libero las lanes fantasma y pauso {NET_RETRY_COOLDOWN}s")
            AUTO_LANES.clear(); _NET_PAUSE_UNTIL[0] = now + NET_RETRY_COOLDOWN
            return
    if now < _NET_PAUSE_UNTIL[0]: return
    for idx, p in queue:
        lane = AUTO_LANES.get(idx)
        g = int(p.get("general_id", 0) or 0)
        if lane:
            tgt = lane["target"]
            # V3 "tropas volvieron" (RALLY): si el general MARCHÓ de verdad (apareció en busy_gens con su gid real,
            # ya fuera de la reunión donde reporta gid=0) y ahora YA NO está -> la marcha volvió a casa = el rally
            # TERMINÓ. Liberamos el PRESET (coge otro target este mismo ciclo); el TARGET queda en CUARENTENA hasta
            # confirmar su muerte -> 0 tiempo muerto del general, 0 duplicados sobre ese tile. La REUNIÓN sigue
            # cubierta por el 'hold' de abajo (marched aún False), y busy_gens+AUTO_FIRED impiden cualquier duplicado.
            if p.get("mode") == "rally" and g:
                if g in busy_gens:
                    lane["marched"] = True
                    if not lane.get("confirmed"):   # B: el rally SALIÓ (general marchando = el server aceptó sus tropas) -> confirmar cap >= sent, igual que en solo (línea in_flight). Así el general deja de estar en "learning" perpetuo (los rally no aparecen en in_flight)
                        lane["confirmed"] = True; _snt = int(lane.get("sent", 0) or 0)
                        if _snt > 0 and _msize_mult() > 1.0:   # rally BOOSTEADO (March Size) -> NO aprender (sería el cap buffeado, corrompería el base)
                            logmsg(f"AUTO(rally): gen {g} salió con {_snt} (March Size boost -> cap base protegido, no se aprende)")
                        elif _snt > 0:
                            _ms = STATE.setdefault("max_send", {}); STATE.setdefault("max_send_conf", {})[str(g)] = True
                            if _snt > int(_ms.get(str(g), 0) or 0): _ms[str(g)] = _snt
                            save_state(); logmsg(f"AUTO(rally): gen {g} rally salió con {_snt} -> max_send>={_snt} (confirmed)")
                elif lane.get("marched"):
                    KILL_PENDING[tgt] = now; AUTO_FIRED[tgt] = AUTO_FIRED.get(tgt, now)
                    AUTO_LANES.pop(idx, None)
                    logmsg(f"AUTO: preset {idx+1} gen {g} volvió -> LIBRE; target {tgt} en cuarentena (confirmar muerte)")
                    continue
            if tgt in in_flight:
                if not lane.get("confirmed"):   # the march WENT OUT (server accepted it) -> this general carries >= sent (LEARN the real cap, without the hook that crashed)
                    lane["confirmed"] = True; _snt = int(lane.get("sent", 0) or 0)
                    if g and _snt > 0 and _msize_mult() > 1.0:   # marcha BOOSTEADA (March Size) -> NO aprender el cap (sería el buffeado, corrompería el base)
                        logmsg(f"AUTO: gen {g} marched {_snt} troops OK (March Size boost -> cap base protegido, no se aprende)")
                    elif g and _snt > 0:
                        _ms = STATE.setdefault("max_send", {}); STATE.setdefault("max_send_conf", {})[str(g)] = True
                        if _snt > int(_ms.get(str(g), 0) or 0): _ms[str(g)] = _snt
                        save_state(); logmsg(f"AUTO: gen {g} marched {_snt} troops OK -> max_send>={_snt} (confirmed)")
                lane["seen"] = now; lane["dur"] = in_flight.get(tgt) or lane.get("dur", 0); continue   # marcha en vuelo a ese tile -> ocupado
            if lane.get("seen") and now - lane["seen"] < SOLO_SEEN_GRACE: continue                        # la marcha se VIO en vuelo y hace <GRACE que no aparece -> aún volviendo. V3: margen fijo pequeño (antes dur+25 = casi una pierna entera); si aún volara reaparece en in_flight y se re-ocupa
            if now - lane.get("ts", 0) < lane.get("hold", 30): continue                                                 # dentro del HOLD (SOLO ~30s / RALLY cubre la REUNIÓN ~7min + lag de scan) -> ocupado. VA ANTES de la detección de rechazo: si no, un rally en reunión (que aún NO aparece en in_flight) se marcaba FALSAMENTE como rechazado a los 25s -> se popeaba la lane -> re-disparo -> DUPLICADOS (61% del historial)
            if (not lane.get("confirmed")) and (not lane.get("seen")) and int(lane.get("sent", 0) or 0) > 0:            # PASADO el hold y la marcha NUNCA apareció en in_flight
                if mons is None: mons = fetch_monsters()
                if not any((int(mm.get("x", 0)), int(mm.get("y", 0))) == tgt for mm in mons):
                    AUTO_LANES.pop(idx, None); continue                # el monstruo YA NO está -> nuestra marcha lo MATÓ (o desapareció) -> liberar la lane (permite re-farmear el respawn)
                if p.get("mode") == "rally":
                    # CLAVE anti-DUPLICADO rally: los RALLY NUNCA aparecen en in_flight (el scan solo ve marchas SOLO), así que NO se pueden "confirmar" por ahí. Si el monstruo SIGUE vivo, el rally puede estar EN CURSO (reuniendo/marchando) -> mantener la lane OCUPADA (bloquea el re-disparo propio Y el de otros presets vía `taken`). Safety anti-zombie: soltar tras RALLY_MAX_HOLD.
                    # C: distinguir RECHAZADO de EN CURSO via guild_wars. PASADO el hold (>=5min) un rally legítimo YA está registrado como guerra liderada por MI ciudad (_my_rally_war_id); si NO aparece -> el server lo RECHAZÓ (tropas > cap) -> aprender el cap y reintentar con menos (igual que solo). Evita el bloqueo de 30min por un rally que nunca salió.
                    _wid, _ = _my_rally_war_id(idx)
                    if _wid or not g:                                   # rally registrado en guild_wars (en curso) -> mantener ocupado; sin general no hay cap que aprender
                        if now - lane.get("ts", 0) < RALLY_MAX_HOLD: continue
                        AUTO_LANES.pop(idx, None); continue
                    _snt = int(lane.get("sent", 0) or 0)               # rally NO registrado pasado el hold, con general -> RECHAZADO
                    if _snt > 0 and _msize_mult() > 1.0:               # rally boosteado rechazado -> NO tocar el cap base; boost OFF este general este buff
                        _BOOST_FAILED.add(g); AUTO_LANES.pop(idx, None)
                        logmsg(f"AUTO(rally): gen {g} rechazó rally boosteado (March Size) -> boost OFF este general este buff; cap base intacto")
                        continue
                    _ms = STATE.setdefault("max_send", {}); _mc = STATE.setdefault("max_send_conf", {}); _pv = int(_ms.get(str(g), 0) or 0)
                    if _mc.get(str(g)) and _snt <= _pv:                # cap confirmado y envié <= -> falso positivo, no bajar
                        AUTO_LANES.pop(idx, None); continue
                    if _snt > 0:
                        _nc = max(200000, int(_snt * 0.85)); _ms[str(g)] = min(_pv, _nc) if _pv > 0 else _nc; _mc.pop(str(g), None); save_state()
                        logmsg(f"AUTO(rally): gen {g} rechazó rally de {_snt} (no aparece en guild_wars) -> learned max_send={_ms[str(g)]}, reintentar con menos")
                    AUTO_LANES.pop(idx, None); continue
                # SOLO: monstruo presente + nunca visto en in_flight tras el hold -> el server la RECHAZÓ (tropas > cap) -> aprende el cap y reintenta con menos
                _snt = int(lane["sent"]); _ms = STATE.setdefault("max_send", {}); _mc = STATE.setdefault("max_send_conf", {}); _pv = int(_ms.get(str(g), 0) or 0)
                if g and _msize_mult() > 1.0:                          # marcha SOLO boosteada rechazada -> NO tocar el cap base; boost OFF ese general este buff
                    _BOOST_FAILED.add(g); AUTO_LANES.pop(idx, None)
                    logmsg(f"AUTO: gen {g} rechazó marcha boosteada (March Size) -> boost OFF para este general este buff; cap base intacto")
                    continue
                if _mc.get(str(g)) and _snt <= _pv:
                    AUTO_LANES.pop(idx, None); continue                # cap confirmado y envié <= -> falso positivo, no bajar
                _nc = max(200000, int(_snt * 0.85)); _ms[str(g)] = min(_pv, _nc) if _pv > 0 else _nc; _mc.pop(str(g), None); save_state()
                logmsg(f"AUTO: gen {g} rejected {_snt} troops (march did not go out, monster present) -> learned max_send={_ms[str(g)]}, retrying with less")
                AUTO_LANES.pop(idx, None); continue
        if g and g in busy_gens: continue                          # su general ya está marchando (ida o vuelta) -> ocupado, NO duplicar
        if active + len(fired) >= cap_n: break                     # tope = min(presets, slots)
        if mons is None: mons = fetch_monsters()
        if atkc is None: atkc = _attacked_coords()               # monstruos con ataque enemigo en curso/formándose (lo que sale en Rally Steals)
        if wiped is None: wiped = _wiped_keys()                  # targets que ya nos han ANIQUILADO el ejército -> bloqueados temporalmente
        want_rally = (p.get("mode", "solo") == "rally")          # rally->Boss/Event ; solo->resto (por grupo)
        taken = {l["target"] for l in AUTO_LANES.values()}
        ptargets = set(p.get("targets") or []) & selkeys     # target por-preset: sólo los tipos asignados a ESTE preset que sigan seleccionados
        keys = ptargets if ptargets else selkeys             # sin asignación -> todos los seleccionados (comportamiento por defecto)
        elig = sorted([m for m in mons
                       if f"{m['name']}|{m['level']}" in keys and int(m.get("id", 0) or 0) > 0
                       and (m["x"], m["y"]) not in in_flight
                       and (m["x"], m["y"]) not in taken
                       and (m["x"], m["y"]) not in AUTO_FIRED
                       and (m["x"], m["y"]) not in atkc              # ya lo ataca otra alianza (sale en Rally Steals) -> NO mandar rally/solo tardío; para eso están los presets STEAL
                       and f"{p.get('mode','solo')}|{m['name']}|{int(m.get('level',0) or 0)}" not in wiped   # AUTO-PROTECCIÓN: este target ya aniquiló el ejército >= WIPE_STRIKES veces
                       and _can_win(p, m.get("power", 0))],          # no enviar a batalla perdida
                      key=lambda m: _elig_sort_key(m, p, want_rally))
        if not elig: continue
        m = elig[0]; t = _make_target(p, m)
        if not t or not t.get("has_tpl"): continue
        post_agent({"type": "ctl", "cmd": "fire", "targets": [{k: t[k] for k in FIRE_KEYS}]})
        hist("farm", mode=p.get("mode", "solo"), name=m.get("name"), level=int(m.get("level", 0) or 0),
             tx=m["x"], ty=m["y"], power=int(m.get("power", 0) or 0),
             troops=sum(int(x.get("n", 0) or 0) for x in (t.get("troops") or [])),
             general=_gen_name(int(p.get("general_id", 0) or 0)),
             our_eta=round((dist_from_city(m["x"], m["y"]) or 0) * _spt_for(int(p.get("general_id", 0) or 0))),  # ETA de llegada: permite matchear el mail 'Target Disappeared' con ESTA marcha (sin esto el GONE salia sin target/power/force)
             result="sent",
             note=("expected win" if t.get("win") else "win uncertain"))
        if p.get("mode") == "rally" and STATE.get("share_rally", True) and int(p.get("general_id", 0) or 0) > 0:
            PENDING_SHARES[int(p.get("general_id", 0) or 0)] = {"tx": m["x"], "ty": m["y"], "name": m.get("name"),
                "level": int(m.get("level", 0) or 0), "cid": int(m.get("id", 0) or 0), "ts": now, "shared": False}   # se compartirá al chat cuando su marcha traiga el mass_id (war_id)
        AUTO_LANES[idx] = {"target": (m["x"], m["y"]), "ts": now, "seen": 0, "dur": 0, "hold": _lane_hold(t), "name": m.get("name"), "level": int(m.get("level", 0) or 0), "gid": g, "sent": sum(int(x.get("n", 0) or 0) for x in (t.get("troops") or [])), "confirmed": False}
        STATE["lane_disp"][str(idx)] = {"name": m.get("name"), "level": int(m.get("level", 0) or 0), "tx": int(m["x"]), "ty": int(m["y"])}; save_state()   # persist target context (survives restarts)
        AUTO_FIRED[(m["x"], m["y"])] = now; AUTO_HOLD[(m["x"], m["y"])] = _coord_cd(t)   # bloquea esta coord para TODOS los presets: ventana de la marcha + supresión de re-hit (no re-disparar el mismo boss que reaparece)
        fired.append((idx, m))
        if g: busy_gens.add(g)   # MISMO-CICLO: este general ya marcha este ciclo -> no lo re-uses en otro preset (evita 2 marchas del mismo general)
        with LOCK:
            FIRES.append({"t": int(now), "name": m["name"], "level": int(m.get("level", 0) or 0),
                          "mode": p.get("mode", "solo"), "x": m["x"], "y": m["y"],
                          "dist": round(dist_from_city(m["x"], m["y"])), "tunits": t["tunits"], "auto": True})
            if len(FIRES) > 200: del FIRES[:100]
    if fired:
        logmsg("AUTO: +" + str(len(fired)) + " march(es): " +
               ", ".join(f"P{i+1}->{m['name']} @{m['x']},{m['y']}" for i, m in fired))

WIPE_FRAC = 0.9            # perder >= 90% de lo enviado = ANIQUILACIÓN (no son "bajas normales", que van al 0-1%)
WIPE_STRIKES = 2          # nº de aniquilaciones recientes del MISMO monstruo+nivel+modo para bloquearlo
WIPE_WINDOW = 6 * 3600    # ventana y a la vez CADUCIDAD del bloqueo: a las 6h se reintenta una vez.
                          # Es a propósito temporal: un bloqueo permanente nunca podría auto-sanarse (bloqueado =
                          # no hay resultados nuevos = sigue bloqueado para siempre), y las condiciones cambian
                          # (más tropas, buffs, la alianza empieza a reforzar los rallies...).
def _wiped_keys():
    """{'modo|nombre|nivel': (nº_aniquilaciones, ts_última)} de los targets que han ANIQUILADO el ejército.
    Protege de repetir el desastre: caso real (lume, 30/07) = 13 de 15 rallies a Ymir perdieron el ejército COMPLETO
    porque Ymir es un boss de ALIANZA (pide tropas de varios miembros) y con el override activo el bot creía poder solo.
    Ojo: se mira por MODO, así que un Ymir que aniquila en 'rally' no bloquea el mismo Ymir en 'steal' (donde sí gana)."""
    now = time.time(); by = {}
    for ev in HISTORY:
        if ev.get("kind") != "farm" or not ev.get("confirmed"): continue
        if ev.get("result") not in ("won", "lost"): continue
        nm = ev.get("name")
        if not nm: continue
        ts = float(ev.get("ts", 0) or 0)
        if now - ts > int(STATE.get("wipe_window_h", 6) or 6) * 3600: continue   # EXPERT
        if ts <= float(STATE.get("wipe_cleared_ts", 0) or 0): continue   # bloqueos reseteados a mano: ignora wipes previos al reset
        snt = int(ev.get("troops", 0) or 0); lost = int(ev.get("lost", 0) or 0)
        if snt <= 0: continue
        if lost < snt * float(STATE.get("wipe_frac", WIPE_FRAC) or WIPE_FRAC): continue   # EXPERT
        by.setdefault(f"{ev.get('mode', 'solo')}|{nm}|{int(ev.get('level', 0) or 0)}", []).append(ts)
    return {k: (len(v), max(v)) for k, v in by.items() if len(v) >= int(STATE.get("wipe_strikes", WIPE_STRIKES) or WIPE_STRIKES)}   # EXPERT

_ATKC = {"ts": 0.0, "set": frozenset()}
def _attacked_coords(ttl=8.0):
    """Coords (tx,ty) de los monstruos que aparecen en 'Rally Steals' = con un ataque de OTRA alianza en curso o
    formándose. El FARM (solo/rally) NO debe tocarlos: llegaríamos tarde a un monstruo que ya están matando, o nos
    meteríamos en la pelea de otro. Los presets en modo STEAL sí van a por ellos (es justo su función).
    Se reutiliza la MISMA lista que pinta la vista Rally Steals (mismos filtros de alianza/monstruo) para que no haya
    discrepancias, con caché de unos segundos porque el ciclo de farm corre a menudo."""
    now = time.time()
    if now - _ATKC["ts"] < ttl: return _ATKC["set"]
    try:
        tg, _err = _steal_targets()
        _ATKC["set"] = frozenset((int(t.get("tx", 0) or 0), int(t.get("ty", 0) or 0)) for t in (tg or [])
                                 if int(t.get("tx", 0) or 0) and int(t.get("ty", 0) or 0))
        _ATKC["ts"] = now
    except Exception as e:
        logmsg(f"_attacked_coords err: {e}")      # ante fallo se mantiene el último conjunto conocido (no bloquea el farm)
    return _ATKC["set"]

def _steal_targets():
    """Ataques de OTRAS alianzas sobre MONSTRUOS robables, con factibilidad (¿llegamos antes que su rally?)."""
    try:
        attacks, ally_tag = fetch_attacks()
    except Exception as e:
        return [], f"V3 /api/attacks: {e}"
    target_wl = {t.strip().lower() for t in (STATE.get("target_tags") or "").split(",") if t.strip()}
    own_uid = int(ACCOUNT.get("uid", 0) or 0)
    spt_cfg = float(STATE.get("steal_sec_per_tile", 1.5) or 1.5)
    _sgids = [int(p.get("general_id", 0) or 0) for p in PRESETS if p.get("enabled") and p.get("troops") and p.get("mode") == "steal"]
    spt_eff = min([_spt_for(g) for g in _sgids] or [spt_cfg])   # general de robo MÁS RÁPIDO (medido) -> our_eta best-case (¿llega ALGÚN preset?)
    cmargin = int(STATE.get("steal_combat_margin", 25) or 0)    # seg que el enemigo tarda en MATAR tras llegar (ventana extra de robo)
    excl_names ={e.strip().lower() for e in (STATE.get("steal_exclude") or "").split(",") if e.strip()} | set(STEAL_HARD_EXCLUDE)  # exclusión MANUAL (steal_exclude) + HARDCODE (Royal Thief etc.)
    wr = float(STATE.get("win_ratio", 0.8) or 0.8)
    max_pp = max([_preset_pow(p) for p in PRESETS if p.get("enabled") and p.get("troops") and p.get("mode") == "steal"] or [0])   # poder del preset STEAL más fuerte (robos solo usan presets en modo steal)
    buf = int(STATE.get("steal_enemy_buffer", 90) or 0)   # tiempo que el rally tarda en aterrizar TRAS reunir (fase wait)
    winm = int(STATE.get("steal_win_margin", 0) or 0)     # seg de ventaja EXIGIDA para robar (their_kill > our_eta + winm). 0 = robar todo empate a favor (agresivo)
    min_pow = float(STATE.get("steal_min_power", 0) or 0) * 1e6   # STEAL: poder mínimo del monstruo para robarlo (millones -> unidades; 0 = sin mínimo)
    MON_MTYS = {2, 19, 20, 21}                       # 2=monstruo solo, 19/20/21=fases de boss-rally
    MON_GROUPS = {"Boss", "Event", "Normal", "Shadow", "Other"}   # solo monstruos (excluye "Rally PvP" / castillos)
    # los ataques traen tg_id=0 (sobre todo en rallies): cruzar con /api/data por tile/nombre para el id de config
    try: mons = fetch_monsters()
    except Exception: mons = []
    by_coord = {(m["x"], m["y"]): m for m in mons if int(m.get("id", 0) or 0) > 0}
    by_name = {}; by_namelevel = {}
    for m in mons:
        if int(m.get("id", 0) or 0) > 0:
            nm0 = (m.get("name") or "").lower()
            by_name.setdefault(nm0, m)
            by_namelevel.setdefault((nm0, int(m.get("level", 0) or 0)), m)   # (nombre,nivel) -> id de config FIABLE
    # mapa id_config -> nivel: V3 trae level=0 en muchas filas (sobre todo bosses) por tile, pero el nivel real del
    # mismo id puede estar en OTRA fila -> rellenar con el mejor nivel visto (el id es único por nombre+nivel = fiable)
    id2lvl = {}
    for m in mons:
        lv = int(m.get("level", 0) or 0); mid = int(m.get("id", 0) or 0)
        if lv > 0 and mid > 0 and lv > id2lvl.get(mid, 0): id2lvl[mid] = lv
    nowt0 = time.time()
    recently_stolen = {k for k, ts in STEAL_SEEN.items() if nowt0 - ts < STEAL_SEEN_TTL}   # tiles que ya robamos -> el monstruo está muerto/muriendo -> NO re-robar (aunque V3 aún reporte el ataque)
    for _k in [_k for _k, _v in list(_ATK_ETA.items()) if nowt0 - float(_v.get("ts", 0)) > 1800]: _ATK_ETA.pop(_k, None)   # purga tracking de ETA enemigo (ataques viejos)
    out = []
    for a in attacks:
        if int(a.get("mty", 0)) not in MON_MTYS: continue
        if (a.get("tgroup") or "") not in MON_GROUPS: continue     # solo monstruos, no PvP/jugador
        tx, ty = int(a.get("tx", 0) or 0), int(a.get("ty", 0) or 0)
        if not tx or not ty: continue
        tag = (a.get("tag") or "").strip()
        if target_wl and tag.lower() not in target_wl: continue      # solo robar a las alianzas OBJETIVO (vacío = todas)
        if own_uid and int(a.get("uid", 0) or 0) == own_uid: continue  # nunca robar MIS propias marchas
        tg = int(a.get("tg_id", 0) or 0)
        lvl = int(a.get("tlevel", 0) or 0)
        mc = by_coord.get((tx, ty))                                  # match EXACTO por tile -> id+level FIABLES
        tlv = int(a.get("tlevel", 0) or 0)
        mnl = by_namelevel.get(((a.get("tname") or "").lower(), tlv)) if (not mc and tlv > 0) else None  # nombre+nivel del ataque -> id FIABLE (id=name+level)
        mm = mc or mnl or by_name.get((a.get("tname") or "").lower()) # último recurso: solo nombre -> nivel AMBIGUO
        match = "coord" if mc else ("namelevel" if mnl else ("name" if mm else ("attack" if tg > 0 else "none")))
        if mm:
            if int(mm.get("id", 0) or 0) > 0: tg = int(mm.get("id", 0))
            mlv = int(mm.get("level", 0) or 0)
            if mlv > 0: lvl = mlv                          # no pisar el tlevel del ataque con un 0
        if lvl <= 0 and tg > 0: lvl = id2lvl.get(tg, 0)    # nivel ausente (típico en bosses): rellenar por id de config (fiable)
        # FALLBACK con la config ESTÁTICA del juego (MONCFG): resuelve "no id" (V3 no tiene el monstruo) + nivel/power que falten.
        nm0 = (a.get("tname") or "").strip().lower()
        if tg <= 0 and nm0:
            if tlv > 0 and (nm0, tlv) in MONCFG_NL: tg = MONCFG_NL[(nm0, tlv)]; match = "cfg"   # nombre+nivel -> id FIABLE (feasible)
            elif nm0 in MONCFG_N: tg = MONCFG_N[nm0]; match = "name"                             # solo nombre -> nivel AMBIGUO (id uncertain)
        if tg > 0 and tg in MONCFG and lvl <= 0: lvl = int(MONCFG[tg].get("level", 0) or 0)      # nivel desde la config del juego
        dist = dist_from_city(tx, ty)
        their_eta = int(a.get("eta", 0) or 0)
        # DETECTAR speedups/gemas del ENEMIGO: si SU ETA baja más rápido que el reloj real entre escaneos -> avanzó el rally con items
        _pe = _ATK_ETA.get((tx, ty)); _nowe = time.time()
        if _pe is not None and their_eta > 0:
            _drop = int(_pe.get("eta", 0)) - their_eta; _el = _nowe - float(_pe.get("ts", _nowe))
            _sp = _pe.get("sped", 0) + int(_drop - _el) if (_drop - _el) > 20 else _pe.get("sped", 0)   # margen 20s para el jitter de muestreo
            _ATK_ETA[(tx, ty)] = {"eta": their_eta, "ts": _nowe, "sped": _sp}
        elif their_eta > 0:
            _ATK_ETA[(tx, ty)] = {"eta": their_eta, "ts": _nowe, "sped": 0}
        _ph = a.get("phase") or ""
        # cuándo MUERE el monstruo a manos del enemigo = su ETA de llegada + (reunión+aterrizaje si rally en wait) + margen de combate
        # (el enemigo no mata al instante de llegar; en fase 'combat' ya está muriendo -> no se suma margen).
        their_kill = their_eta + (buf if _ph == "wait" else 0) + (cmargin if _ph != "combat" else 0)
        our_eta = round(dist * spt_eff)
        targetable = tg > 0                           # sin id de config no se puede disparar un solo válido
        nm = (a.get("tname") or (mm.get("name") if mm else "") or "")
        excluded = any(e in nm.lower() for e in excl_names)   # exclusión MANUAL por nombre (ej Viking)
        mpw = float(mm.get("power", 0) or 0) if mm else 0.0
        if mpw <= 0 and tg > 0 and tg in MONCFG: mpw = float(MONCFG[tg].get("power", 0) or 0)   # power desde la config estática del juego
        if (not nm) and tg in MONCFG: nm = MONCFG[tg].get("name", "") or nm                     # nombre desde la config si falta
        winnable = (mpw <= 0) or (max_pp >= mpw * wr)         # ¿algún preset puede matarlo? (no robar batallas perdidas)
        low_power = (min_pow > 0 and 0 < mpw < min_pow)        # monstruo por debajo del poder mínimo -> no merece robarlo
        out.append({"tx": tx, "ty": ty, "id": tg, "level": lvl,
                    "name": nm, "group": a.get("tgroup") or "", "power": round(mpw),
                    "atk_tag": tag, "atk_name": a.get("name") or "", "phase": a.get("phase") or "",
                    "kind": a.get("kind") or "",   # "alliance"=rally de alianza (mty 19/20/21) / "solo"=1 jugador (mty 2). De V3.
                    "their_eta": their_eta, "their_kill": their_kill, "our_eta": our_eta, "dist": round(dist), "match": match,
                    "targetable": targetable, "excluded": excluded, "winnable": winnable, "low_power": low_power,
                    "no_steal_preset": max_pp <= 0, "preset_pow": round(max_pp), "need_pow": round(mpw * wr),
                    "recently_stolen": (tx, ty) in recently_stolen,   # ya robado hace poco -> el monstruo está muerto -> no re-robar
                    "feasible": bool(targetable and match in ("coord", "namelevel", "attack", "cfg") and not excluded
                                     and winnable and not low_power and our_eta > 0 and their_kill > our_eta + winm
                                     and (tx, ty) not in recently_stolen), "count": int(a.get("count", 0) or 0)})
    # --- robos EN CURSO ("Stealing…"): NO sacarlos de la lista; mostrar tag + ETA de NUESTRA marcha ---
    nowt = time.time()
    om = {(int(m.get("wx", 0)), int(m.get("wy", 0))): m for m in ACCOUNT.get("march_targets", [])}   # nuestras marchas salientes por coord
    out_by_coord = {(s["tx"], s["ty"]): s for s in out}
    for (sx, sy), info in list(STEAL_FIRED.items()):
        mar = om.get((sx, sy))
        if not mar and nowt - info.get("ts", 0) > 25:           # ya no hay marcha saliente y pasó el lag -> robo terminado o rechazado
            STEAL_FIRED.pop((sx, sy), None); continue
        te = int(mar.get("te", 0) or 0) if mar else 0
        eta = (max(0, te - int(nowt)) if te else int(mar.get("dur", 0) or 0)) if mar else None   # None = aún lanzándose
        row = out_by_coord.get((sx, sy))
        if row:
            row["stealing"] = True; row["steal_eta"] = eta
        else:                                                   # el ataque enemigo ya no está en /api/attacks -> fila sintética para que siga visible
            out.append({"tx": sx, "ty": sy, "id": info.get("id", 0), "level": info.get("level", 0), "name": info.get("name", ""),
                        "group": info.get("group", ""), "power": info.get("power", 0), "atk_tag": info.get("atk_tag", ""),
                        "atk_name": info.get("atk_name", ""), "phase": info.get("phase", ""),
                        "their_eta": info.get("their_eta", 0), "their_kill": info.get("their_kill", 0),
                        "our_eta": 0, "dist": info.get("dist", 0) or round(dist_from_city(sx, sy)), "match": "coord", "targetable": True,
                        "excluded": False, "winnable": True, "no_steal_preset": False,
                        "preset_pow": 0, "need_pow": 0, "feasible": False, "kind": info.get("kind", ""),
                        "count": info.get("count", 0), "stealing": True, "steal_eta": eta})
    out.sort(key=lambda s: (0 if s.get("stealing") else (1 if s["feasible"] else (2 if s["targetable"] else 3)), s["dist"]))
    return out, None

def _skip_reason(t):
    """Por qué NO robamos este rally de alianza OBJETIVO. Devuelve (reason, note) o None si no es un skip 'interesante' (ruido)."""
    if t.get("feasible") or t.get("recently_stolen") or t.get("stealing"): return None
    if not t.get("targetable"): return None              # sin id de config -> no accionable
    pw = float(t.get("power", 0) or 0); pm = (f"{pw/1e6:.1f}M" if pw > 0 else "?")
    if t.get("excluded"): return None                    # exclusión manual: el usuario ya lo sabe
    if t.get("low_power"): return ("low_power", f"below min power ({pm})")
    if not t.get("winnable"): return ("cant_win", f"can't win — monster {pm} too strong for our presets")
    tk = int(t.get("their_kill", 0) or 0); oe = int(t.get("our_eta", 0) or 0)
    wm = int(STATE.get("steal_win_margin", 0) or 0)
    if oe > 0 and tk > 0 and tk <= oe + wm:              # no llegamos con el margen EXIGIDO antes de SU kill
        if tk <= oe:                                     # llegamos IGUAL o DESPUÉS que su kill -> realmente lentos
            return ("too_slow", f"too slow — their kill ETA {tk}s vs our ETA {oe}s")
        return ("too_close", f"too close — we arrive {oe}s vs their kill {tk}s (want +{wm}s margin)")   # vamos por delante pero dentro del colchón
    return None
def _auto_steal_cycle():
    """Robo automático: manda presets (SOLO, rápido) a ataques de otras alianzas que podamos robar.
    Reusa las lanes (1 marcha por preset, sin duplicar general). Corre bajo el AUTO global junto a solo/rally/autojoin."""
    if not (STATE.get("auto") and ACCOUNT.get("troops")): return
    queue = [(i, p) for i, p in enumerate(PRESETS) if p.get("enabled") and p.get("troops") and p.get("mode") == "steal"]
    if not queue: return
    in_flight = {(int(m.get("wx", 0)), int(m.get("wy", 0))): int(m.get("dur", 0) or 0)
                 for m in ACCOUNT.get("march_targets", [])}
    active = int(ACCOUNT.get("active_marches", 0) or 0)
    now = time.time()
    # Tras disparar un robo, ¿el general G salió a marchar? (gid es fiable en march_targets). Si sale = robo lanzado;
    # si no sale en 22s = no salió (general ocupado / hiccup del server) -> se limpia el pending y se reintenta.
    march_gids = {int(m.get("gid", 0) or 0) for m in ACCOUNT.get("march_targets", []) if int(m.get("gid", 0) or 0) > 0}
    march_gids |= set(_JOIN_GEN.keys()) | set(_JOIN_WAR.keys())   # + comprometidos a un token de autojoin (gid=0 en la reunión) -> no re-desplegar ese general en un steal
    for mid in list(STEAL_PENDING):
        pend = STEAL_PENDING[mid]
        gid = int(pend.get("gid", 0) or 0)
        launched = (gid > 0 and gid in march_gids)   # SOLO por general (quitado el coord-match: flaky y falso-positivo). Con la guarda de "general ocupado" al disparar, que el general aparezca marchando = NUESTRO robo salió de verdad.
        if launched:
            logmsg(f"STEAL OK: march '{pend['name']}' @{pend['tx']},{pend['ty']} LAUNCHED (gen {gid} marching) | "
                   f"match={pend.get('match')} subtype={pend.get('subtype')} lvl={pend.get('level')}")
            STEAL_SEEN[(pend["tx"], pend["ty"])] = now            # robo lanzado -> el monstruo morirá -> no re-robar este tile (evita re-steal de muerto)
            # CALIBRACIÓN seg/tile REAL de este general (marcha que SÍ salió): te(llegada) - ts(disparo) = viaje real
            try:
                mt = next((m for m in ACCOUNT.get("march_targets", []) if int(m.get("gid", 0) or 0) == gid), None)
                if mt:
                    te = int(mt.get("te", 0) or 0); dst = dist_from_city(pend["tx"], pend["ty"]); travel = te - float(pend.get("ts", now))
                    if dst > 5 and travel > 5:
                        rspt = travel / dst; acc = (0.5 <= rspt <= 2.5)   # rango sano: descarta lecturas raras / ida+vuelta
                        if acc:
                            _sm = STATE.setdefault("spt_meas", {}); prev = _sm.get(str(gid))
                            _sm[str(gid)] = round((0.7 * prev + 0.3 * rspt) if prev else rspt, 3); save_state()   # PERSISTE: sobrevive restarts
                        logmsg(f"SPT meas gen {gid}: {rspt:.2f} s/tile (viaje {int(travel)}s / {dst} tiles) {'-> '+str((STATE.get('spt_meas') or {}).get(str(gid))) if acc else 'rechazado(fuera de rango)'}")
            except Exception: pass
            sped = int((_ATK_ETA.get((pend["tx"], pend["ty"])) or {}).get("sped", 0))   # speedups/gemas que usó el enemigo en SU rally
            margin = int(pend.get("their_kill", 0) or 0) - int(pend.get("our_eta", 0) or 0)   # cuánto antes llegábamos nosotros (al disparar)
            hist("steal", name=pend.get("name"), level=pend.get("level"), tx=pend["tx"], ty=pend["ty"],
                 power=pend.get("power"), alliance=pend.get("alliance"), attacker=pend.get("attacker"),
                 troops=pend.get("troops_n"), general=pend.get("gen_name"), our_eta=pend.get("our_eta"),
                 their_kill=pend.get("their_kill"), enemy_speedup=sped, mode="steal",
                 atk_kind=pend.get("kind"), count=pend.get("count"),
                 result=("lost" if sped > max(0, margin) else "won"),
                 note=(f"enemy used ~{sped}s speedups/gems -> overtook us" if sped > max(0, margin) else (f"enemy used ~{sped}s speedups (we still landed first)" if sped > 0 else "")))
            STEAL_PENDING.pop(mid, None)
        elif now - pend["ts"] > 30:
            # El general NO salió a marchar en 30s (server rechazó el solo, o hiccup) -> limpia y REINTENTA al próximo ciclo.
            # El aprendizaje AUTO de "rally-only" (3 strikes -> steal_blacklist) se QUITÓ 2026-07-03: daba FALSOS POSITIVOS
            # (p.ej. Ymir Lv3 SÍ es soloable y quedó bloqueado por strikes de otras causas; era por TIPO, así que 3 fallos
            # bloqueaban todos). Para no robar algo concreto: exclusión MANUAL "Never steal" (steal_exclude) o STEAL_HARD_EXCLUDE.
            logmsg(f"STEAL: '{pend['name']}' (id {mid}) no march in 30s — will retry")
            AUTO_LANES.pop(pend.get("lane"), None)
            STEAL_PENDING.pop(mid, None)
    targets, err = _steal_targets()
    if err: return
    # registrar en History los rallies de alianzas OBJETIVO que NO robamos y POR QUÉ (too slow / too close con kill ETA, can't win, low power).
    # Deduplicado por (tile, atacante, motivo) -> 1 registro por rally, no por escaneo. Solo corre con AUTO + presets de robo activos.
    for _k in [k for k, ts in list(SKIP_SEEN.items()) if now - ts > 1200]: SKIP_SEEN.pop(_k, None)
    for t in targets:
        rr = _skip_reason(t)
        if not rr: continue
        key = (t["tx"], t["ty"], t.get("atk_name", ""), rr[0])
        if key in SKIP_SEEN: continue
        SKIP_SEEN[key] = now
        sped = int((_ATK_ETA.get((t["tx"], t["ty"])) or {}).get("sped", 0))
        hist("skip", name=t.get("name"), level=t.get("level"), tx=t["tx"], ty=t["ty"], power=t.get("power"),
             alliance=t.get("atk_tag"), attacker=t.get("atk_name"), their_kill=t.get("their_kill"), our_eta=t.get("our_eta"),
             enemy_speedup=sped, phase=t.get("phase"), atk_kind=t.get("kind"), count=t.get("count"), reason=rr[0], result=rr[0],
             note=rr[1] + (f" · enemy used ~{sped}s speedups/gems" if sped > 0 else ""))
    feas = [t for t in targets if t["feasible"]]
    if not feas: return
    for k in [k for k, ts in list(AUTO_FIRED.items()) if now - ts > max(int(STATE["cooldown"]), int(AUTO_HOLD.get(k, 0) or 0))]:
        AUTO_FIRED.pop(k, None); AUTO_HOLD.pop(k, None)   # FIX DUPLICADOS: respetar AUTO_HOLD (ventana real del rally ~21min). Antes purgaba a los 360s (cooldown) ignorando AUTO_HOLD: este ciclo (STEAL) comparte AUTO_FIRED con el de FARM/RALLY, así que liberaba coords de rally antes de tiempo -> el check `coord not in AUTO_FIRED` (1183) las veía libres -> RE-DISPARO a ~6-8min (mismo boss, MISMO battle report -> won duplicado con números idénticos). Ahora IDÉNTICA a la purga del ciclo farm (~L1124). Solo corre si hay steals feasibles (por eso era intermitente)
    for k in [k for k, ts in list(STEAL_SEEN.items()) if now - ts > STEAL_SEEN_TTL]: STEAL_SEEN.pop(k, None)   # purga tiles robados ya expirados (pueden re-robarse si reaparece un monstruo)
    active_idx = {i for i, p in enumerate(PRESETS) if p.get("enabled") and p.get("troops")}   # TODOS los presets activos (no solo los de este ciclo) -> no borrar las lanes de otros modos
    for k in [k for k in list(AUTO_LANES) if k not in active_idx]: AUTO_LANES.pop(k, None)
    cap_n = int(STATE.get("max_slots", 6) or 6)
    if active >= cap_n: return
    GRACE = 25
    fired = []
    for idx, p in queue:
        lane = AUTO_LANES.get(idx)
        if lane:
            tgt = lane["target"]
            if tgt in in_flight: lane["seen"] = now; lane["dur"] = in_flight[tgt] or lane.get("dur", 0); continue
            if now - lane["ts"] < lane.get("hold", GRACE): continue
            if lane.get("seen") and now - lane["seen"] < (lane.get("dur") or 120) + 25: continue
        if int(p.get("general_id", 0) or 0) in march_gids: continue   # GENERAL YA MARCHANDO -> el server rechazaría el solo (no sale marcha) -> NO disparar (evitaba el falso "sent": gid quedaba en march_gids y se daba por lanzado sin marcha real)
        if active + len(fired) >= cap_n: break
        taken = {l["target"] for l in AUTO_LANES.values()}
        cand = next((t for t in feas if (t["tx"], t["ty"]) not in in_flight
                     and (t["tx"], t["ty"]) not in taken and (t["tx"], t["ty"]) not in AUTO_FIRED
                     and _can_win(p, t.get("power", 0))                 # este preset debe poder matarlo
                     and _can_reach(p, t)), None)                       # y su general (su velocidad real) debe llegar a tiempo
        if not cand: continue
        pseudo = {"x": cand["tx"], "y": cand["ty"], "id": cand["id"], "level": cand["level"],
                  "name": cand["name"], "group": cand["group"], "power": cand.get("power", 0)}
        t = _make_target({**p, "mode": "solo"}, pseudo)   # robo = SOLO siempre (rápido)
        if not t: continue
        payload = {k: t[k] for k in FIRE_KEYS}
        post_agent({"type": "ctl", "cmd": "fire", "targets": [payload]})
        logmsg(f"STEAL FIRE P{idx+1}->{cand['name']} @{cand['tx']},{cand['ty']} | match={cand.get('match')} "
               f"subtype={payload.get('subtype')} level={payload.get('level')} type={payload.get('march_type')} "
               f"tuid={payload.get('target_user_id')} | ourETA~{cand['our_eta']}s theirETA={cand['their_eta']}s "
               f"pow_mon={cand.get('power')} pow_preset={int(_preset_pow(p))} troops={payload.get('troops')}")
        AUTO_LANES[idx] = {"target": (cand["tx"], cand["ty"]), "ts": now, "seen": 0, "dur": 0, "hold": _lane_hold(t), "name": cand.get("name"), "level": int(cand.get("level", 0) or 0), "steal": True, "atk_name": cand.get("atk_name"), "atk_tag": cand.get("atk_tag")}
        STATE["lane_disp"][str(idx)] = {"name": cand.get("name"), "level": int(cand.get("level", 0) or 0), "tx": int(cand["tx"]), "ty": int(cand["ty"]), "steal": True, "atk_name": cand.get("atk_name"), "atk_tag": cand.get("atk_tag")}; save_state()   # persist target context (survives restarts)
        AUTO_FIRED[(cand["tx"], cand["ty"])] = now
        STEAL_PENDING[cand["id"]] = {"tx": cand["tx"], "ty": cand["ty"], "ts": now, "name": cand["name"], "lane": idx,
                                     "our_eta": cand["our_eta"], "match": cand.get("match"),
                                     "gid": int(payload.get("general_id", 0) or 0),   # general del robo: señal fiable de "salió la marcha"
                                     "subtype": payload.get("subtype"), "level": payload.get("level"),
                                     "power": cand.get("power", 0), "alliance": cand.get("atk_tag", ""), "attacker": cand.get("atk_name", ""),
                                     "their_kill": cand.get("their_kill", 0), "phase": cand.get("phase", ""),
                                     "kind": cand.get("kind", ""), "count": cand.get("count", 0),   # enemigo solo vs rally de alianza (+ nº marchas)
                                     "troops_n": sum(int(t.get("n", 0) or 0) for t in (payload.get("troops") or [])),
                                     "gen_name": _gen_name(int(p.get("general_id", 0) or 0))}   # datos para la pestaña History
        STEAL_FIRED[(cand["tx"], cand["ty"])] = {"ts": now, "name": cand["name"], "id": cand["id"], "level": cand["level"],
                                                 "group": cand.get("group", ""), "power": cand.get("power", 0),
                                                 "atk_tag": cand.get("atk_tag", ""), "atk_name": cand.get("atk_name", ""),
                                                 "phase": cand.get("phase", ""), "dist": cand.get("dist", 0),
                                                 "kind": cand.get("kind", ""), "count": cand.get("count", 0),
                                                 "their_eta": cand.get("their_eta", 0), "their_kill": cand.get("their_kill", 0)}
        fired.append((idx, cand))
        with LOCK:
            FIRES.append({"t": int(now), "name": cand["name"], "level": cand["level"], "mode": "steal",
                          "x": cand["tx"], "y": cand["ty"], "dist": cand["dist"], "tunits": t["tunits"], "auto": True})
            if len(FIRES) > 200: del FIRES[:100]
    if fired:
        logmsg("STEAL: +" + str(len(fired)) + " steal(s): " +
               ", ".join(f"P{i+1}->{c['name']} @{c['tx']},{c['ty']} (their ETA {c['their_eta']}s vs ~{c['our_eta']}s)" for i, c in fired))
    elif feas:
        # diagnóstico detallado: POR QUÉ no disparó ningún preset libre
        free_lanes = [idx for idx, p in queue if idx not in AUTO_LANES]
        gens_busy = [idx for idx, p in queue if int(p.get("general_id", 0) or 0) in march_gids]
        taken_now = {l["target"] for l in AUTO_LANES.values()}
        top = feas[0]; tk = (top["tx"], top["ty"])
        canwin_free = [idx for idx, p in queue if idx in free_lanes and _can_win(p, top.get("power", 0))]
        logmsg(f"STEAL debug: {len(feas)} feasible but 0 fired | queue={[i for i,_ in queue]} free_lanes={free_lanes} gens_busy={gens_busy} active={active}/{cap_n} "
               f"| top={top['name']}@{top['tx']},{top['ty']} pow={top.get('power')} in_cd={tk in AUTO_FIRED} taken={tk in taken_now} inflight={tk in in_flight} canwin_by_free_presets={canwin_free}")

_REFILL = {"last": 0.0}    # anti-spam: timestamp de la última recarga

def _plan_refill(items, deficit):
    """items: [{id,num,gain}] -> [(item_id,count)] cuya stamina sumada >= deficit,
    prefiriendo items grandes (menos consumos) y con el menor exceso posible."""
    avail = {it["id"]: int(it.get("num") or 0) for it in items}
    gain = {it["id"]: int(it.get("gain") or 0) for it in items}
    ids_desc = sorted([i for i in avail if gain.get(i, 0) > 0 and avail[i] > 0], key=lambda i: -gain[i])
    plan = {}; remaining = int(deficit)
    for i in ids_desc:                                   # rellena con grandes sin pasarse (floor)
        if remaining <= 0: break
        use = min(avail[i], remaining // gain[i])
        if use > 0: plan[i] = plan.get(i, 0) + use; avail[i] -= use; remaining -= use * gain[i]
    if remaining > 0:                                    # resto: 1 item que lo cubra (el menor que llegue), o el mayor disponible
        cands = [i for i in avail if avail[i] > 0]
        cover = sorted([i for i in cands if gain[i] >= remaining], key=lambda i: gain[i])
        pick = cover[0] if cover else (sorted(cands, key=lambda i: -gain[i])[0] if cands else None)
        if pick is not None: plan[pick] = plan.get(pick, 0) + 1
    return [(i, c) for i, c in plan.items() if c > 0]

def _refill_to_target(force=False):
    """Recarga stamina consumiendo items del bag hasta refill_target. force=True ignora el umbral."""
    cur = ACCOUNT.get("stamina")
    if cur is None or cur < 0: return {"ok": False, "error": "no stamina reading yet"}
    thr = int(STATE.get("refill_threshold", 500) or 0); tgt = int(STATE.get("refill_target", 2000) or 0)
    if not force and cur >= thr: return {"ok": True, "skipped": "above threshold", "stamina": cur}
    if cur >= tgt: return {"ok": True, "skipped": "already >= target", "stamina": cur}
    items = [it for it in ACCOUNT.get("stamina_items", []) if (it.get("num") or 0) > 0 and (it.get("gain") or 0) > 0]
    if not items:
        _now = time.time()
        if _now - _MAIL_CLAIM["last"] > MAIL_CLAIM_COOLDOWN:
            _claim_reward_mails("stamina baja + bag sin items de stamina")
            return {"ok": False, "error": "sin items de stamina; cobrando stamina del mail (reintento en el proximo ciclo)"}
        logmsg(f"refill: stamina {cur} low but NO stamina items left in bag (cobro de mail en cooldown)"); return {"ok": False, "error": "no stamina items in bag"}
    plan = _plan_refill(items, max(0, tgt - cur))
    if not plan: return {"ok": True, "skipped": "nothing to do", "stamina": cur}
    for (iid, cnt) in plan: post_agent({"type": "ctl", "cmd": "use_item", "item_id": iid, "amount": cnt})
    gmap = {it["id"]: it["gain"] for it in items}
    gained = sum(gmap.get(i, 0) * c for i, c in plan)
    logmsg(f"refill: stamina {cur} < target {tgt} -> use {plan} (~+{gained} -> ~{cur + gained})")
    hist("refill", gained=int(gained), items=sum(int(c) for _, c in plan), from_stamina=int(cur), target=int(tgt), result="ok")
    return {"ok": True, "plan": plan, "gained": gained, "from": cur, "target": tgt}

def _auto_refill_cycle():
    if not STATE.get("refill"): return
    cur = ACCOUNT.get("stamina")
    if cur is None or cur < 0 or cur >= int(STATE.get("refill_threshold", 500) or 0): return
    now = time.time()
    if now - _REFILL["last"] < 45: return                # espera al re-scan tras consumir (evita doble disparo)
    _REFILL["last"] = now
    _refill_to_target(force=False)

_MAIL_LAST = {"ts": 0.0, "found": 0}   # último resultado del cobro (lo lee el botón Get Stamina)
_MAIL_CLAIM = {"last": 0.0}          # in-memory: anti-spam del disparo por stamina-baja (no persiste al reinicio)
MAIL_CLAIM_COOLDOWN = 3600           # 1h min entre cobros disparados por stamina-baja
MAIL_CLAIM_EVERY = 12 * 3600          # cobro periodico cada 12h (2026-08-12; era 8h). Persistido en STATE.
def _claim_reward_mails(reason=""):
    """Pide al agente cobrar TODOS los reward mails (stamina de eventos / jugador-jugador, etc.).
    Persiste el timestamp para la cadencia de 24h."""
    now = time.time()
    _MAIL_CLAIM["last"] = now
    STATE["last_mail_claim"] = now; save_state()
    ok = post_agent({"type": "ctl", "cmd": "claim_reward_mails"})
    logmsg(f"MAIL: cobrando reward mails ({reason})" + ("" if ok else " [post fail]"))
    return ok
def _mail_claim_cycle():
    """Cobra los reward mails una vez cada 24h (independiente de stamina/AUTO)."""
    if time.time() - float(STATE.get("last_mail_claim", 0) or 0) >= MAIL_CLAIM_EVERY:
        _claim_reward_mails("cada 24h")

# ---- AUTO-TRUCE: renueva el escudo de paz (burbuja) con un Truce Agreement del bag ANTES de que caiga. NUNCA gemas
#      (UsePeaceShield consume el item). Usa el de MAYOR duración disponible primero (menos renovaciones). ----
_TRUCE = {"last": 0.0}
def _truce_now(force=False):
    """Renueva la burbuja usando un Truce Agreement. force=True ignora el umbral de horas."""
    left = ACCOUNT.get("peace_shield_left")
    if left is None or int(left) < 0: return {"ok": False, "error": "no shield reading yet"}
    left = int(left)
    thr = int(float(STATE.get("truce_renew_h", 2) or 0) * 3600)
    if not force and left > thr: return {"ok": True, "skipped": "shield above threshold", "left": left}
    items = [it for it in (ACCOUNT.get("truce_items") or []) if int(it.get("num", 0) or 0) > 0 and int(it.get("dur", 0) or 0) > 0]
    if not items:
        logmsg("truce: shield low but NO Truce Agreements in bag"); return {"ok": False, "error": "no truce agreements in bag"}
    pick = max(items, key=lambda it: int(it.get("dur", 0)))   # MAYOR duración primero (menos renovaciones / burbuja más larga)
    post_agent({"type": "ctl", "cmd": "use_truce", "item_id": int(pick["id"])})
    h = int(pick["dur"]) // 3600
    logmsg(f"AUTO-TRUCE: shield {left}s (<= {thr}s) -> using {h}h Truce (id {pick['id']}, {pick['num']} left)")
    hist("truce", dur=int(pick["dur"]), item_id=int(pick["id"]), left_before=left, result="ok", note=f"renewed bubble with {h}h truce ({pick['num']-1} left)")
    return {"ok": True, "used": int(pick["id"]), "dur": int(pick["dur"]), "left": left}
def _auto_truce_cycle():
    if not STATE.get("auto_truce"): return
    left = ACCOUNT.get("peace_shield_left")
    if left is None or int(left) < 0: return                 # sin lectura del escudo todavía
    if int(left) > int(float(STATE.get("truce_renew_h", 2) or 0) * 3600): return   # aún hay margen
    now = time.time()
    if now - _TRUCE["last"] < 120: return                    # espera al re-scan tras aplicar (evita gastar 2 truces)
    _TRUCE["last"] = now
    _truce_now(force=False)

# ---- AUTO-HEAL: cura los heridos con recursos+tiempo (immediately=0, NUNCA gemas/speedups). El server recalcula el
#      coste; el agente sólo envía troop+position_id (verificado en vivo). Sólo cura con la cola libre (healing==0)
#      para no intentar encolar dos curas a la vez.
HEAL = {"last_fire": 0.0, "last_msg": "", "last_wounded": -1, "worked": False}
# RALLY MONTHLY CARD — estado vivo del auto-join de la tarjeta (leído del juego: GuildManager.autoRallyData).
# active = card auto-join ON (temporizador 8h corriendo). supported=None hasta la 1ª lectura. Excluyente con AUTO global.
RALLY_CARD = {"supported": None, "active": False, "expire": 0, "secs_left": 0,
              "owned": None, "card_expire": 0, "card_secs_left": 0,   # caducidad de la SUSCRIPCIÓN (días restantes de la tarjeta); epoch absoluto
              "ts": 0.0, "last_poll": 0.0}
def _rally_card_cycle():
    # Refresca el estado de la Rally Monthly Card cada ~30s (ligero: 1 msg de red vía CheckAutoRally en el agente).
    now = time.time()
    if now - RALLY_CARD.get("last_poll", 0) < 30: return
    RALLY_CARD["last_poll"] = now
    post_agent({"type": "ctl", "cmd": "rally_card_get"})
def _auto_heal_cycle():
    if not STATE.get("heal"): return
    wounded = ACCOUNT.get("wounded"); healing = ACCOUNT.get("healing")
    if wounded is None or healing is None or wounded < 0: return     # aún no hay scan con datos de heridos
    if (healing or 0) > 0:                                           # una cura YA está en cola -> ARRANCÓ bien -> esperar a que termine
        HEAL["worked"] = True                                        # healing>0 = la última cura SÍ tomó efecto (marca REAL de progreso)
        return
    if wounded < int(STATE.get("heal_threshold", 1) or 1): return    # nada (o menos del umbral) que curar
    now = time.time()
    since = (now - HEAL["last_fire"]) if HEAL["last_fire"] else 1e9
    # FIX 2026-07-29: "rechazada" NO se mide por si el nº de heridos cambió (el farmeo los cambia SIEMPRE -> burlaba el back-off
    # y salía un HEAL inútil cada 30s-5min), sino por si la cura ARRANCÓ: healing>0 alguna vez, o los heridos BAJARON. Si
    # disparamos y la cola nunca subió ni bajaron los heridos, el server la rechaza (típico: SIN recursos -> mineral agotado /
    # capacidad de hospital) -> reintento MUY espaciado (15min) hasta que haya recursos, en vez de spamear.
    lw = HEAL.get("last_wounded", -1)
    healed_some = (lw >= 0 and 0 <= wounded < lw)                    # los heridos bajaron desde el último disparo = la cura funcionó
    rejected = bool(HEAL["last_fire"]) and not HEAL.get("worked") and not healed_some
    cooldown = 900 if rejected else 30                              # rechazada -> 15min; arrancó / 1er intento -> 30s
    if since < cooldown: return
    HEAL["last_fire"] = now; HEAL["worked"] = False; HEAL["last_wounded"] = wounded; HEAL["last_msg"] = f"auto-healing {wounded} wounded"
    post_agent({"type": "ctl", "cmd": "heal_soldiers"})
    logmsg(f"AUTO-HEAL: curando {wounded} heridos" + (" (reintento espaciado 15min: la cura no arranca — ¿recursos/capacidad?)" if rejected else " (cola libre)"))

def _heal_now(cancel_first=False):
    """Cura manual: cura TODOS los heridos ya. Si la cola está ocupada, requiere cancel_first (cancela la cura en
    curso -> devuelve tropas+recursos -> vuelve a curar todo). Siempre recursos+tiempo, nunca gemas."""
    wounded = ACCOUNT.get("wounded"); healing = ACCOUNT.get("healing")
    if wounded is None: return {"ok": False, "error": "no scan yet"}
    if (wounded or 0) <= 0: return {"ok": True, "skipped": "no wounded"}
    if (healing or 0) > 0 and not cancel_first:
        return {"ok": False, "error": "heal queue busy — enable cancel_first to override"}
    HEAL["last_fire"] = time.time(); HEAL["last_msg"] = f"healing {wounded} wounded"
    post_agent({"type": "ctl", "cmd": "heal_soldiers", "cancel_first": bool(cancel_first)})
    logmsg(f"HEAL now: {wounded} heridos (cancel_first={cancel_first})")
    return {"ok": True, "wounded": wounded, "cancel_first": bool(cancel_first)}

_JOINED_WARS = {}   # war_id -> ts del último intento de join (dedup; se purga a los 30 min)
_JOIN_GEN = {}      # general_id -> ts del último join (grace de scan-lag: cuenta como ocupado)
JOIN_GEN_COOLDOWN = 180   # 60 -> 180s. El guard cubre el HUECO entre mandar el join y verlo en el scan. Con 60s se colaban
                          # dos joins del MISMO general a 68s de distancia (visto: Beowulf 09:54:50 y 09:55:58) porque la
                          # marcha del primero aún no aparecía en march_targets y el general parecía libre.
def _seed_join_state():
    """Siembra _JOINED_WARS y _JOIN_GEN desde el HISTORY PERSISTIDO. Sin esto, cada reinicio del backend borraba la
    memoria de a qué rallies ya nos habíamos unido y con qué general -> el bot se re-unía al MISMO war con otro preset
    (visto: war 11886978 con Huo Qubing 09:51:24 y con Beowulf 09:54:50, justo con un reinicio en medio)."""
    now = time.time()
    try:
        for e in HISTORY:
            if e.get("kind") != "join": continue
            ts = float(e.get("ts", 0) or 0)
            wid = int(e.get("war_id", 0) or 0)
            if wid and now - ts <= 1800 and wid not in _JOINED_WARS: _JOINED_WARS[wid] = ts
            gid = int(e.get("gid", 0) or 0)
            if gid and now - ts <= JOIN_GEN_COOLDOWN and gid not in _JOIN_GEN: _JOIN_GEN[gid] = ts
            if gid and wid and now - ts <= 1800 and any(int(_p.get("general_id", 0) or 0) == gid and _p.get("mode") == "autojoin" for _p in PRESETS):
                _prev = _JOIN_WAR.get(gid)                          # _JOIN_WAR indexado por GENERAL (estable ante reordenar presets)
                if not _prev or ts >= float(_prev.get("ts", 0) or 0):   # quedarse con el join MÁS RECIENTE de ese general
                    _JOIN_WAR[gid] = {"war_id": wid, "ts": ts, "tx": e.get("tx"), "ty": e.get("ty"),
                                      "name": e.get("name"), "level": e.get("level"), "power": e.get("power"),
                                      "leader": e.get("leader"), "tag": e.get("tag")}
    except Exception as ex:
        logmsg(f"_seed_join_state err: {ex}")
_JOIN_WAR = {}      # general_id -> {"war_id", "ts"}: el rally que ESE general tiene en curso. Indexado por GENERAL (no por índice de preset: reordenar/reconfigurar presets ya no descoloca marcha/nombre en la barra). IMPRESCINDIBLE porque el
                    # juego devuelve las marchas de rally con __general_id=0 (solo lo rellena en solos, mtype=2), así que
                    # NO se puede saber por general si un preset autojoin está ocupado. Sin esto (bug visto 2026-07-03 en
                    # Lume): busy salía vacío -> cada preset se re-unía a OTRO war cada ~40s -> 3 presets llenaron los 6
                    # slots -> el preset SOLO se quedaba sin slots y sus marchas nunca salían (historial en 'pending').

def _auto_join_cycle():
    """AUTO-JOIN POR-PRESET: cada preset en modo 'autojoin' (habilitado + tropas + general) se une
    a UN rally PvE de tu alianza con SU general + asistente + tropas, si ese general está LIBRE.
    Convive con solo/rally/steal (cada preset usa su propio general; busy_gens evita duplicarlo)."""
    # OJO: un preset autojoin NO necesita tropas configuradas. Se une SIEMPRE con un token de 1×T1 que
    # construye _autojoin_troops() unas líneas más abajo, IGNORANDO por completo p["troops"]. Exigir aquí
    # p.get("troops") era un resto de cuando el autojoin mandaba el ejército del preset, y dejaba el
    # auto-join MUERTO en silencio: como la UI oculta el editor de tropas en modo autojoin, un preset
    # creado directamente como autojoin nace con troops=[] -> filtro vacío -> `if not aj: return` y el
    # ciclo no llegaba a ejecutarse NUNCA (0 líneas "AUTO-JOIN" en el log, con rallies libres a la vista).
    # Solo funcionaban los presets heredados de un modo solo/rally, que conservaban tropas de antes.
    aj = [(i, p) for i, p in enumerate(PRESETS)
          if p.get("enabled") and p.get("mode") == "autojoin" and int(p.get("general_id", 0) or 0) > 0]
    _aj_gens = {int(p.get("general_id", 0) or 0) for _, p in aj}
    for _g in [k for k in list(_JOIN_WAR) if k not in _aj_gens]: _JOIN_WAR.pop(_g, None)   # purga generales que ya no son un preset autojoin (cambiado de modo / apagado / reordenado)
    if not aj: return
    wars = ACCOUNT.get("guild_wars", []) or []
    if not wars: return
    _mons_fresh()          # pobla el mapa de nombres del escáner (nombre/poder del monstruo por coords) — clave en bots solo-autojoin
    now = time.time()
    for k in [k for k, ts in list(_JOINED_WARS.items()) if now - ts > 1800]: _JOINED_WARS.pop(k, None)
    for k in [k for k, ts in list(_JOIN_GEN.items()) if now - ts > JOIN_GEN_COOLDOWN]: _JOIN_GEN.pop(k, None)
    for i in [i for i, v in list(_JOIN_WAR.items()) if now - v["ts"] > 1800]: _JOIN_WAR.pop(i, None)   # solo alimenta la barra
    mt = ACCOUNT.get("march_targets", [])
    live_wars = {int(m.get("war_id", 0) or 0) for m in mt if int(m.get("war_id", 0) or 0) > 0}
    # FRENO POR CONTEO (no por identificador). El juego NO permite atribuir una marcha de rally a su preset:
    # devuelve __general_id=0 y __union_war_id a 0 en muchas fases (visto 2026-07-03: mtype=19 -> gid=0 y war_id=0).
    # Así que se cuenta: cada preset autojoin puede tener 1 rally en vuelo -> si ya hay tantas marchas de rally como
    # presets autojoin, no hay ninguno libre. Sin esto, `busy` salía vacío y cada preset se re-unía a OTRO war cada
    # ~40s hasta llenar los 6 slots, dejando al preset SOLO sin sitio (sus marchas nunca salían -> 'pending').
    RALLY_MTY = (10, 19, 20, 21)     # 10=refuerzo/join en camino al líder; 19/20/21=fases del rally
    aj_gids = {int(p.get("general_id", 0) or 0) for _, p in aj}
    rally_n = sum(1 for m in mt if int(m.get("mtype", 0) or 0) in RALLY_MTY
                  and (int(m.get("gid", 0) or 0) == 0 or int(m.get("gid", 0) or 0) in aj_gids))
    # FIX 2026-07-30: el conteo metía marchas que NO son de presets autojoin. Un preset SOLO/RALLY con su marcha en
    # mtype 10/19/20/21 (p.ej. la vuelta a casa, o cualquier fase de un rally propio) inflaba rally_n -> libres_aj=0
    # -> `cupo<=0` -> return, y el ciclo NO llegaba nunca a los últimos presets autojoin (visto en pixel: P6 con su
    # general LIBRE y 3 rallies sin unir, jamás salía; en lume los 5 presets RALLY dejaban al único autojoin a 0).
    # Ahora solo cuentan las marchas de rally ATRIBUIBLES al pool autojoin: gid de un preset autojoin, o gid==0
    # (el juego devuelve gid=0 en varias fases del rally -> no se puede atribuir, se cuenta por seguridad = freno original).
    recent = sum(1 for g, ts in _JOIN_GEN.items() if now - ts < 60)      # joins ya enviados que aún no salen en el scan
    libres_aj = max(0, len(aj) - max(rally_n, recent))                   # presets autojoin realmente libres
    free = max(0, int(STATE.get("max_slots", 6) or 6) - len(mt))         # y nunca pasar del tope de slots
    cupo = min(libres_aj, free)
    if cupo <= 0: return
    _cx, _cy = _city()
    joinable = [w for w in wars if int(w.get("war_id", 0) or 0) and not w.get("joined")
                and int(w["war_id"]) not in _JOINED_WARS
                and int(w["war_id"]) not in live_wars                             # ya estamos en ese rally
                and not (int(w.get("mst", 0) or 0) and int(w["mst"]) < now - 5)   # rally ya marchó -> tarde
                and int(w.get("lwx", 0) or 0) and int(w.get("lwy", 0) or 0)
                and not (_cx and _cy and int(w.get("lwx", 0) or 0) == _cx and int(w.get("lwy", 0) or 0) == _cy)]
                # FIX 2026-07-31: NO unirse a un rally que LIDERAMOS nosotros (lwx,lwy = mi ciudad). Estaba metiendo el
                # token de 1xT1 en los rallies de nuestros propios presets rally: gastaba un slot de marcha y un general
                # para nada, y salía en el panel Alliance War como "Huo Qubing · 1 Mounted Conscript" sobre NUESTRO rally.
    if not joinable: return
    taken = set(); sent = []
    for idx, p in aj:
        if cupo <= 0: break                                      # cupo agotado (rallies en vuelo o slots llenos)
        g = int(p.get("general_id", 0) or 0)
        troops = _autojoin_troops()   # AUTO-JOIN always sends a single T1 token, never the preset's real army (also fixes presets already saved in autojoin)
        if not troops: continue        # account has no troops for a token -> skip
        if g in {int(m.get("gid", 0) or 0) for m in mt if int(m.get("gid", 0) or 0) > 0}: continue   # su general marcha en un SOLO
        if now - _JOIN_GEN.get(g, 0) < JOIN_GEN_COOLDOWN: continue   # join recién enviado (aún no aparece en el scan)
        w = next((x for x in joinable if int(x["war_id"]) not in taken), None)
        if not w: break                                          # no quedan rallies libres para más presets autojoin
        wid = int(w["war_id"]); taken.add(wid); _JOINED_WARS[wid] = now; _JOIN_GEN[g] = now
        _wtx, _wty = int(w.get("tx", 0) or 0), int(w.get("ty", 0) or 0)          # objetivo del rally al que nos unimos
        _wm = dict(_MONS_XY.get((_wtx, _wty)) or _MONS_XY_ALL.get((_wtx, _wty)) or {})   # respaldo: lo que sepa el escáner del tile
        if int(w.get("mlevel", 0) or 0) > 0: _wm["level"] = int(w.get("mlevel"))         # nivel y poder del PROPIO rally: fiables
        if int(w.get("mpower", 0) or 0) > 0: _wm["power"] = int(w.get("mpower"))
        _wc = _mon_by_lvpow(_wm.get("level"), _wm.get("power"))                          # nombre por (nivel,poder) contra MONCFG
        if _wc and _wc.get("name"): _wm["name"] = _wc["name"]                            # (el rally NO trae nombre: __name = "1")
        if not _wm.get("name") and _wtx and _wty:                                        # PERIFERIA: el mapa (top-100k) no tiene el
            _wt2 = _scanner_tile(_wtx, _wty)                                             # tile -> consulta DIRIGIDA (esquiva truncado)
            if _wt2:
                _wm["name"] = _wt2["name"]
                if not _wm.get("level"): _wm["level"] = _wt2["level"]
                if not _wm.get("power"): _wm["power"] = _wt2["power"]
        _JOIN_WAR[g] = {"war_id": wid, "ts": now,
                          "tx": _wtx or None, "ty": _wty or None,
                          "name": _wm.get("name"), "level": int(_wm.get("level", 0) or 0) or None,
                          "power": int(_wm.get("power", 0) or 0) or None,
                          "leader": (str(w.get("lname")) if w.get("lname") else None),
                          "tag": (str(w.get("ltag")) if w.get("ltag") else None)}; cupo -= 1
        post_agent({"type": "ctl", "cmd": "join_rally", "wars": [{
            "war_id": wid, "lwx": int(w["lwx"]), "lwy": int(w["lwy"]), "troops": troops,
            "general_id": g, "assistant_id": int(p.get("assistant_id", 0) or 0)}]})
        sent.append({"preset": idx + 1, "war_id": wid, "general": g})
        hist("join", war_id=wid, gid=g, lwx=int(w["lwx"]), lwy=int(w["lwy"]), general=_gen_name(g),
             tx=_wtx or None, ty=_wty or None, name=_wm.get("name"), level=int(_wm.get("level", 0) or 0) or None,
             power=int(_wm.get("power", 0) or 0) or None,
             leader=(str(w.get("lname")) if w.get("lname") else None),           # quién CONVOCA el rally
             tag=(str(w.get("ltag")) if w.get("ltag") else None),                # tag corto de su alianza
             troops=sum(int(t.get("n", 0) or 0) for t in troops), result="joined")
    if sent: logmsg(f"AUTO-JOIN: {sent} (cada preset con su general + tropas)")

# ---- Wheel of Fortune (ruleta de la taberna): gira tandas de "100 spins" (play_wheel{bet:100}) para conseguir lotes de stamina.
WHEEL = {"credits": -1, "running": False, "done": 0, "target": 0, "last_msg": "",
         "cost": 0, "items_before": None, "items_after": None, "open": False, "vip": -1, "t": 0}
WHEEL_COST_DEFAULT = 9000   # coste medido ~90 créditos/giro × 100 = ~9000/tanda (se reaprende en runtime)

def _stam_item_total():
    """Valor total de stamina embotellada en el bag (Σ num×gain). La rueda añade ITEMS de stamina, no stamina directa."""
    return sum(int(i.get("num", 0) or 0) * int(i.get("gain", 0) or 0) for i in ACCOUNT.get("stamina_items", []))

def _wheel_spin_run(n):
    """Gira la rueda n tandas de 100. REQUISITO: la ventana de la rueda debe estar ABIERTA en el juego (se verifica con
    wheel_status). NO toca el vip -> respeta el General Blessing del usuario (vip=11). El agente por tanda:
    check_rotary -> play_wheel{bet:100} -> collect_wheel_data -> GetRouletteCredits -> re-scan (stamina items al bag).
    Aprende el coste real por tanda (~9000) y PARA si no quedan créditos (NUNCA compra con gemas)."""
    if WHEEL["running"]: return
    n = max(1, min(int(n or 1), 50))           # tope de seguridad: máx 50 tandas (5000 giros)
    WHEEL.update({"running": True, "done": 0, "target": n, "last_msg": "spinning…",
                  "items_before": _stam_item_total(), "items_after": None, "t": int(time.time() * 1000)})
    logmsg(f"WHEEL: starting {n}×100 spins (Wheel of Fortune)")
    try:
        # REQUISITO (verificado): la ventana de la rueda debe estar ABIERTA en el juego — desde el mapa el server rechaza
        # el bet ("Failed to bet") pase lo que pase. Comprobamos y avisamos en vez de spamear fallos.
        post_agent({"type": "ctl", "cmd": "wheel_status"})
        time.sleep(1.8)
        if not WHEEL.get("open"):
            WHEEL["last_msg"] = "⚠ open the Wheel of Fortune in-game first, then press Spin"
            logmsg("WHEEL: ruleta NO abierta en el juego -> abortado (abre la rueda y reintenta)")
            return
        cost = int(WHEEL.get("cost") or 0) or WHEEL_COST_DEFAULT   # coste/tanda (se reaprende tras la 1ª)
        completed = True
        for i in range(n):
            c = WHEEL.get("credits", -1)
            if 0 <= c < cost:                                 # sin créditos para otra tanda -> parar (no compra con gemas)
                WHEEL["last_msg"] = f"stopped: not enough credits ({c} < ~{cost}/batch) after {WHEEL['done']}×100"
                logmsg(f"WHEEL: not enough credits ({c} < ~{cost}); stopped after {WHEEL['done']} batches")
                completed = False; break
            before = c
            post_agent({"type": "ctl", "cmd": "wheel_spin", "bet": 100})
            WHEEL["done"] = i + 1
            WHEEL["last_msg"] = f"spinning… {WHEEL['done']}/{n}" + (f" · credits {c}" if c >= 0 else "")
            time.sleep(4.2)                                    # check_rotary(0.5)+play(1.4)+collect+credits+scan(1.5)+margen
            now_c = WHEEL.get("credits", -1)                   # el agente refresca créditos tras la tanda
            if before >= 0 and 0 <= now_c < before:
                cost = before - now_c; WHEEL["cost"] = cost    # aprende el coste real por tanda
        if completed:
            WHEEL["last_msg"] = f"done: {WHEEL['done']}×100 spins"
            logmsg(f"WHEEL: completed {WHEEL['done']} batches")
        WHEEL["items_after"] = _stam_item_total()
    except Exception as e:
        WHEEL["last_msg"] = f"error: {e}"; logmsg(f"WHEEL run err: {e}")
    finally:
        WHEEL["running"] = False; WHEEL["t"] = int(time.time() * 1000)

def _maybe_moncfg():
    """Pide al agente la tabla estática de config de monstruos UNA vez (id->nombre/nivel/power). Reintenta cada 60s
    hasta cargarla; es estática, así que una vez basta (se re-pide solo si el backend se reinició -> MONCFG vacío)."""
    if MONCFG: return
    now = time.time()
    if now - _MONCFG["last"] < 60: return
    _MONCFG["last"] = now
    post_agent({"type": "ctl", "cmd": "dump_moncfg"})

_GEN_STATES = {"last": 0.0}
def _maybe_gen_states():
    """Recalcula marchable por general (lo hace el agente con la captura OFF -> sin colgarse).

    ANTI-TORMENTA (2026-08-17, en observación): gen_states hace gc.choose(PD) [fuerza GC + recorre
    el heap] + ~98 invokes en bucle desde el hilo de frida -> el mayor martillo del BOT al GC de
    Unity. Justo tras cada recarga provocaba tormentas de freeze (re-congelar enseguida). Dos frenos
    SEGUROS y reversibles: (1) gracia de 30s tras (re)enganchar -> el burst pesado espera a que el
    juego se estabilice; (2) intervalo 90->150s -> menos martillazos (el estado de servicio de un
    general no cambia tan rápido; los desconocidos se asumen marchables). No cambia QUÉ hace el bot."""
    if not ACCOUNT.get("generals"): return        # espera a que la cuenta cargue
    now = time.time()
    if now - (AGENT.get("attached_at") or 0) < 30: return   # gracia post-recarga (anti-tormenta)
    if now - _GEN_STATES["last"] < 150: return
    _GEN_STATES["last"] = now
    post_agent({"type": "ctl", "cmd": "gen_states"})

def auto_loop():
    _tick = 0; _next_farm = 0
    while True:
        time.sleep(max(2, int(STATE.get("auto_tick", 4) or 4)))  # loop base rápido; el STEAL corre CADA tick -> robar casi inmediato
        _tick += 1
        try:
            if AGENT.get("script"):
                if AGENT.get("connected") == 0:              # JUEGO kicked (login en otro dispositivo): NO tocar el juego -> pausar TODAS las acciones y dejar la sesion libre. El scan del agente (cada 15s) sigue -> detecta cuando vuelve a estar conectado y reanuda solo.
                    continue
                _maybe_reports()                             # CADA tick (throttle interno ~8s) -> captura el veredicto REAL rápido = menos "done"/"pending"
                _share_cycle()                               # comparte al chat de alianza los rallies lanzados por el bot (cuando aparece su mass_id)
                if _tick % 3 == 0:                           # pesadas / NO urgentes cada ~3 ticks (~12s, como antes)
                    _maybe_moncfg()                          # tabla estática de monstruos (una vez) -> resuelve id/nivel/power en steals
                    _maybe_gen_states()                      # refresca marchable de generales (servicio de edificio)
                    _auto_refill_cycle()                     # auto-refill stamina (independiente del AUTO global)
                    _mail_claim_cycle()                      # cobra reward mails cada 24h (stamina/items de eventos)
                    _auto_heal_cycle()                       # auto-heal heridos (recursos+tiempo, sin gemas; independiente del AUTO global)
                    _auto_truce_cycle()                      # auto-truce: renueva la burbuja antes de que caiga (independiente del AUTO global)
                    _rally_card_cycle()                      # RALLY MONTHLY CARD: refresca el estado del auto-join (throttle interno 30s)
                if STATE.get("auto"):                        # AUTO GLOBAL
                    _auto_steal_cycle()                      # CADA tick (~4s) -> robar casi inmediato, SIN jitter
                    if _tick >= _next_farm:                  # farm + join con cadencia JITTER (humano) o fija
                        _auto_fire_cycle()                   # presets solo + rally (farm de monstruos marcados)
                        _auto_join_cycle()                   # presets autojoin (unirse a rallies de alianza)
                        _next_farm = _tick + (random.randint(2, 5) if STATE.get("farm_jitter", True) else 3)
        except Exception as e:
            logmsg(f"auto_loop err: {e}")

# ---------------------------------------------------------------- HTTP
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)
        if "html" in ctype:                                    # el documento de la UI NUNCA se cachea (evita ver una version vieja tras un deploy)
            self.send_header("Cache-Control", "no-cache, no-store, must-revalidate")
        self.send_header("Content-Length", str(len(b))); self.end_headers()
        try: self.wfile.write(b)
        except Exception: pass

    def _authed(self):
        if not AUTH_PASS: return True                  # auth deshabilitada (sin auth.json = uso local)
        for c in self.headers.get("Cookie", "").split(";"):
            c = c.strip()
            if c.startswith("botsession="):
                return hmac.compare_digest(c[len("botsession="):], _SESSION_TOKEN)
        return False

    def do_GET(self):
        u = urlparse(self.path)
        if u.path == "/login":
            return self._send(200, LOGIN_HTML.replace("__ERR__", ""), "text/html; charset=utf-8")
        if not self._authed():
            if u.path.startswith("/api/"):
                return self._send(401, json.dumps({"ok": False, "error": "unauthorized"}))
            self.send_response(302); self.send_header("Location", "/login")
            self.send_header("Content-Length", "0"); self.end_headers(); return
        if u.path == "/" or u.path == "/index.html":
            return self._send(200, UI_HTML.replace("__BVARIANT__", _bot_variant()), "text/html; charset=utf-8")
        if u.path in ("/readme", "/readme.html"):
            return self._send(200, README_HTML, "text/html; charset=utf-8")
        if u.path == "/api/quick":
            # SHORTCUTS: familias × nivel con conteo live on-map (cache 15s en el store). Solo lectura.
            try:
                return self._send(200, json.dumps({"families": scanner_store.quick_families(),
                                                    "server": scanner_store.STATE.get("server", 0)}))
            except Exception as e:
                return self._send(200, json.dumps({"error": str(e), "families": []}))
        if u.path == "/api/quick_list":
            # SHORTCUTS: los N monstruos on-map más cercanos de una familia (fam, coma-separable
            # para EventBoss) y nivel (lv>0), por distancia a la ciudad del bot.
            try:
                q = parse_qs(u.query or "")
                fams = [f.strip() for f in (q.get("fam", [""])[0] or "").split(",") if f.strip()]
                lv = int(q.get("lv", ["0"])[0] or 0)
                limit = int(q.get("limit", ["40"])[0] or 40)
                minp = float(q.get("min", ["0"])[0] or 0)
                c = STATE["bot_city"]
                res = scanner_store.quick_list(fams, lv=lv, limit=limit,
                                               city=(int(c.get("x", 0) or 0), int(c.get("y", 0) or 0)),
                                               min_power_m=minp)
                res["city"] = c
                return self._send(200, json.dumps(res))
            except Exception as e:
                return self._send(200, json.dumps({"error": str(e), "shown": [], "total": 0}))
        if u.path == "/api/monsters":
            try:
                mons = fetch_monsters()
                agg = {}
                for m in mons:
                    k = f"{m['name']}|{m['level']}"
                    a = agg.setdefault(k, {"name": m["name"], "level": m["level"], "group": m["group"],
                                           "count": 0, "nearest": 1e18, "power": 0})
                    a["count"] += 1
                    a["power"] = max(a["power"], float(m.get("power", 0) or 0))
                    d = dist_from_city(m["x"], m["y"])
                    if d and d < a["nearest"]: a["nearest"] = d
                # CATÁLOGO COMPLETO: añade TODOS los monstruos del juego (MONCFG) que NO estén en el mapa,
                # para poder marcarlos aunque no existan ahora (count vacío). El auto-fire solo ataca los que
                # SÍ están en el mapa + marcados, así que esto es solo para la selección en la UI.
                name_group = {a["name"]: a["group"] for a in agg.values() if a.get("group")}   # grupo por nombre (de los del mapa)
                for c in MONCFG.values():
                    nm = (c.get("name") or "").strip()
                    if not nm: continue
                    lv = int(c.get("level", 0) or 0); k = f"{nm}|{lv}"
                    if k in agg: continue
                    agg[k] = {"name": nm, "level": lv, "group": name_group.get(nm, ""),
                              "count": 0, "nearest": 1e18, "power": float(c.get("power", 0) or 0)}
                rows = []
                for k, a in agg.items():
                    a["key"] = k
                    a["nearest"] = None if a["nearest"] >= 1e17 else round(a["nearest"])
                    sel = SELECTION.get(k)
                    a["on"] = bool(sel and sel.get("on"))
                    a["mode"] = (sel or {}).get("mode", "solo")
                    rows.append(a)
                rows.sort(key=lambda r: (-r["count"], r["name"], r["level"]))
                return self._send(200, json.dumps({"rows": rows, "city": STATE["bot_city"]}))
            except Exception as e:
                return self._send(200, json.dumps({"error": str(e), "rows": []}))
        if u.path == "/api/claim_mails":     # último resultado del cobro (lo consulta el botón)
            _t = _MAIL_LAST.get("ts", 0) or 0
            return self._send(200, json.dumps({
                "ts": _t, "found": int(_MAIL_LAST.get("found", 0) or 0),
                "age": (int(time.time() - _t) if _t else None)}))
        if u.path == "/api/attacks":
            # APP HÍBRIDA: ataques activos crudos (sin el filtrado de robabilidad que
            # aplica /api/steals). Los produce el propio barrido, no un escáner externo.
            # ?raw=1 devuelve TODAS las marchas sin filtrar (relocations, refuerzos y las
            # propias). Sirve para comprobar que el filtrado no se está comiendo ataques.
            _raw = (parse_qs(u.query or "").get("raw", ["0"])[0] in ("1", "true", "yes"))
            try:
                if _raw:
                    atks, ally = scanner_store.attacks(own_uid=0, own_gid=0, include_helps=True, skip_relocations=False), ""
                else:
                    atks, ally = fetch_attacks()
            except Exception as e:
                return self._send(200, json.dumps({"ok": False, "err": str(e), "attacks": []}))
            return self._send(200, json.dumps({"ok": True, "ally_tag": ally, "n": len(atks),
                                               "attacks": atks[:300], **scanner_store.marches_status()}))
        if u.path == "/api/scanner":
            # APP HÍBRIDA: estado del barrido propio (progreso de vuelta, objetos en el
            # almacén, configs cargadas) + salud del cliente medida en el main thread de
            # Unity: es la telemetría que dice si barrer y marchar se están pisando.
            st = scanner_store.status()
            st.update(scanner_store.marches_status())
            try: st["attacks"] = len(fetch_attacks()[0])
            except Exception: st["attacks"] = 0
            sw = st.get("sweep") or {}
            order = int(sw.get("order") or 0); idx = int(sw.get("idx") or 0)
            sent = int(sw.get("sent") or 0); rep = int(sw.get("replies") or 0)
            mt = st.get("mt") or {}
            st["progress_pct"] = round(100.0 * idx / order, 1) if order else 0.0
            st["reply_ratio"] = round(rep / sent, 3) if sent else 0.0
            st["freezes"] = int(mt.get("freezes") or 0)
            st["gap_max_ms"] = int(mt.get("max") or 0)
            st["gap_avg_ms"] = int(mt.get("avg") or 0)
            return self._send(200, json.dumps(st))
        if u.path == "/api/status":
            return self._send(200, json.dumps({
                "agent": {"attached": bool(AGENT.get("script")), "ready": AGENT.get("ready"),
                          "last_hb_age": round(time.time() - AGENT["last_hb"], 1) if AGENT["last_hb"] else None,
                          "server": AGENT.get("server"), "connected": AGENT.get("connected"), "stopped_kick": AGENT.get("stopped_kick")},
                "templates": {k: {"march_type": v.get("march_type"), "target_type": v.get("target_type"),
                                  "troops": v.get("troops")} for k, v in TEMPLATES.items()},
                "pending_capture": PENDING_CAPTURE.get("label"),
                "account": {"name": ACCOUNT.get("name"), "power": ACCOUNT.get("power"), "city_x": ACCOUNT.get("city_x"), "city_y": ACCOUNT.get("city_y"),
                            "free_slots": ACCOUNT.get("free_slots"), "active_marches": ACCOUNT.get("active_marches"),
                            "generals": ACCOUNT.get("generals"), "max_monster_level": ACCOUNT.get("max_monster_level"),
                            "march_targets": ACCOUNT.get("march_targets", []),
                            "stamina": ACCOUNT.get("stamina"), "stamina_max": ACCOUNT.get("stamina_max"),
                            "stamina_full_in": ACCOUNT.get("stamina_full_in"), "stamina_items": ACCOUNT.get("stamina_items", []),
                            "peace_shield_left": ACCOUNT.get("peace_shield_left"), "truce_items": ACCOUNT.get("truce_items", []),
                            "guild_wars": ACCOUNT.get("guild_wars", []), "my_guild": ACCOUNT.get("my_guild"),
                            "wounded": ACCOUNT.get("wounded"), "healing": ACCOUNT.get("healing"),
                            "hosp_cap": ACCOUNT.get("hosp_cap"), "heal_left": ACCOUNT.get("heal_left"),
                            "troops": sorted(ACCOUNT.get("troops", []), key=lambda t: -(t.get("power") or 0)), "buffs": ACCOUNT.get("buffs")},
                "state": STATE, "fires": FIRES[-20:], "log": LOGBUF[-60:], "wheel": WHEEL,
                "heal": {"on": STATE.get("heal"), "threshold": STATE.get("heal_threshold"), "last_msg": HEAL.get("last_msg"),
                         "wounded": ACCOUNT.get("wounded"), "healing": ACCOUNT.get("healing"),
                         "hosp_cap": ACCOUNT.get("hosp_cap"), "heal_left": ACCOUNT.get("heal_left")},
                "rally_card": {"supported": RALLY_CARD.get("supported"), "active": bool(RALLY_CARD.get("active")),
                               "expire": int(RALLY_CARD.get("expire", 0) or 0), "secs_left": int(RALLY_CARD.get("secs_left", 0) or 0),
                               "owned": RALLY_CARD.get("owned"), "card_expire": int(RALLY_CARD.get("card_expire", 0) or 0),
                               "card_secs_left": int(RALLY_CARD.get("card_secs_left", 0) or 0),
                               "age": (round(time.time() - RALLY_CARD["ts"], 1) if RALLY_CARD.get("ts") else None)},
                "msize": {"boost": bool(STATE.get("msize_boost", True)),   # AUTO-BOOST March Size: estado para el indicador por-preset
                          "eta": int(((ACCOUNT.get("buffs") or {}).get("march_size") or {}).get("eta", 0) or 0),
                          "mult": round(_msize_mult(), 3), "base_known": (STATE.get("msize_bonus_base") is not None),
                          "failed": len(_BOOST_FAILED),
                          "troops": {str(i): sum(t["n"] for t in _eff_troops(p))   # tropas EFECTIVAS (ya boosteadas) que marcharían por preset
                                     for i, p in enumerate(PRESETS) if p.get("enabled") and p.get("mode") in ("solo", "rally", "steal")}},
                "preset_marches": _preset_march_view(time.time()),
                "preset_avail": _preset_availability(),
                "overlimit": _overlimit_keys(),   # claves mode|name|level con pérdidas recientes -> pill parpadea "al límite"
                "wiped": {k: {"n": v[0], "ts": int(v[1])} for k, v in _wiped_keys().items()},   # targets BLOQUEADOS por aniquilación
            }))
        if u.path == "/api/account":
            return self._send(200, json.dumps(ACCOUNT))
        if u.path == "/api/steals":
            targets, err = _steal_targets()
            return self._send(200, json.dumps({"ok": not err, "error": err, "targets": targets[:200],
                                                "target_tags": STATE.get("target_tags", ""), "steal": STATE.get("steal", False),
                                                "feasible": sum(1 for t in targets if t.get("feasible")),
                                                "steal_exclude": STATE.get("steal_exclude", ""),
                                                "steal_enemy_buffer": STATE.get("steal_enemy_buffer", 90), "steal_combat_margin": STATE.get("steal_combat_margin", 25), "steal_win_margin": STATE.get("steal_win_margin", 0),
                                                "win_ratio": STATE.get("win_ratio", 0.8),
                                                "steal_min_power": STATE.get("steal_min_power", 0)}))
        if u.path == "/api/presets":
            return self._send(200, json.dumps({"presets": PRESETS}))
        if u.path == "/api/queue":
            try: _qi = int(parse_qs(u.query).get("preset", ["-1"])[0])
            except Exception: _qi = -1
            return self._send(200, json.dumps({"queue": _preset_queue(_qi)}))
        if u.path == "/api/history":
            for _e in HISTORY[-1000:]:                                       # BACKFILL del nombre del monstruo en joins: al dispararse
                if _e.get("kind") != "join" or _e.get("name"): continue      # pudo no estar el tile en el escáner (quedó null); si
                _tx = int(_e.get("tx", 0) or 0); _ty = int(_e.get("ty", 0) or 0)  # ahora sí lo conocemos, se rellena el registro
                _mm = (_MONS_XY_ALL.get((_tx, _ty)) or _MONS_XY.get((_tx, _ty))) if (_tx and _ty) else None
                if _mm and _mm.get("name"):
                    _e["name"] = _mm.get("name"); _e["level"] = _e.get("level") or int(_mm.get("level", 0) or 0)
                    if _mm.get("power") and not _e.get("power"): _e["power"] = int(_mm.get("power") or 0)
                if not _e.get("name"):                                        # y si el rally traía nivel/poder, por MONCFG
                    _cc = _mon_by_lvpow(_e.get("level"), _e.get("power"))
                    if _cc and _cc.get("name"): _e["name"] = _cc["name"]
            evs = sorted(HISTORY[-1000:], key=lambda e: e.get("ts", 0), reverse=True)   # más recientes primero (por ts; muestra hasta 1000 de los 1500 guardados)
            return self._send(200, json.dumps({"events": evs}))
        return self._send(404, "{}")

    def do_POST(self):
        u = urlparse(self.path)
        ln = int(self.headers.get("Content-Length", 0) or 0)
        raw = self.rfile.read(ln) if ln else b""
        if u.path == "/login":
            q = parse_qs(raw.decode("utf-8", "ignore"))
            if AUTH_PASS and q.get("user", [""])[0] == AUTH_USER and q.get("pass", [""])[0] == AUTH_PASS:
                self.send_response(302); self.send_header("Location", "/")
                self.send_header("Set-Cookie", f"botsession={_SESSION_TOKEN}; HttpOnly; Path=/; Max-Age=2592000; SameSite=Lax")
                self.send_header("Content-Length", "0"); self.end_headers(); return
            return self._send(200, LOGIN_HTML.replace("__ERR__", "<div class=err>Credenciales incorrectas</div>"), "text/html; charset=utf-8")
        if not self._authed(): return self._send(401, json.dumps({"ok": False, "error": "unauthorized"}))
        body = {}
        if raw:
            try: body = json.loads(raw.decode())
            except Exception: body = {}
        if u.path == "/api/select":
            if body.get("clear"):                       # vaciar TODAS las selecciones (empezar de cero)
                with LOCK: SELECTION.clear()
                save_state(); logmsg("selection cleared (clear all)")
                return self._send(200, json.dumps({"ok": True, "cleared": True}))
            # body: {name, level, on, mode}
            k = f"{body.get('name')}|{int(body.get('level',0))}"
            with LOCK:
                SELECTION[k] = {"name": body.get("name"), "level": int(body.get("level", 0)),
                                "on": bool(body.get("on")), "mode": body.get("mode", "solo")}
            save_state()
            return self._send(200, json.dumps({"ok": True}))
        if u.path == "/api/config":
            with LOCK:
                if "city_x" in body: STATE["bot_city"]["x"] = int(body.get("city_x") or 0)
                if "city_y" in body: STATE["bot_city"]["y"] = int(body.get("city_y") or 0)
                if "max_slots" in body: STATE["max_slots"] = max(1, int(body.get("max_slots") or 1))
                if "server_id" in body: STATE["server_id"] = int(body.get("server_id") or 1939)
                if "margin" in body: STATE["margin"] = max(1.0, float(body.get("margin") or 1.2))
                if "win_ratio" in body: STATE["win_ratio"] = max(0.1, float(body.get("win_ratio") or 0.8))
                if "steal_min_power" in body:
                    try: STATE["steal_min_power"] = max(0, float(body.get("steal_min_power") or 0))
                    except Exception: pass
                if "cap" in body: STATE["cap"] = max(1, int(body.get("cap") or 500000))
                if "target_tags" in body: STATE["target_tags"] = (body.get("target_tags") or "").strip()
                if "steal_sec_per_tile" in body: STATE["steal_sec_per_tile"] = max(0.1, float(body.get("steal_sec_per_tile") or 1.0))
                if "steal_exclude" in body: STATE["steal_exclude"] = (body.get("steal_exclude") or "").strip()
                if "steal_enemy_buffer" in body: STATE["steal_enemy_buffer"] = max(0, int(body.get("steal_enemy_buffer") or 0))
                if "steal_combat_margin" in body: STATE["steal_combat_margin"] = max(0, int(body.get("steal_combat_margin") or 0))
                if "steal_win_margin" in body: STATE["steal_win_margin"] = max(0, int(body.get("steal_win_margin") or 0))
                # ── EXPERT MODE (rangos de seguridad, GLOBAL en STATE) ──
                if "cooldown" in body: STATE["cooldown"] = max(60, min(1800, int(body.get("cooldown") or 360)))
                if "farm_seen_max" in body: STATE["farm_seen_max"] = max(30, min(600, int(body.get("farm_seen_max") or 120)))
                if "rally_seen_max" in body: STATE["rally_seen_max"] = max(120, min(1800, int(body.get("rally_seen_max") or 600)))
                if "wipe_strikes" in body: STATE["wipe_strikes"] = max(1, min(5, int(body.get("wipe_strikes") or 2)))
                if "wipe_frac" in body: STATE["wipe_frac"] = max(0.5, min(1.0, float(body.get("wipe_frac") or 90) / 100.0))   # UI manda % (50-100) -> fracción
                if "wipe_window_h" in body: STATE["wipe_window_h"] = max(1, min(24, int(body.get("wipe_window_h") or 6)))
                if "farm_jitter" in body: STATE["farm_jitter"] = bool(body.get("farm_jitter"))
                if "expert_targets_open" in body: STATE["expert_targets_open"] = bool(body.get("expert_targets_open"))
                if "expert_steals_open" in body: STATE["expert_steals_open"] = bool(body.get("expert_steals_open"))
                if body.get("reset_expert") == "targets":
                    STATE.update({"margin": 1.2, "win_ratio": 0.8, "max_slots": 6, "cooldown": 360, "farm_seen_max": 120, "rally_seen_max": 600, "wipe_strikes": 2, "wipe_frac": 0.9, "wipe_window_h": 6, "farm_jitter": True})
                if body.get("reset_expert") == "steals":
                    STATE.update({"steal_sec_per_tile": 1.5, "steal_enemy_buffer": 90, "win_ratio": 0.8, "steal_min_power": 0, "steal_combat_margin": 25, "steal_win_margin": 0})
                if "target_fps" in body:      # límite de frames del juego: se aplica EN CALIENTE (no hace falta reattach)
                    STATE["target_fps"] = max(0, min(60, int(body.get("target_fps") or 0)))
                    if AGENT.get("session"): _apply_fps(AGENT["session"], STATE["target_fps"])
                if "share_rally" in body: STATE["share_rally"] = bool(body.get("share_rally"))    # ON/OFF compartir rallies del bot al chat de alianza
                if "chat_session_id" in body: STATE["chat_session_id"] = int(body.get("chat_session_id") or 0)   # sembrar/forzar el session_id del chat (normalmente lo aprende el hook con 1 share manual)
            save_state()
            return self._send(200, json.dumps({"ok": True, "state": STATE}))
        if u.path == "/api/capture":
            # body: {label:"solo"|"rally", sticky:bool}  (sticky: NO auto-desarmar tras el rally-create -> observar el share u otros mensajes posteriores)
            label = body.get("label")
            if label not in ("solo", "rally"):
                return self._send(200, json.dumps({"ok": False, "error": "label inválido"}))
            PENDING_CAPTURE["label"] = label
            PENDING_CAPTURE["sticky"] = bool(body.get("sticky"))
            post_agent({"type": "ctl", "cmd": "capture_on"})
            logmsg(f"CAPTURE armed for '{label}'{' (STICKY)' if PENDING_CAPTURE['sticky'] else ''}: do 1 manual {label} attack on the bot account")
            return self._send(200, json.dumps({"ok": True}))
        if u.path == "/api/claim_mails":     # botón "Get Stamina": cobra YA los reward mails
            # Mismo camino que el automático (stamina baja / periódico), disparado a mano.
            # La respuesta del agente llega asíncrona -> la UI consulta luego con GET.
            ok = _claim_reward_mails("manual (botón Get Stamina)")
            return self._send(200, json.dumps({"ok": bool(ok)}))
        if u.path == "/api/share_intel":     # DEV: dispara el diagnóstico del share en el agente (layouts + session_id + métodos)
            post_agent({"type": "ctl", "cmd": "dump_share_intel"})
            logmsg("dump_share_intel triggered")
            return self._send(200, json.dumps({"ok": True}))
        if u.path == "/api/test_share":      # DEV: prueba manual del share (manda share_rally con params del body)
            post_agent({"type": "ctl", "cmd": "share_rally",
                        "wx": int(body.get("wx", 0) or 0), "wy": int(body.get("wy", 0) or 0),
                        "mass_id": int(body.get("mass_id", 0) or 0), "des": str(body.get("des", "") or ""),
                        "format": str(body.get("format", "") or ""), "server_id": int(STATE.get("server_id", 0) or 0),
                        "session_id": int(STATE.get("chat_session_id", 0) or 0), "client_id": str(uuid.uuid4()),
                        "channel": 1, "level": int(body.get("level", 2) or 2)})
            logmsg(f"test_share -> mass_id={body.get('mass_id')} ({body.get('wx')},{body.get('wy')})")
            return self._send(200, json.dumps({"ok": True}))
        if u.path == "/api/presets":
            ps = body.get("presets")
            if isinstance(ps, list):
                with LOCK:
                    PRESETS[:] = [{"enabled": bool(x.get("enabled")), "mode": x.get("mode", "solo"),
                                   "general_id": int(x.get("general_id", 0) or 0),
                                   "assistant_id": int(x.get("assistant_id", 0) or 0),
                                   "dist_prio": bool(x.get("dist_prio", True)),   # prioridad-por-distancia por preset (default ON = comportamiento previo)
                                   "override": bool(x.get("override", False)),   # override de winnability (aislado)
                                   "override_pow": max(0, int(x.get("override_pow", 0) or 0)),   # poder efectivo cuando override=ON. Se CLAMPA justo debajo al máximo permitido (⚔×3)
                                   "targets": [str(k) for k in (x.get("targets") or []) if isinstance(k, str) and "|" in k][:40],
                                   "troops": [{"t": int(t.get("t", 0) or 0), "n": int(t.get("n", 0) or 0)}
                                              for t in (x.get("troops") or []) if int(t.get("t", 0) or 0) > 0 and int(t.get("n", 0) or 0) > 0],
                                   "troops_bak": ([{"t": int(t.get("t", 0) or 0), "n": int(t.get("n", 0) or 0)}
                                                   for t in (x.get("troops_bak") or []) if int(t.get("t", 0) or 0) > 0 and int(t.get("n", 0) or 0) > 0]
                                                  if x.get("mode") == "autojoin" else [])}   # backup del ejército real (solo en AUTO-JOIN) para restaurarlo al cambiar de modo
                                  for x in ps][:6]
                    _ld = STATE.get("lane_disp") or {}
                    for _i, pp in enumerate(PRESETS):
                        _clamp_preset_to_cap(pp)   # no permitir más tropas que el tope de marcha del general (si se conoce)
                        _clamp_override_pow(pp)    # y el override nunca por encima de la recomendación (⚔×3)
                        if pp.get("mode") == "autojoin": _ld.pop(str(_i), None)   # el objetivo recordado ya no aplica (no farmea)
                AUTO_LANES.clear()
                save_state()
            return self._send(200, json.dumps({"ok": True, "presets": PRESETS}))
        if u.path == "/api/apply":
            # only={x,y} -> DISPARO INDIVIDUAL de ese objetivo (botón Attack de cada fila del Preview).
            # Por COORDS y no por índice de fila: el plan se recalcula en cada llamada.
            _only = body.get("only") if isinstance(body, dict) else None
            _oxy = None
            if isinstance(_only, dict) and _only.get("x") is not None and _only.get("y") is not None:
                _oxy = (int(_only["x"]), int(_only["y"]))
            return self._send(200, json.dumps(apply_and_fire(dry=bool(body.get("dry")), only_xy=_oxy)))
        if u.path == "/api/scan":
            post_agent({"type": "ctl", "cmd": "scan"})
            return self._send(200, json.dumps({"ok": True}))
        if u.path == "/api/use_buff":
            post_agent({"type": "ctl", "cmd": "use_buff", "buff_id": int((body or {}).get("buff_id", 0) or 0)})
            return self._send(200, json.dumps({"ok": True}))
        if u.path == "/api/msize_boost":   # toggle del AUTO-BOOST March Size (escala solo/rally/steal mientras el buff esté activo)
            STATE["msize_boost"] = bool((body or {}).get("on"))
            save_state()
            return self._send(200, json.dumps({"ok": True, "on": STATE["msize_boost"]}))
        if u.path == "/api/buy_buff":   # SOLO manual: activar el City Buff "March Speed Increase" comprandolo con GEMAS (buy_from_store, isUse=1). Unico buff que exige gemas (no hay item en el bag). El bot NUNCA lo llama solo.
            iid = int((body or {}).get("item_id", 18777) or 18777)
            amt = int((body or {}).get("amount", 1) or 1)
            tot = int((body or {}).get("total", 0) or 0)
            post_agent({"type": "ctl", "cmd": "buy_buff", "item_id": iid, "amount": amt, "total": tot})
            logmsg(f"BUY_BUFF requested (GEMS): item={iid} x{amt}")
            return self._send(200, json.dumps({"ok": True}))
        if u.path == "/api/history_clear":     # vaciar History: TODO, o sólo un tipo de evento si se pasa "kind"
            k = (body.get("kind") or "").strip()
            with LOCK:
                if k:
                    before = len(HISTORY); HISTORY[:] = [e for e in HISTORY if e.get("kind") != k]; removed = before - len(HISTORY)
                else:
                    removed = len(HISTORY); HISTORY.clear()
            if k:
                _save_hist()                       # re-persiste el resto (sin los del tipo borrado)
            else:
                try:
                    import os as _os
                    if _os.path.exists(HIST_FILE): _os.remove(HIST_FILE)
                except Exception: pass
            return self._send(200, json.dumps({"ok": True, "kind": k or "all", "removed": removed}))
        if u.path == "/api/auto":
            # AUTO GLOBAL: un único flag corre TODOS los modos de preset a la vez (solo/rally/steal/autojoin).
            STATE["auto"] = bool(body.get("on"))
            STATE["steal"] = STATE["auto"]   # legacy: mantener sincronizado (steal ya no es un toggle aparte)
            # NO limpiar AUTO_LANES/AUTO_FIRED al activar: respeta marchas ya lanzadas (manual o auto)
            # para no duplicar presets ni re-pegar al mismo objetivo recién atacado.
            if STATE["auto"]:
                # EXCLUYENTE: al activar el AUTO global se apaga el auto-join de la Rally Monthly Card.
                post_agent({"type": "ctl", "cmd": "rally_card_set", "on": False})
                RALLY_CARD["active"] = False; RALLY_CARD["secs_left"] = 0; RALLY_CARD["expire"] = 0; RALLY_CARD["ts"] = time.time()
            save_state()
            logmsg(f"AUTO (global) {'ON' if STATE['auto'] else 'OFF'}" + (" · Rally Card OFF" if STATE["auto"] else ""))
            return self._send(200, json.dumps({"ok": True, "auto": STATE["auto"]}))
        if u.path == "/api/max_send":     # march cap (max_send) of a general: set by hand, reset (re-learn) or reset_all.
            with LOCK:
                ms = STATE.setdefault("max_send", {}); mc = STATE.setdefault("max_send_conf", {})
                if body.get("reset_all"):
                    ms.clear(); mc.clear()
                else:
                    gid = int(body.get("gid", 0) or 0); cap = int(body.get("cap", 0) or 0)
                    if gid > 0:
                        if cap > 0: ms[str(gid)] = cap; mc[str(gid)] = True      # set by hand = confirmed
                        else: ms.pop(str(gid), None); mc.pop(str(gid), None)      # reset -> re-learn
            save_state()
            return self._send(200, json.dumps({"ok": True, "max_send": STATE.get("max_send", {}), "max_send_conf": STATE.get("max_send_conf", {})}))
        if u.path == "/api/reset_blocks":     # limpia los bloqueos de AUTO-PROTECCION: _wiped_keys() ignora los wipes anteriores a este ts
            STATE["wipe_cleared_ts"] = time.time()
            save_state()
            logmsg("AUTO-PROTECTION blocks RESET (manual)")
            return self._send(200, json.dumps({"ok": True}))
        if u.path == "/api/steal":
            STATE["steal"] = bool(body.get("on"))
            if STATE["steal"]: STATE["auto"] = False     # robos pausan el farm (excluyentes)
            save_state()
            logmsg(f"RALLY-STEALS {'ON' if STATE['steal'] else 'OFF'}")
            return self._send(200, json.dumps({"ok": True, "steal": STATE["steal"], "auto": STATE["auto"]}))
        if u.path == "/api/steal_one":     # test manual: dispara UN robo (preset STEAL) sobre el objetivo elegido
            tx = int(body.get("tx", 0) or 0); ty = int(body.get("ty", 0) or 0)
            cid = int(body.get("id", 0) or 0); lvl = int(body.get("level", 0) or 0)
            if not (tx and ty and cid):
                return self._send(200, json.dumps({"ok": False, "error": "target sin id de config (no robable)"}))
            _nm = ((MONCFG.get(cid) or {}).get("name", "") or body.get("name", "") or "")   # resuelve nombre por id (MONCFG)
            if any(e in _nm.lower() for e in STEAL_HARD_EXCLUDE):                            # HARDCODE: ni manual (solo lo mata la alianza enemiga)
                return self._send(200, json.dumps({"ok": False, "error": f"'{_nm or cid}' en la lista HARDCODE de no-robar (Royal Thief: solo lo mata la alianza enemiga)"}))
            busy = {int(m.get("gid", 0) or 0) for m in ACCOUNT.get("march_targets", [])}
            p = next((pp for pp in PRESETS if pp.get("enabled") and pp.get("troops")
                      and pp.get("mode") == "steal" and int(pp.get("general_id", 0) or 0) not in busy), None)
            if not p:
                return self._send(200, json.dumps({"ok": False, "error": "no hay preset en modo STEAL con general libre"}))
            pseudo = {"x": tx, "y": ty, "id": cid, "level": lvl, "name": body.get("name", ""),
                      "group": body.get("group", ""), "power": body.get("power", 0)}
            t = _make_target({**p, "mode": "solo"}, pseudo)   # robo = SOLO (rápido), llega antes que el rally
            if not t or not t.get("has_tpl"):
                return self._send(200, json.dumps({"ok": False, "error": "no se pudo construir la marcha (¿preset sin tropas?)"}))
            payload = {k: t[k] for k in FIRE_KEYS}
            ok = post_agent({"type": "ctl", "cmd": "fire", "targets": [payload]})
            if ok:
                STEAL_FIRED[(tx, ty)] = {"ts": time.time(), "name": pseudo["name"] or "", "id": cid, "level": lvl,
                                         "group": pseudo.get("group", ""), "power": pseudo.get("power", 0)}   # -> "Stealing…" en la lista (con campos para no perder columnas)
                STEAL_SEEN[(tx, ty)] = time.time()   # robado -> no re-robar este tile (el monstruo morirá)
            logmsg(f"STEAL TEST -> @{tx},{ty} subtype={payload.get('subtype')} level={payload.get('level')} type={payload.get('march_type')} troops={payload.get('troops')}")
            return self._send(200, json.dumps({"ok": bool(ok), "x": tx, "y": ty, "subtype": cid, "level": payload.get("level")}))
        if u.path == "/api/refill":
            if "on" in body: STATE["refill"] = bool(body.get("on"))
            if body.get("threshold") is not None:
                try: STATE["refill_threshold"] = max(0, int(body.get("threshold")))
                except Exception: pass
            if body.get("target") is not None:
                try: STATE["refill_target"] = max(0, int(body.get("target")))
                except Exception: pass
            save_state()
            logmsg(f"AUTO-REFILL {'ON' if STATE['refill'] else 'OFF'} (threshold {STATE['refill_threshold']}, target {STATE['refill_target']})")
            return self._send(200, json.dumps({"ok": True, "refill": STATE["refill"], "threshold": STATE["refill_threshold"], "target": STATE["refill_target"]}))
        if u.path == "/api/refill_now":     # recarga manual inmediata hasta refill_target (ignora umbral)
            return self._send(200, json.dumps(_refill_to_target(force=True)))
        if u.path == "/api/heal":           # AUTO-HEAL on/off + umbral mínimo de heridos
            if "on" in body: STATE["heal"] = bool(body.get("on"))
            if body.get("threshold") is not None:
                try: STATE["heal_threshold"] = max(1, int(body.get("threshold")))
                except Exception: pass
            save_state()
            logmsg(f"AUTO-HEAL {'ON' if STATE['heal'] else 'OFF'} (threshold {STATE['heal_threshold']} heridos)")
            return self._send(200, json.dumps({"ok": True, "heal": STATE["heal"], "threshold": STATE["heal_threshold"]}))
        if u.path == "/api/rally_card":      # RALLY MONTHLY CARD: activar/desactivar el auto-join de la tarjeta (excluyente con AUTO global)
            on = bool(body.get("on"))
            post_agent({"type": "ctl", "cmd": "rally_card_set", "on": on})
            if on:
                # EXCLUYENTE: activar la tarjeta apaga el AUTO global. Optimista: reflejar ON (~8h) ya; el agente confirma en ~1.4s.
                STATE["auto"] = False; STATE["steal"] = False; save_state()
                RALLY_CARD.update({"active": True, "supported": True, "expire": int(time.time()) + 8 * 3600, "secs_left": 8 * 3600, "ts": time.time()})
            else:
                RALLY_CARD.update({"active": False, "expire": 0, "secs_left": 0, "ts": time.time()})
            logmsg(f"RALLY CARD auto-join -> {'ON' if on else 'OFF'}" + (" · AUTO global OFF" if on else ""))
            return self._send(200, json.dumps({"ok": True, "on": on, "auto": STATE["auto"]}))
        if u.path == "/api/heal_now":        # cura manual inmediata de TODOS los heridos (recursos+tiempo, sin gemas)
            return self._send(200, json.dumps(_heal_now(cancel_first=bool(body.get("cancel_first")))))
        if u.path == "/api/truce":           # AUTO-TRUCE on/off + horas-antes-de-caer
            if "on" in body: STATE["auto_truce"] = bool(body.get("on"))
            if body.get("renew_h") is not None:
                try: STATE["truce_renew_h"] = max(0.0, float(body.get("renew_h")))
                except Exception: pass
            save_state()
            logmsg(f"AUTO-TRUCE {'ON' if STATE['auto_truce'] else 'OFF'} (renew when < {STATE['truce_renew_h']}h of shield left)")
            return self._send(200, json.dumps({"ok": True, "auto_truce": STATE["auto_truce"], "renew_h": STATE["truce_renew_h"]}))
        if u.path == "/api/truce_now":       # renovación manual inmediata de la burbuja (ignora el umbral de horas)
            return self._send(200, json.dumps(_truce_now(force=True)))
        if u.path in ("/api/rally_blitz", "/api/rally_cancel"):
            # SOLO MANUAL, nunca automático. blitz = "Attack Now" del juego: lanza el rally sin esperar la reunión y
            # CUESTA GEMAS (2ª y última excepción autorizada a "nunca gemas", como el City Buff de March Speed).
            # cancel = "Cancel Alliance War": disuelve el rally del que somos líderes (no es quit_union_war).
            blitz = (u.path == "/api/rally_cancel") is False
            idx = int((body or {}).get("preset", -1))
            wid = int((body or {}).get("war_id", 0) or 0)
            err = ""
            if wid <= 0: wid, err = _my_rally_war_id(idx)
            if wid <= 0: return self._send(200, json.dumps({"ok": False, "error": err or "sin war_id"}))
            if blitz:
                cost = max(0, int((body or {}).get("cost", 1000) or 1000))
                post_agent({"type": "ctl", "cmd": "blitz_rally", "war_id": wid, "cost": cost})
                _eta = _rally_eta(idx)
                BLITZ_AT[idx] = {"ts": time.time(), "eta": _eta, "war_id": wid}   # puente para la barra hasta que el juego refleje "en camino"
                hist("blitz", war_id=wid, preset=(idx + 1 if idx >= 0 else None), cost=cost, result="sent",
                     note=f"rally lanzado YA (Attack Now) · {cost:,} gemas · manual")
                logmsg(f"BLITZ (GEMAS, manual) P{idx+1} war_id={wid} cost={cost}")
                return self._send(200, json.dumps({"ok": True, "war_id": wid, "cost": cost, "eta": _eta}))
            post_agent({"type": "ctl", "cmd": "cancel_rally", "war_id": wid})
            hist("cancel_rally", war_id=wid, preset=(idx + 1 if idx >= 0 else None), result="sent",
                 note="rally de alianza cancelado (manual)")
            logmsg(f"CANCEL RALLY (manual) P{idx+1} war_id={wid}")
            return self._send(200, json.dumps({"ok": True, "war_id": wid}))
        if u.path == "/api/reload":
            AGENT["stopped_kick"] = False   # "Reload" reanuda una instancia cerrada por Stop/kick -> el watchdog re-attacha
            try: os.remove(os.path.join(os.path.dirname(os.path.abspath(__file__)), ".bot_stopped"))   # borra el sentinel STOP -> el watchdog vuelve a operar
            except Exception: pass
            try: self._send(200, json.dumps({"ok": True, "msg": "Reload: relaunching game + re-attaching (sweep resumes)…"}))
            except Exception: pass
            here = os.path.dirname(os.path.abspath(__file__))
            logmsg("RELOAD solicitado desde la UI (relanzar juego + re-attach; el backend sigue vivo)")
            try:
                import subprocess
                # full_restart.sh es IDEMPOTENTE: relanza Evony y, sólo si hicieran falta, arranca
                # emulador/frida. NO reinicia el backend (sigue vivo y re-attacha solo). Detached por
                # si algún día el script sí reiniciara el proceso.
                subprocess.Popen(["bash", "-c", f"sleep 1; bash '{here}/full_restart.sh' > /tmp/hibrid_fullrestart.log 2>&1"],
                                 stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, start_new_session=True)
            except Exception as e: logmsg(f"reload err: {e}")
            return
        if u.path == "/api/stop_bot":
            # STOP manual desde la UI: cierra SÓLO Evony (el emulador y frida siguen vivos) y DEJA
            # el backend vivo. Reutiliza el mecanismo del auto-cierre por kick; se recupera con "Reload".
            try: self._send(200, json.dumps({"ok": True, "msg": "Stopping bot — Evony closed, emulator+frida+backend stay alive"}))
            except Exception: pass
            logmsg("STOP BOT solicitado desde la UI (cerrar Evony; emulador/frida/backend siguen vivos)")
            try:
                import threading as _th
                _th.Thread(target=lambda: _stop_instance_kick(manual=True), daemon=True).start()
            except Exception as e: logmsg(f"stop_bot err: {e}")
            return
        if u.path == "/api/reattach":
            AGENT["script"] = None; AGENT["ready"] = False
            return self._send(200, json.dumps({"ok": True}))
        if u.path == "/api/gen_states":     # recalcula marchable por general (estado de servicio); pobla GEN_STATE en el agente
            ok = post_agent({"type": "ctl", "cmd": "gen_states"})
            return self._send(200, json.dumps({"ok": bool(ok)}))
        if u.path == "/api/scanner":        # APP HÍBRIDA: control del barrido del mapa (canal "scan", no "ctl")
            cmd = (body.get("cmd") or "status").strip()
            if cmd not in ("pause", "resume", "status", "cadence", "priority"):
                return self._send(400, json.dumps({"ok": False, "err": "cmd inválido"}))
            msg = {"type": "scan", "cmd": cmd}
            if cmd == "cadence": msg["ms"] = int(body.get("ms") or 350)
            if cmd == "priority": msg["x"] = int(body.get("x") or 0); msg["y"] = int(body.get("y") or 0)
            ok = post_agent(msg)
            return self._send(200, json.dumps({"ok": bool(ok), "sent": msg}))
        if u.path == "/api/wheel_spin":     # gira el Wheel of Fortune n tandas de 100 (en hilo; para si no hay créditos)
            if WHEEL["running"]:
                return self._send(200, json.dumps({"ok": False, "err": "already running"}))
            n = max(1, min(int(body.get("n") or 10), 50))
            threading.Thread(target=_wheel_spin_run, args=(n,), daemon=True).start()
            return self._send(200, json.dumps({"ok": True, "started": n}))
        if u.path == "/api/wheel_credits":  # refresca créditos + estado abierto/cerrado de la rueda
            ok = post_agent({"type": "ctl", "cmd": "wheel_status"})
            return self._send(200, json.dumps({"ok": bool(ok), "credits": WHEEL.get("credits", -1), "open": WHEEL.get("open", False)}))
        return self._send(404, "{}")

# ---------------------------------------------------------------- UI (HTML/JS)
LOGIN_HTML = """<!doctype html><html><head><meta charset=utf-8><meta name=viewport content="width=device-width,initial-scale=1"><title>Bot Pixel - Login</title>
<style>body{background:#0d1117;color:#e6edf3;font:14px/1.4 -apple-system,system-ui,sans-serif;display:flex;min-height:100vh;align-items:center;justify-content:center;margin:0}
form{background:#161b22;border:1px solid #30363d;border-radius:12px;padding:26px;width:280px}
h1{font-size:16px;margin:0 0 16px;text-align:center}
input{width:100%;box-sizing:border-box;background:#0d1117;border:1px solid #30363d;border-radius:7px;padding:10px;color:#e6edf3;margin-bottom:10px;font-size:14px}
button{width:100%;background:#238636;color:#fff;border:0;border-radius:7px;padding:11px;font-size:14px;font-weight:600;cursor:pointer}
.err{color:#f85149;font-size:12px;margin-bottom:10px;text-align:center}</style></head><body>
<form method=post action=/login><h1>&#128274; Bot v.1</h1>__ERR__
<input name=user placeholder="User" autocomplete=username autofocus>
<input name=pass type=password placeholder="Password" autocomplete=current-password>
<button type=submit>Entrar</button></form></body></html>"""
UI_HTML = r"""<!doctype html><html><head><meta charset=utf-8>
<meta name=viewport content="width=device-width,initial-scale=1">
<title>Bot Pixel · __BVARIANT__</title><style>
body{background:#0d1117;color:#e6edf3;font:14px/1.4 -apple-system,system-ui,sans-serif;margin:0;padding:16px}
h1{font-size:18px;margin:0 0 18px} .sub{color:#8b949e;font-size:12px;margin-bottom:14px}
h1 .sub{margin-bottom:0}   /* el header es flex con align-items:center: el margin-bottom heredado de .sub descentraba #astat ~7px hacia arriba y hacia que el boton Reload + el tag LITE (bien centrados) PARECIERAN desalineados */
.subpick{margin-top:22px}  /* separa el texto de 'Pick targets...' del panel de widgets */
.expwrap{margin:4px 0 12px}
.expsw{display:inline-flex;align-items:center;gap:8px;cursor:pointer;font-size:12px;color:#8b949e;user-select:none;font-weight:600;text-transform:uppercase;letter-spacing:.04em}
.expsw .swtk{width:34px;height:18px;border-radius:10px;background:#30363d;position:relative;transition:background .15s;flex:none}
.expsw .swtk::after{content:'';position:absolute;top:2px;left:2px;width:14px;height:14px;border-radius:50%;background:#8b949e;transition:transform .15s,background .15s}
.expsw.on .swtk{background:#1f6feb}
.expsw.on .swtk::after{transform:translateX(16px);background:#fff}
.expbody{margin-top:10px;padding:13px;background:#0d1117;border:1px solid #30363d;border-radius:8px}
.expgrid{display:grid;grid-template-columns:repeat(auto-fill,minmax(170px,1fr));gap:11px 16px}
.exprow{display:flex;flex-direction:column;gap:3px;min-width:0}
.exprow>label{font-size:11px;color:#8b949e;display:flex;align-items:center;gap:5px}
.exprow input[type=number]{width:100%;box-sizing:border-box}
.expfoot{margin-top:13px;display:flex;gap:8px;align-items:center;flex-wrap:wrap}
.expchk{display:flex;align-items:center;gap:7px;font-size:12px;color:#e6edf3}
.expchk input{width:auto}
.card{background:#161b22;border:1px solid #30363d;border-radius:10px;padding:12px;margin-bottom:12px}
/* ── Panel de widgets (estilo CRM): las tarjetas de automatismos (truce/stamina/heal/buffs) en 2 COLUMNAS en desktop.
      Móvil intacto: sin grid-template-columns el grid es de 1 columna, y el `gap` sustituye al margin-bottom de .card. ── */
.wgrid{display:grid;gap:12px;align-items:stretch}   /* stretch = cada tarjeta llena el alto de su fila -> las dos columnas quedan a la MISMA altura (en movil, 1 columna, no tiene efecto: responsive por construccion) */
.wgrid>.wcol{min-width:0}
.wcol{display:flex;flex-direction:column;gap:12px;min-width:0;justify-content:space-between}   /* space-between: el sobrante se reparte ENTRE las tarjetas -> las 2 columnas alinean arriba Y abajo, sin estirar ninguna */
.wcol>.card{margin-bottom:0;min-width:0}
@media (min-width:641px){
  .wgrid{grid-template-columns:1fr 1fr}
  #autoCard{margin-top:0;padding-top:12px}    /* dentro del panel ya no necesita el aire extra (eso era para cuando iba suelto debajo) */
}
#autoCard{padding-top:12px;margin-top:0}   /* AUTO dentro de su columna: el gap de .wcol ya lo separa */
#rcCard{padding-top:9px;padding-bottom:9px}   /* Monthly card: más colapsada (le sobraba alto) */
.row{display:flex;gap:8px;align-items:center;flex-wrap:wrap}
button{background:#238636;color:#fff;border:0;border-radius:7px;padding:7px 12px;cursor:pointer;font-size:13px}
button.sec{background:#21262d;border:1px solid #30363d}
button.warn{background:#9e6a03}
@keyframes savePulse{from{background:#21262d;box-shadow:0 0 0 0 rgba(46,160,67,0)}to{background:#238636;box-shadow:0 0 9px 1px rgba(46,160,67,.55)}}
button.sec.dirty{animation:savePulse .9s ease-in-out infinite alternate;border-color:#2ea043}
input[type=number]{background:#0d1117;border:1px solid #30363d;color:#e6edf3;border-radius:6px;padding:5px;width:80px}
input[type=text]{background:#0d1117;border:1px solid #30363d;color:#e6edf3;border-radius:6px;padding:6px;width:160px}
table{width:100%;border-collapse:collapse;font-size:13px} th,td{text-align:left;padding:5px 8px;border-bottom:1px solid #21262d}
th{color:#8b949e;font-weight:600;font-size:12px} tr:hover{background:#1c2333}
.pill{font-size:11px;padding:2px 6px;border-radius:10px;background:#21262d;border:1px solid #30363d}
.astatpill{display:inline-block;padding:2px 10px;border-radius:11px;font-size:12px;font-weight:600;border:1px solid #30363d}
.ap-ok{background:rgba(63,185,80,.14);color:#3fb950;border-color:rgba(63,185,80,.45)}
.ap-bad{background:rgba(248,81,73,.14);color:#f85149;border-color:rgba(248,81,73,.45)}
.ap-warn{background:rgba(210,153,34,.14);color:#d29922;border-color:rgba(210,153,34,.45)}
.autoflag{font-size:10px;font-weight:800;padding:1px 8px;border-radius:9px;letter-spacing:.5px;vertical-align:middle}
.autoflag.on{background:rgba(63,185,80,.2);color:#3fb950}
.autoflag.off{background:rgba(210,153,34,.2);color:#d29922}
.autoflag.gray{background:rgba(139,148,158,.18);color:#8b949e}
#autoCard.auto-on{border:1px solid rgba(63,185,80,.55)!important;background:rgba(63,185,80,.06)}
#autoCard.auto-off{border:1px solid rgba(210,153,34,.55)!important;background:rgba(210,153,34,.06)}
#rcCard.rc-on{border:1px solid rgba(63,185,80,.55)!important;background:rgba(63,185,80,.06)}
#rcCard.rc-off{border:1px solid rgba(139,148,158,.30)}
.ok{color:#3fb950}.bad{color:#f85149}.dim{color:#8b949e}
.info{color:#58a6ff;cursor:pointer;margin-left:3px;font-size:12px;user-select:none;-webkit-user-select:none}.info:active{opacity:.55}
#infopop{position:fixed;max-width:280px;background:#161b22;border:1px solid #30363d;border-radius:8px;padding:9px 11px;font-size:12px;line-height:1.45;color:#c9d1d9;box-shadow:0 8px 28px rgba(0,0,0,.6);z-index:9999;display:none}
.mode{font-size:12px;border-radius:6px;background:#0d1117;border:1px solid #30363d;color:#e6edf3;padding:3px}
#log{font:11px/1.5 ui-monospace,monospace;background:#0d1117;border:1px solid #21262d;border-radius:8px;padding:8px;height:150px;overflow:auto;white-space:pre-wrap}
.grp{color:#8b949e;font-size:11px}
.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}
.htag{padding:1px 7px;border-radius:4px;font-size:10px;font-weight:700;letter-spacing:.3px}
/* Type column: semáforo para el flujo de robo (STEAL verde / SKIP amarillo / MISS rojo) + colores distintos por categoría */
.h-steal{background:#14361f;color:#56d364}    /* verde  - robo (acción ganadora) */
.h-skip{background:#3a2f12;color:#f0b229}     /* amarillo - no robado (motivo) */
.h-miss{background:#7f1d1d;color:#fecaca}     /* rojo   - objetivo desapareció */
.h-solo{background:#0d3b3b;color:#56d4d4}     /* cyan   - farm solo */
.h-rally{background:#172554;color:#79c0ff}    /* azul   - farm rally */
.h-join{background:#23244d;color:#9aa0ff}     /* índigo - auto-join alianza */
.h-heal{background:#3d1f33;color:#f0a6d0}     /* rosa   - cura de tropas */
.h-refill{background:#231a3d;color:#c084fc}   /* violeta eléctrico - stamina (distinto del amarillo de SKIP) */
.h-shield{background:#1e2a38;color:#8db4d8}   /* pizarra - escudo/truce (defensivo) */
.hres{padding:1px 7px;border-radius:4px;font-size:10px;font-weight:700}
.hr-won,.hr-ok,.hr-joined{background:#14361f;color:#56d364}.hr-lost{background:#7f1d1d;color:#fecaca}.hr-sent{background:#172554;color:#79c0ff}.hr-rejected{background:#3d2d0d;color:#f0b229}.hr-skip{background:#21262d;color:#9aa4b2}
.hloss{color:#f0b229;font-weight:700;background:rgba(240,178,41,.13);border:1px solid rgba(240,178,41,.38);border-radius:4px;padding:0 5px;font-size:10px;white-space:nowrap}
.kd{padding:0 5px;border-radius:3px;font-size:9px;font-weight:700;vertical-align:middle}.kd-rally{background:#7f1d1d;color:#fecaca}.kd-solo{background:#21262d;color:#9aa4b2}
.copyc{cursor:pointer;font:12px ui-monospace,monospace;color:#58a6ff;border:1px solid #30363d;border-radius:6px;padding:2px 7px;white-space:nowrap}
.copyc:hover{background:#161b22;border-color:#388bfd}
.copyc.cok{color:#3fb950;border-color:#3fb950}
th.sortable{cursor:pointer;user-select:none;white-space:nowrap}
th.sortable:hover{color:#e6edf3}
.sarrow{color:#6e7681;font-size:11px;margin-left:5px}
.sarrow.act{color:#58a6ff;font-weight:700}
.ms{position:relative;display:inline-block;min-width:300px}
.msbox{display:flex;flex-wrap:wrap;gap:5px;align-items:center;background:#0d1117;border:1px solid #30363d;border-radius:7px;padding:5px 8px}
.msbox input{background:transparent;border:0;color:#e6edf3;outline:none;flex:1;min-width:90px;font-size:13px;padding:2px}
.chip{display:inline-flex;align-items:center;gap:5px;background:#1f6feb;color:#fff;border-radius:12px;padding:2px 9px;font-size:12px}
.chip b{cursor:pointer;font-weight:700;opacity:.85}.chip b:hover{opacity:1}
.dd{position:absolute;z-index:30;left:0;right:0;top:100%;margin-top:4px;background:#161b22;border:1px solid #30363d;border-radius:7px;max-height:240px;overflow:auto;display:none}
.dd.open{display:block}
.dd .opt{padding:6px 10px;cursor:pointer;font-size:13px}
.dd .opt:hover{background:#1f6feb;color:#fff}
tr.selrow td{background:#11271a}
tr.selrow:hover td{background:#163a23}
.menu{display:flex;gap:8px;margin-bottom:12px;flex-wrap:wrap}
.pill-nav{font-size:13px;padding:6px 14px;border-radius:18px;background:#161b22;border:1px solid #30363d;cursor:pointer;user-select:none}
.pill-nav:hover{border-color:#8b949e}
.pill-nav.active{background:#1f6feb;border-color:#1f6feb;color:#fff}
.view{display:none}.view.show{display:block}
.chead{cursor:pointer;user-select:none;display:inline-flex;align-items:center;gap:10px;border-radius:7px;padding:3px 12px 12px 3px;transition:background .12s}
.chead:hover{background:#161b22}
.chead b{color:#58a6ff;text-transform:uppercase;letter-spacing:.06em;font-size:13px}
.chev{display:inline-flex;align-items:center;justify-content:center;width:24px;height:24px;border-radius:6px;background:#0d1117;border:1px solid #3d444d;color:#c9d1d9;font-size:17px;font-weight:700;line-height:1;transition:border-color .12s,color .12s}
.chev::before{content:'−'}
.chev.collapsed::before{content:'+'}
.chead:hover .chev{border-color:#58a6ff;color:#58a6ff}
.cbody.collapsed{display:none}
.preset{border:1px solid #30363d;border-radius:9px;padding:10px 12px;margin-bottom:10px;background:#1c2128;transition:border-color .12s}
.preset.on{border-left:3px solid #238636}
.preset.dragover{border-color:#1f6feb;background:#10243e}
.handle{cursor:grab;color:#6e7681;font-size:16px;padding:0 4px;align-self:flex-start}
.handle:hover{color:#e6edf3}
.prow{gap:12px;align-items:flex-end}
.pmarch{margin-top:8px;display:flex;align-items:center;gap:10px}
.pmarch:empty{display:none;margin:0}
.pmwrap{display:contents}   /* móvil: sin caja -> barra y botones fluyen inline. En desktop pasa a columna absoluta (media query) con la barra arriba y los botones DEBAJO */
.pmlabel{font:11px ui-monospace,monospace;color:#8b949e;white-space:nowrap}
.pmtgt{font:11px ui-monospace,monospace;color:#e6edf3;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;display:inline-block;max-width:150px;vertical-align:bottom}
.pmsteal{font:11px ui-monospace,monospace;color:#f0883e;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;display:inline-block;max-width:160px;vertical-align:bottom}
.pmlead{font:11px ui-monospace,monospace;color:#79c0ff;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;display:inline-block;max-width:160px;vertical-align:bottom}   /* convocante del rally en un AUTO-JOIN */
.pmcoord{font:11px ui-monospace,monospace;color:#8b949e;white-space:nowrap;vertical-align:bottom}
.bver{font:700 11px ui-monospace,monospace;color:#0d1117;background:#58a6ff;padding:2px 8px;border-radius:6px;letter-spacing:.4px;vertical-align:middle;white-space:nowrap}
.buffrow{display:flex;align-items:center;gap:8px;flex-wrap:wrap;padding:2px 0;border-top:0.5px solid #21262d}
.buffrow:first-child{border-top:0;padding-top:0}
.bufflbl{min-width:150px;font-weight:500}
.buffeta{font:12px ui-monospace,monospace;min-width:66px}
button.gemsbtn{background:#3a2d00;border:1px solid #8a6d1a;color:#ffd35c}
button.cancelbtn{background:rgba(182,35,36,.22);border:1px solid #8a2a2a;color:#ff9b9b}
button.atkbtn{background:rgba(182,35,36,.30);border:1px solid #b62324;color:#ffb3b3;font-weight:600}
/* ── BLITZ / CANCEL del rally (solo en presets en modo RALLY, siempre manual) ── */
.rallybtns{display:inline-flex;align-items:center;gap:6px;flex-wrap:wrap;margin-left:8px}
.rallybtns button{padding:3px 9px;font-size:11px}
.rlymsg{font-size:11px}
.rallybtns button:disabled{opacity:.45;cursor:not-allowed;filter:grayscale(.5)}
.boostlbl{display:inline-flex;align-items:center;gap:4px;font-size:11px;color:#8b949e;cursor:pointer;user-select:none}
@media (max-width:640px){.bufflbl{min-width:100%}}
.pmbar{flex:1;height:6px;background:#21262d;border-radius:4px;overflow:hidden}
@media (min-width:641px){
  .preset{position:relative;padding-right:220px}   /* hueco derecho reservado SIEMPRE -> el ancho no salta según haya marcha/botones o no */
  .pmwrap{position:absolute;top:11px;right:12px;width:200px;display:flex;flex-direction:column;align-items:flex-end;gap:4px}
  .pmarch{width:100%;margin:0;flex-direction:column;align-items:flex-end;gap:4px}
  .rallybtns{margin-left:0;justify-content:flex-end}
  .pmbar{width:100%;flex:none}
  .pmtgt,.pmsteal,.pmcoord{max-width:100%}
}
@media (max-width:640px){        /* movil: cada pieza de la barra en su propia linea, al 100% del contenedor */
  .pmarch{flex-direction:column;align-items:stretch;gap:4px}
  .pmtgt,.pmsteal,.pmcoord,.pmlabel{max-width:100%;width:100%}
  .pmbar{width:100%;flex:none}
}
.pmfill{display:block;height:100%;width:0;border-radius:4px;transition:width .9s linear}
.pen-lbl{display:inline-flex;align-items:center;gap:6px;align-self:center;cursor:pointer}
.pnum{font-weight:700;text-transform:uppercase;letter-spacing:.5px;padding:3px 22px 3px 10px;border:1px solid #30363d;border-left:3px solid #388bfd;border-radius:6px;background:#0d1117}
.toggle{position:relative;display:inline-block;width:38px;height:22px;flex:0 0 auto}
.toggle input{opacity:0;width:0;height:0;position:absolute;margin:0}
.toggle .slider{position:absolute;inset:0;background:#30363d;border-radius:22px;transition:.15s;box-shadow:inset 0 0 0 1px #30363d}
.toggle .slider:before{content:"";position:absolute;height:16px;width:16px;left:3px;top:3px;background:#8b949e;border-radius:50%;transition:.15s}
.toggle input:checked+.slider{background:#238636;box-shadow:inset 0 0 0 1px #2ea043}
.toggle input:checked+.slider:before{transform:translateX(16px);background:#fff}
.fld{display:inline-flex;flex-direction:column;gap:3px}
.flbl{font-size:10px;text-transform:uppercase;letter-spacing:.04em;color:#8b949e;padding-left:2px}
select.sel,input.ptn{background-color:#0d1117;color:#e6edf3;border:1px solid #30363d;border-radius:7px;font-size:13px;padding:6px 10px;transition:border-color .12s,box-shadow .12s}
select.sel{padding-right:30px;cursor:pointer;appearance:none;-webkit-appearance:none;background-repeat:no-repeat;background-position:right 9px center;background-image:url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'><path d='M2 4.5l4 4 4-4' fill='none' stroke='%238b949e' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round'/></svg>")}
select.sel:hover,input.ptn:hover{border-color:#8b949e}
select.sel:focus,input.ptn:focus{outline:none;border-color:#1f6feb;box-shadow:0 0 0 2px rgba(31,111,235,.25)}
select.sel option{background:#161b22;color:#e6edf3}
.pgen,.past{max-width:250px}
.ptt{min-width:370px}
.pmode{min-width:104px}
.pmode.m-solo{background-color:#15324d;color:#dce9ff;border-color:#2b5780}
.pmode.m-rally{background-color:#3a2b53;color:#e7dcff;border-color:#5a3f80}
.ptgtrow{margin:12px 0 7px}
.grow{margin:2px 0 4px}
.tline{margin-top:5px}
.tchips{display:inline-flex;flex-wrap:wrap;gap:4px}
.tchip{font-size:11px;padding:2px 8px;border-radius:11px;background:#21262d;border:1px solid #30363d;color:#8b949e;cursor:pointer;user-select:none;white-space:nowrap}
.tchip:hover{border-color:#8b949e}
.tchip.on{background:#1f6feb;border-color:#1f6feb;color:#fff}
.tchip.distpill{border-color:#8957e5;color:#b392f0}
.tchip.distpill.on{background:#8957e5;border-color:#8957e5;color:#fff}
.tchip.queuepill{border-color:#d4a72c;color:#e3b341;font-weight:600}
.tchip.queuepill:hover{border-color:#e3b341;color:#f2cc60}
.tchip.queuepill.on{background:#d4a72c;border-color:#d4a72c;color:#1c1400}
.queuebox{margin:5px 0 3px 22px;padding:7px 11px;background:#161b22;border:1px solid #30363d;border-left:3px solid #d4a72c;border-radius:8px;max-width:400px}
.queuebox .qhdr{font-size:9px;text-transform:uppercase;letter-spacing:.05em;color:#8b949e;margin-bottom:5px}
.queuebox .qitem{display:flex;justify-content:space-between;gap:14px;font-size:12px;padding:2px 0}
.queuebox .qn{color:#e6edf3}
.queuebox .qd{color:#e3b341;white-space:nowrap;font-variant-numeric:tabular-nums}
.queuebox .qc{color:#8b949e;white-space:nowrap;font-variant-numeric:tabular-nums}
.tchip.ord{cursor:grab}.tchip.ord:active{cursor:grabbing}
.tchip.ord .pri{display:inline-block;min-width:18px;height:18px;line-height:18px;text-align:center;font-size:12px;font-weight:800;background:#fff;color:#1f6feb;border-radius:9px;margin-right:5px;vertical-align:middle}
.tchip.dragov{border-color:#f0f6fc;border-style:dashed}
@keyframes tchipover{0%,100%{box-shadow:0 0 0 0 rgba(248,81,73,0);border-color:rgba(248,81,73,.5)}50%{box-shadow:0 0 7px 1px rgba(248,81,73,.65);border-color:rgba(248,81,73,1)}}
.tchip.overcap{animation:tchipover 1.5s ease-in-out infinite}
/* target BLOQUEADO por AUTO-PROTECCION (ya aniquilo el ejercito): tachado + rojo. SIEMPRE visible, a diferencia del
   parpadeo 'al limite' que esta oculto tras SHOW_OVERCAP. El bloqueo caduca solo a las 6h (WIPE_WINDOW). */
.tchip.wiped{border-color:#f85149;color:#ff9a94;text-decoration:line-through;text-decoration-thickness:2px}
.tchip.wiped.on{background:#5a1a17;border-color:#f85149;color:#ffd7d4}
.tchip.wiped .pri{background:#f85149;color:#fff}   /* monstruo que ha costado tropas -> parpadeo rojo suave: al límite/por encima de las posibilidades del preset */
.tgtlbl{font-size:11px;color:#8b949e}
.mcap{min-width:132px}
.mcap .mcaprow{display:inline-flex;align-items:center;gap:6px;font-size:11px;font-variant-numeric:tabular-nums;white-space:nowrap}
.mcap b{font-weight:600}
.mcap-learn b{color:#8b949e}.mcap-probe b{color:#d29922}.mcap-ok b{color:#3fb950}
.mcapb{cursor:pointer;color:#8b949e;font-size:12px;padding:0 1px;user-select:none}.mcapb:hover{color:#e6edf3}
.pmode.m-steal{background-color:#532a2a;color:#ffdcdc;border-color:#80484a}
.pmode.m-autojoin{background-color:#143a2e;color:#c8f5e2;border-color:#1f6b52}
@media (max-width:640px){
  body{padding:8px}
  .card{padding:9px}
  h1{font-size:15px}
  .sub{font-size:11px}
  table{display:block;overflow-x:auto;-webkit-overflow-scrolling:touch;white-space:nowrap}
  th,td{padding:5px 7px;font-size:11px}
  .prow{gap:8px}
  .prow .fld{flex:1 1 100%}
  .prow .fld .sel{width:100%}
  .pgen,.past{max-width:none}
  .ptt{min-width:0;flex:1 1 auto}
  input.ptn{width:88px}
  .tline{flex-wrap:wrap}
}
.tline{margin-top:6px;gap:6px;align-items:center}
.mul{color:#8b949e}
input.ptn{width:110px}
.del:hover{background:#3d1418;border-color:#f85149;color:#f85149}
/* ── Stamina chip (header) ── */
.stamina{margin-left:auto;display:inline-flex;align-items:center;gap:9px;background:linear-gradient(135deg,#1c1606,#0d1117);border:1px solid #3a2f12;border-radius:11px;padding:6px 13px 7px;min-width:154px;box-shadow:inset 0 1px 0 #ffffff0a}
.stamico{font-size:18px;line-height:1;filter:drop-shadow(0 0 4px rgba(240,178,41,.55))}
.stambody{display:flex;flex-direction:column;gap:4px;flex:1}
.stamtop{display:flex;align-items:baseline;gap:8px;justify-content:space-between}
.stamval{font-size:17px;font-weight:700;color:#f0b229;letter-spacing:.02em;font-variant-numeric:tabular-nums;line-height:1}
.stamsub{font-size:9px;font-weight:600;text-transform:uppercase;letter-spacing:.07em;color:#8b7a3f;white-space:nowrap}
.stambar{height:5px;border-radius:3px;background:#241c09;overflow:hidden}
.stamfill{display:block;height:100%;width:0;border-radius:3px;background:linear-gradient(90deg,#f0b229,#ffd76a);transition:width .5s ease,background .3s}
/* 3 estados por umbral: high=verde, mid=amarillo, low=rojo */
.stamina.s-high{border-color:#1f3b24}
.stamina.s-high .stamval{color:#3fb950}
.stamina.s-high .stamico{filter:drop-shadow(0 0 4px rgba(63,185,80,.55))}
.stamina.s-high .stamfill{background:linear-gradient(90deg,#2ea043,#3fb950)}
.stamina.s-mid{border-color:#3a2f12}
.stamina.s-mid .stamval{color:#f0b229}
.stamina.s-mid .stamico{filter:drop-shadow(0 0 4px rgba(240,178,41,.55))}
.stamina.s-mid .stamfill{background:linear-gradient(90deg,#d29922,#f0b229)}
.stamina.s-low{border-color:#5c1f1d;background:linear-gradient(135deg,#1f0d0c,#0d1117)}
.stamina.s-low .stamval{color:#f85149}
.stamina.s-low .stamsub{color:#c4716e}
.stamina.s-low .stamfill{background:linear-gradient(90deg,#da3633,#f85149)}
@keyframes stamPulse{0%,100%{opacity:1}50%{opacity:.5}}
.stamina.s-low .stamico{filter:drop-shadow(0 0 5px rgba(248,81,73,.6));animation:stamPulse 1.4s ease-in-out infinite}
@media (max-width:640px){.stamina{margin-left:0;width:100%;box-sizing:border-box}}
.ppow{font-size:11px;font-weight:600;color:#d29922;background:#1c1606;border:1px solid #3a2f12;border-radius:6px;padding:2px 7px;letter-spacing:.02em;font-variant-numeric:tabular-nums;white-space:nowrap;align-self:center}
input.ovpow{width:124px;font-variant-numeric:tabular-nums}
.ovchk:checked{accent-color:#d29922}
.ovbox{display:inline-flex;align-items:center;gap:8px;align-self:center;border-radius:8px;padding:3px 6px;border:1px solid transparent}
.ovbox.on{background:rgba(210,153,34,.15);border-color:rgba(210,153,34,.55)}
.ovbox.on .boostlbl{color:#d29922}
.tleft{font-size:10px;color:#8b949e;white-space:nowrap;font-variant-numeric:tabular-nums;min-width:64px}
.preset.on .ppow{color:#f0b229}
</style></head><body>
<h1 style="display:flex;align-items:center;flex-wrap:wrap;gap:10px"><span class=sub id=astat>—</span> <button onclick=reloadServer() style="background:#b62324;color:#fff;border:0;border-radius:7px;padding:5px 11px;font-size:13px;font-weight:600;cursor:pointer">↻ Reload Bot</button> <button onclick=stopBot() style="background:#4b5563;color:#fff;border:0;border-radius:7px;padding:5px 11px;font-size:13px;font-weight:600;cursor:pointer" title="Close the game/emulator to free resources. The backend stays alive — press Reload Bot to bring it back.">⏹ Stop Bot</button> <button onclick=openReadme() style="background:#238636;color:#fff;border:0;border-radius:7px;padding:5px 11px;font-size:13px;font-weight:600;cursor:pointer" title="Bot guide / README">📖 README</button>
<div class=stamina id=stamina style="display:none" title="Monarch stamina — consumed by monster attacks. Regenerates up to the cap; stock above it with stamina items.">
  <span class=stamico>⚡</span>
  <div class=stambody>
    <div class=stamtop><span class=stamval id=stamVal>—</span><span class=stamsub id=stamSub></span></div>
    <span class=stambar><span class=stamfill id=stamFill></span></span>
  </div>
</div></h1>
<div class=wgrid>
<div class=wcol>
<!-- APP HÍBRIDA: panel del escáner propio. En los bots de producción los objetivos
     venían por HTTP de otro servidor; aquí los barre este mismo cliente. -->
<div class=card id=scanCard>
  <div class=row style="gap:8px;align-items:center">
    <b>🗺️ Map scanner <span class=dim style="text-transform:uppercase;letter-spacing:0;font-size:10px">· this client sweeps the map itself — no external scanner</span></b>
    <span id=scanPill style="margin-left:auto;font-size:11px;font-weight:700;padding:2px 9px;border-radius:11px;background:#1f2937;color:#9ca3af">—</span>
  </div>
  <div class=row style="margin-top:8px;align-items:center;gap:14px;flex-wrap:wrap">
    <span><span class=dim>Objects on map:</span> <b id=scObjs>—</b></span>
    <span><span class=dim>Server:</span> <b id=scSrv>—</b></span>
    <span><span class=dim>Monster configs:</span> <b id=scCfg>—</b></span>
  </div>
  <div class=row style="margin-top:8px;align-items:center;gap:8px">
    <span class=dim style=min-width:64px>Lap</span>
    <span style="flex:1;height:9px;border-radius:5px;background:#1f2937;overflow:hidden;display:inline-block;min-width:120px">
      <span id=scBar style="display:block;height:100%;width:0%;background:linear-gradient(90deg,#2f81f7,#3fb950);transition:width .6s"></span>
    </span>
    <b id=scPct style=min-width:52px>—</b>
    <span class=dim id=scLap></span>
  </div>
  <div class=row style="margin-top:8px;align-items:center;gap:14px;flex-wrap:wrap">
    <span title="Requests sent to the server and replies received. A ratio well below 1 means the server is throttling the sweep."><span class=dim>Requests:</span> <b id=scReq>—</b> <span class=dim>· reply ratio</span> <b id=scRatio>—</b></span>
  </div>
  <!-- Marchas en vuelo cosechadas del mismo reply: alimentan Rally Steals -->
  <div class=row style="margin-top:8px;align-items:center;gap:14px;flex-wrap:wrap">
    <span title="Enemy marches in flight seen by the sweep. These feed the Rally Steals view."><span class=dim>Active attacks:</span> <b id=scAtk>—</b></span>
    <span><span class=dim>marches tracked:</span> <b id=scMar>—</b></span>
    <span><span class=dim>players known:</span> <b id=scPly>—</b></span>
    <span title="Times the sweep went quiet so the bot could send troops. Without this the server rate-limits us and the marches never leave."><span class=dim>sweep holds:</span> <b id=scHold>—</b></span>
  </div>
  <!-- Salud del cliente: hueco entre frames medido en el hilo principal de Unity. Es la
       señal que dice si barrer y marchar a la vez se están pisando. -->
  <div class=row style="margin-top:8px;align-items:center;gap:14px;flex-wrap:wrap">
    <span title="Gap between Unity frames, measured inside the game. Long gaps = the client is stalling."><span class=dim>Client health:</span> <b id=scGap>—</b></span>
    <span><span class=dim>peak</span> <b id=scGapMax>—</b></span>
    <span><span class=dim>freezes &gt;2s:</span> <b id=scFrz>—</b></span>
  </div>
  <div class=row style="margin-top:9px;gap:8px;align-items:center;flex-wrap:wrap">
    <button class=sec id=scToggle onclick=scanToggle() title="Pause the sweep (the bot keeps working; targets go stale)">⏸ Pause sweep</button>
    <span class=dim>Cadence</span>
    <input type=number id=scCad value=350 min=200 max=2000 step=10 style=width:74px title="Milliseconds between map requests. Lower = faster sweep, but the server starts rate-limiting: watch the reply ratio (should stay above ~1) and the freeze counter.">
    <button class=sec onclick=scanCadence()>Set</button>
    <span class=dim id=scMsg></span>
  </div>
</div>

<div class=card id=truceCard>
  <div class=row style="gap:8px;align-items:center">
    <label class=toggle><input type=checkbox id=trucechk onchange=toggleTruce()><span class=slider></span></label>
    <b>🛡️ Auto Truce Agreement</b>
  </div>
  <div class=row style="margin-top:8px;align-items:center">
    <span class=dim>Shield:</span> <b id=shieldleft>—</b>
    <span class=dim title="Renew the bubble when fewer than this many hours of shield remain (0 = only re-bubble after it has already dropped).">· renew when &lt;</span> <input type=number id=trenewh value=2 step=0.5 min=0 style=width:64px oninput=markTruceDirty()> <span class=dim>h left</span>
    <button class=sec id=trucesavebtn onclick=saveTruceCfg()>Save</button>
    <button class=sec onclick=truceNow() title="Apply a Truce Agreement now to renew the bubble">🛡️ Renew now</button>
    <span class=dim id=truceinfo></span>
  </div>
  <div class=row style=margin-top:6px>
    <span class=dim>Agreements in bag:</span> <span id=truceitems class=dim>—</span>
  </div>
</div>

<div class=card id=rcCard>
  <div class=row style="gap:10px;align-items:center;justify-content:space-between">
    <span class=row style="gap:9px;align-items:center"><label class=toggle><input type=checkbox id=rcchk onchange=toggleRallyCard()><span class=slider></span></label>
      <b>🎴 Rally Monthly Card <span id=rcflag class=autoflag>—</span> <span class=dim style="text-transform:uppercase;letter-spacing:0;font-size:10px">· Enable to let the card auto-join (this pauses AUTO)</span></b></span>
    <b id=rcCardLeft style="font-size:15px;color:#58a6ff;white-space:nowrap;display:none" title="Días restantes de la suscripción de la Rally Monthly Card">—</b>
  </div>
  <div class=row id=rcRow2 style="margin-top:9px;gap:16px;align-items:center;flex-wrap:wrap;display:none">
    <span id=rcTimerWrap style=display:none><span class=dim>⏳ Auto-join left</span> <b id=rcTimer style=color:#3fb950>—</b></span>
    <span class=dim id=rcstat style="font-size:11px"></span>
  </div>
</div>

<div class="card" id=autoCard>
  <div class=row style="gap:10px;align-items:center;justify-content:space-between">
    <span class=row style="gap:9px;align-items:center"><label class=toggle><input type=checkbox id=autochk onchange=toggleAuto()><span class=slider></span></label>
      <b>AUTO <span id=autoflag class=autoflag>—</span> <span class=dim style="text-transform:uppercase;letter-spacing:0;font-size:10px">· GLOBAL — runs every enabled preset in its own mode at once</span></b></span>
    <span id=autostat class=dim style="font-size:11px"></span>
  </div>
</div>
</div><!-- /.wcol left -->

<div class=wcol>
<div class=card id=staminaCard>
  <div class=row style="gap:8px;align-items:center">
    <label class=toggle><input type=checkbox id=refillchk onchange=toggleRefill()><span class=slider></span></label>
    <b>Stamina auto-refill <span class=dim style="text-transform:uppercase;letter-spacing:0;font-size:10px">· uses stamina items from the bag when stamina drops below the threshold</span></b>
  </div>
  <div class=row style=margin-top:8px>
    <span class=dim title="Refill triggers when current stamina drops below this value.">Refill below</span> <input type=number id=rthr value=500 step=50 style=width:80px oninput=markRefillDirty()>
    <span class=dim title="When refilling, consume stamina items until stamina reaches at least this value.">Refill up to</span> <input type=number id=rtgt value=2000 step=100 style=width:80px oninput=markRefillDirty()>
    <button class=sec id=refillsavebtn onclick=saveRefillCfg()>Save</button>
    <button class=sec onclick=refillNow() title="Use stamina items now to reach the target">⚡ Refill now</button>
    <button class=sec id=mailbtn onclick=getStaminaMail() title="Claim reward mails NOW: stamina shared by teammates, events, etc. Same check the bot does when stamina runs low and every 8h.">📬 Get Stamina</button>
    <span class=dim id=mailinfo></span>
    <span class=dim id=refillinfo></span>
  </div>
  <div class=row style=margin-top:6px>
    <span class=dim>In bag:</span> <span id=stamitems class=dim>—</span>
  </div>
  <!-- Wheel of Fortune OCULTO temporalmente (no se puede headless desde el mapa; se retomará). Reactivar: quitar display:none -->
  <div class=row style="margin-top:8px;padding-top:8px;border-top:1px solid #21262d;display:none">
    <span class=dim title="Wheel of Fortune (tavern roulette). Requiere la rueda abierta en el juego; rewards aleatorios incl. stamina.">🎡 Wheel of Fortune</span>
    <span class=dim>credits:</span> <b id=wheelcred style=color:#f0b229>—</b>
    <span class=dim title="How many ×100 batches to spin (each batch = 100 spins = 100 credits).">batches</span> <input type=number id=wheeln value=10 min=1 max=50 style=width:55px>
    <button class=sec id=wheelbtn onclick=wheelSpin() title="Spin N×100 times to win stamina lots. Stops when credits run out; never buys credits with gems.">🎡 Spin ×100</button>
    <span id=wheelinfo class=dim style="margin-left:6px"></span>
  </div>
</div>

<div class=card id=healCard>
  <div class=row style="gap:8px;align-items:center">
    <label class=toggle><input type=checkbox id=healchk onchange=toggleHeal()><span class=slider></span></label>
    <b>Troop auto-heal <span class=dim style="text-transform:uppercase;letter-spacing:0;font-size:10px">· heals wounded troops with resources + time when the hospital is free · never gems/speedups</span></b>
  </div>
  <div class=row style="margin-top:9px;gap:16px;align-items:center;flex-wrap:wrap">
    <span><span class=dim>🩹 Wounded</span> <b id=hpWounded style=color:#f0b229>—</b></span>
    <span><span class=dim>· Healing now</span> <b id=hpHealing>—</b></span>
    <span id=hpCapWrap style=display:none><span class=dim>· Hospital cap</span> <b id=hpCap class=dim>—</b></span>
    <span id=hpEtaWrap style=display:none><span class=dim>· Heal ETA</span> <b id=hpEta style=color:#3fb950>—</b></span>
  </div>
  <div class=row style=margin-top:9px>
    <span class=dim title="Auto-heal only triggers when at least this many troops are wounded (and the hospital queue is free).">Heal when wounded ≥</span> <input type=number id=hthr value=1 min=1 step=1 style=width:80px oninput=markHealDirty()>
    <button class=sec id=healsavebtn onclick=saveHealCfg()>Save</button>
    <button class=sec onclick=healNow() title="Heal all wounded now (resources + time, never gems). If the hospital is busy it will offer to cancel the current heal first.">🩹 Heal now</button>
    <span class=dim id=healinfo></span>
  </div>
</div>

<div class=card id=buffCard>
  <div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap"><b>💪 Troop &amp; March Buffs</b> <span class=dim style="text-transform:uppercase;letter-spacing:0;font-size:10px">· activate attack/defense/HP + march boosts · shows items in bag &amp; time left</span></div>
  <div id=buffRows style="margin-top:10px;display:flex;flex-direction:column;gap:0">—</div>
</div>
</div><!-- /.wcol right -->
</div><!-- /.wgrid -->

<div class=card style=display:none>
  <div class=chead onclick="toggleCard('setup')"><span class=chev id=chev_setup></span> <b>Setup</b></div>
  <div class=cbody id=body_setup>
    <div class=row style=margin-top:10px>
      <span class=dim>City</span> X <input type=number id=cx> Y <input type=number id=cy>
      <span class=dim>Max slots</span> <input type=number id=ms value=6 style=width:55px>
      <span class=dim>Margin×</span> <input type=number id=mg value=1.2 step=0.1 style=width:55px>
      <span class=dim>Cap/march</span> <input type=number id=cap value=500000 style=width:90px>
      <span class=dim>Game FPS <span class=info data-info="Frame rate the game is capped to. The bot never needs the game to render: it reads state from memory and sends protobuf directly. Evony defaults to 30 fps, which burned ~74% CPU rendering for nobody (the emulator is headless, ~68 MB/s of OpenGL commands); 5 fps drops it to ~30% CPU, 1 fps to ~23%. Lower = less CPU, but the game's own logic runs per frame, so don't go too low. Applies instantly and re-applies on every attach. 0 = don't touch the game." onclick="showInfo(event,this)">ⓘ</span></span> <input type=number id=fps value=5 min=0 max=60 style=width:55px>
      <button class=sec onclick=saveCfg()>Save</button>
    </div>
    <div class=sub id=accinfo style=margin-top:6px></div>
    <div class=row style=margin-top:8px>
      <b>Learn presets:</b>
      <button class=warn onclick="cap('solo')">① Capture SOLO</button>
      <button class=warn onclick="cap('rally')">② Capture RALLY</button>
      <span class=dim id=tpls>presets: —</span>
    </div>
    <div class=sub id=caphint></div>
  </div>
</div>


<div class=card style="margin-top:50px">
  <div class=row style=justify-content:space-between>
    <div class=chead onclick="toggleCard('presets')"><span class=chev id=chev_presets></span> <b>March queue (presets)</b></div>
    <span class=row><span class=dim id=preset_hint style="text-transform:uppercase;letter-spacing:0;font-size:10px">each active preset = 1 march → nearest winnable marked monster (mode = attack type) · drag ⠿ to reorder</span>
    <button class=sec onclick=resetAllCaps() title="Forget the learned march cap of ALL generals and measure them again">⟳ Reset caps</button>
    <button class=sec onclick=resetBlocks() title="Clear the auto-protection blocks (monsters that wiped your army ≥2 times in the last 6h). The bot will be able to target them again.">⛔ Reset blocks</button>
    <button class=sec id=savebtn onclick=savePresets()>💾 SAVE PRESETS</button>
    <span id=joininfo class=dim style="font-size:10px"></span></span>
  </div>
  <div class=cbody id=body_presets><div id=presets></div></div>
</div>

<div class=menu id=menu>
  <span class="pill-nav" data-view=monsters onclick="showView('monsters')">🎯 Targets</span>
  <span class="pill-nav" data-view=steals onclick="showView('steals')">🏴 Rally Steals</span>
  <span class="pill-nav" data-view=history onclick="showView('history')">📜 History</span>
</div>

<div class="card view" data-view=monsters id=view_monsters>
  <div class=row style="gap:8px;align-items:center;margin-bottom:8px"><b>🎯 Target monsters <span class=dim style="text-transform:uppercase;letter-spacing:0;font-size:10px">· mark which monsters AUTO farms (each preset attacks in its own mode)</span></b></div>
  <div class=expwrap>
    <span class="expsw" id=expsw_t onclick="toggleExpert('t')"><span class=swtk></span>⚙ Expert Mode</span>
    <div class=expbody id=expbody_t style=display:none>
      <div class=expgrid>
        <div class=exprow><label>Troop margin <span class=info data-info="Troops to send: preset power >= monster power x margin. Lower (e.g. 1.05) = smaller, more efficient marches; higher = bigger safety cushion. Range 1.0-2.0." onclick="showInfo(event,this)">&#9432;</span></label><input type=number id=ex_mg min=1 max=2 step=0.05></div>
        <div class=exprow><label>Win ratio &#127760; <span class=info data-info="GLOBAL (solo+rally+steal): a preset must be >= monster power x win_ratio to attack. Lower = attack bigger monsters. Shared with the Steal panel. Range 0.5-1.2." onclick="showInfo(event,this)">&#9432;</span></label><input type=number id=ex_wr min=0.5 max=1.2 step=0.05></div>
        <div class=exprow><label>Max slots <span class=info data-info="Max simultaneous marches. Range 1-6." onclick="showInfo(event,this)">&#9432;</span></label><input type=number id=ex_ms min=1 max=6 step=1></div>
        <div class=exprow><label>Re-attack cooldown (s) <span class=info data-info="Seconds before re-attacking the same monster (covers the round trip). Range 60-1800." onclick="showInfo(event,this)">&#9432;</span></label><input type=number id=ex_cd min=60 max=1800 step=10></div>
        <div class=exprow><label>Farm freshness (s) <span class=info data-info="Max seconds since a ROAMING monster was last seen to still farm it. Higher = more candidates but some may have moved (more fails); lower = only fresh. Range 30-600." onclick="showInfo(event,this)">&#9432;</span></label><input type=number id=ex_fsm min=30 max=600 step=10></div>
        <div class=exprow><label>Rally freshness (s) <span class=info data-info="Same for boss/event targets (stationary, periphery re-scanned slowly). Raise to catch far bosses. Range 120-1800." onclick="showInfo(event,this)">&#9432;</span></label><input type=number id=ex_rsm min=120 max=1800 step=30></div>
        <div class=exprow><label>Wipe strikes <span class=info data-info="How many times the same monster must wipe your army before it is auto-blocked. Lower = more cautious. Range 1-5." onclick="showInfo(event,this)">&#9432;</span></label><input type=number id=ex_ws min=1 max=5 step=1></div>
        <div class=exprow><label>Wipe threshold (%) <span class=info data-info="Percentage of the SENT troops you must lose for a march to count as a wipe (annihilation). A % adapts to march size (unlike a fixed troop count): normal farm losses are 0-1%, an annihilation is near-total. Lower = more sensitive. Range 50-100." onclick="showInfo(event,this)">&#9432;</span></label><input type=number id=ex_wf min=50 max=100 step=5></div>
        <div class=exprow><label>Block duration (h) <span class=info data-info="Hours a monster stays auto-blocked after wiping you, before it is retried once. Kept temporary on purpose: a permanent block could never self-heal (blocked = no new results = blocked forever). Range 1-24." onclick="showInfo(event,this)">&#9432;</span></label><input type=number id=ex_ww min=1 max=24 step=1></div>
        <div class=exprow><label class=expchk><input type=checkbox id=ex_jit> Human cadence</label></div>
      </div>
      <div class=expfoot>
        <button class=sec onclick="saveExpert('t')">&#128190; Save</button>
        <button class=sec onclick="resetExpert('t')" title="Reset all Targets Expert Mode values to their defaults">&#8634; Reset to defaults</button>
        <span class=dim id=exinfo_t></span>
      </div>
    </div>
  </div>
  <div class=row style="gap:8px;align-items:flex-start;margin-bottom:8px">
    <div class=ms id=ms_flt style="flex:1;min-width:200px"><div class=msbox id=msbox_flt style="max-height:112px;overflow:auto"><input id=flt placeholder="filter or add target — type a monster…" autocomplete=off oninput="mPage=0;render();renderFltDD()"></div><div class=dd id=dd_flt></div></div>
    <button class=sec onclick=loadMons() title="Reload monster list from V3">↻ Refresh</button>
    <button class=sec onclick=clearSel() title="Deselect all active targets">🗑 Clear targets</button>
  </div>
  <div class=row style=margin-bottom:8px>
    <button class=sec onclick=preview()>👁 Preview</button>
    <button onclick=apply() style="background:#b62324;font-weight:600">⚔️ ATTACK</button>
    <span class=dim style="text-transform:uppercase;letter-spacing:0;font-size:10px">Preview = dry-run · ATTACK = launch now (one-shot)</span>
  </div>
  <div id=applyres class=sub style=margin-bottom:8px></div>
  <div class=row id=mpager style="justify-content:space-between;margin-bottom:6px;align-items:center">
    <span class=dim id=mpinfo style=font-size:11px></span>
    <span class=row style=gap:6px><button class=sec id=mprev onclick=mPrev()>‹ Prev</button><button class=sec id=mnext onclick=mNext()>Next ›</button></span>
  </div>
  <table><thead><tr><th>✓</th><th>Monster</th><th class=sortable onclick="sortCol('level')">Lv<span id=sort_level class=sarrow></span></th><th class=sortable onclick="sortCol('power')">Power<span id=sort_power class=sarrow></span></th><th>Group</th><th>#</th><th>Dist</th></tr></thead>
  <tbody id=tb></tbody></table>
  <div class=row id=mpager_b style="justify-content:space-between;margin-top:6px;align-items:center">
    <span class=dim id=mpinfo_b style=font-size:11px></span>
    <span class=row style=gap:6px><button class=sec id=mprev_b onclick=mPrev()>‹ Prev</button><button class=sec id=mnext_b onclick=mNext()>Next ›</button></span>
  </div>
</div>

<div class="card view" data-view=steals id=view_steals>
  <div class=row style=justify-content:space-between>
    <span class=row style="gap:8px;align-items:center"><b>🏴 Rally Steals <span class=dim style=text-transform:none;letter-spacing:0>· enemy rallies your STEAL-mode presets can steal (SOLO, land first) — runs under global AUTO</span></b></span>
  </div>
  <div class=expwrap>
    <span class="expsw" id=expsw_s onclick="toggleExpert('s')"><span class=swtk></span>⚙ Expert Mode</span>
    <div class=expbody id=expbody_s style=display:none>
      <div class=expgrid>
        <div class=exprow><label>Travel sec/tile <span class=info data-info="Our march speed: seconds per map tile. Arrival ETA = distance x this. Only the STARTING value - the bot auto-measures the real sec/tile per general from the marches that launch. Range 0.3-3.0." onclick="showInfo(event,this)">&#9432;</span></label><input type=number id=spt step=0.1 min=0.3 max=3></div>
        <div class=exprow><label>Enemy land +s <span class=info data-info="Seconds an enemy alliance rally needs to march + land after gathering. Added to their gather ETA to estimate when the monster dies. Range 0-300." onclick="showInfo(event,this)">&#9432;</span></label><input type=number id=sbuf min=0 max=300></div>
        <div class=exprow><label>Win ratio &#127760; <span class=info data-info="GLOBAL (shared with the Targets Expert Mode): preset power / monster power needed to attempt. Lower = attempt bigger monsters. Range 0.5-1.2." onclick="showInfo(event,this)">&#9432;</span></label><input type=number id=swr step=0.05 min=0.5 max=1.2></div>
        <div class=exprow><label>Min power (M) <span class=info data-info="Do NOT steal monsters weaker than this, in millions of power (0 = no minimum). Range 0-500." onclick="showInfo(event,this)">&#9432;</span></label><input type=number id=sminpow min=0 max=500 step=1></div>
        <div class=exprow><label>Combat margin (s) <span class=info data-info="Seconds the enemy needs to kill the monster after landing. We can arrive up to this many seconds after them and still snipe. Higher = more aggressive. Range 0-120." onclick="showInfo(event,this)">&#9432;</span></label><input type=number id=scmargin min=0 max=120 step=5></div>
        <div class=exprow><label>Win margin (s) <span class=info data-info="Head start required to attempt a steal: go only if we arrive more than this many seconds before their kill. Higher = safer, fewer steals. Range 0-60." onclick="showInfo(event,this)">&#9432;</span></label><input type=number id=swm min=0 max=60 step=1></div>
      </div>
      <div class=expfoot>
        <button class=sec onclick="saveExpert('s')">&#128190; Save</button>
        <button class=sec onclick="resetExpert('s')" title="Reset all Steal Expert Mode values to their defaults">&#8634; Reset to defaults</button>
        <span class=dim id=exinfo_s></span>
      </div>
    </div>
  </div>
  <div class=row style=margin-top:8px>
    <span class=dim>Target alliances <span class=info data-info="Only steal from monsters being attacked by these alliance tags (comma-separated, e.g. NUD, TDL). Empty = steal from any alliance." onclick="showInfo(event,this)">ⓘ</span></span> <input type=text id=ttags placeholder="e.g. NUD, TDL (empty = all)" style=width:190px>
    <button class=sec id=stealsavebtn onclick=saveStealCfg()>Save</button>
    <button class=sec onclick=loadSteals() title="Refresh active enemy attacks">↻ Refresh</button>
    <span class=dim id=stealinfo></span>
  </div>
  <div class=row style=margin-top:8px>
    <span class=dim>Never steal:</span>
    <div class=ms id=ms_excl><div class=msbox id=msbox_excl><input id=msin_excl placeholder="type monster name… (Viking, Barbary…)" autocomplete=off></div><div class=dd id=dd_excl></div></div>
  </div>
  <table><thead><tr><th>Monster</th><th title="Enemy rally type / state (like V3 Active Attacks)">Phase</th><th>Lv</th><th>Group</th><th>Coords</th><th>Dist</th><th>Power</th><th>Attacker</th><th>Alliance</th><th>Kill ETA</th><th>Our ETA</th><th>Steal?</th><th>Test</th></tr></thead>
  <tbody id=stb></tbody></table>
</div>

<div class="card view" data-view=history id=view_history>
  <div class=row style="gap:10px;align-items:center;flex-wrap:wrap">
    <b>📜 History</b>
  </div>
  <div class=row style="margin-top:8px;gap:8px;align-items:center">
    <select id=hfilter onchange=renderHistory() class=sel style="font-size:12px;padding:4px 8px;width:auto">
      <option value=all>All events</option><option value=steal>🏴 Steals</option><option value=skip>⤳ Not stolen (why)</option><option value=farm>⚔ Solo/Rally</option>
      <option value=join>🤝 Auto-join</option><option value=heal>🩹 Heals</option><option value=refill>⚡ Stamina</option><option value=truce>🛡 Shield</option><option value=blitz>⚡ Blitz (gems)</option><option value=cancel_rally>✖ Rally cancelled</option><option value=miss>🚫 Target gone</option><option value=watchdog>⚠ Frozen</option>
    </select>
    <button class=sec onclick=loadHistory()>↻ Refresh</button>
    <button class=sec onclick=clearHistory() title="Clear the whole history">🗑 Clear</button>
    <span class=dim id=histinfo></span>
  </div>
  <table style=margin-top:8px><thead><tr><th>Time</th><th>Type</th><th>Target</th><th>Power</th><th>Enemy</th><th>Our force</th><th>Result</th></tr></thead>
  <tbody id=htb></tbody></table>
</div>

<div class=card>
  <div class=chead onclick="toggleCard('log')"><span class="chev collapsed" id=chev_log></span> <b>Log</b></div>
  <div class="cbody collapsed" id=body_log><div id=log></div></div>
</div>

<script>
let MONS=[], sortBy=null, sortDir=1, mPage=0; const MPAGE=100;
async function j(u,o){const r=await fetch(u,o);return r.json()}
function sortCol(c){if(sortBy==c){sortDir=-sortDir}else{sortBy=c;sortDir=1}render()}
function mPrev(){if(mPage>0){mPage--;render()}}
function mNext(){mPage++;render()}
async function loadMons(){const d=await j('/api/monsters');MONS=d.rows||[];
  if(d.city){document.getElementById('cx').value=d.city.x||'';document.getElementById('cy').value=d.city.y||''}
  render()}
function render(){const f=(document.getElementById('flt').value||'').toLowerCase();
  const tb=document.getElementById('tb');tb.innerHTML='';
  const rows=MONS.filter(m=>!f||m.name.toLowerCase().includes(f));
  rows.sort((a,b)=>{
    const d=(b.on?1:0)-(a.on?1:0); if(d)return d;                         // seleccionados SIEMPRE arriba (destacados)
    if(sortBy){const av=+(a[sortBy]||0),bv=+(b[sortBy]||0); if(av!=bv)return (av-bv)*sortDir; return a.name.localeCompare(b.name);}  // y dentro de cada grupo, por la columna
    return (b.count-a.count)||a.name.localeCompare(b.name)||((a.level||0)-(b.level||0));
  });
  const total=rows.length, pages=Math.max(1,Math.ceil(total/MPAGE));
  if(mPage>pages-1)mPage=pages-1; if(mPage<0)mPage=0;
  rows.slice(mPage*MPAGE,(mPage+1)*MPAGE).forEach(m=>{
    const tr=document.createElement('tr');if(m.on)tr.className='selrow';
    const nm=m.name.replace(/'/g,"\\'");
    tr.innerHTML=`<td><input type=checkbox ${m.on?'checked':''} onchange="sel('${nm}',${m.level},this.checked)"></td>
      <td>${m.name}</td><td>${m.level||''}</td><td>${m.power?fmtK(m.power):'<span class=dim>—</span>'}</td><td class=grp>${m.group||''}</td><td>${m.count||'<span class=dim>—</span>'}</td>
      <td>${m.nearest==null?'<span class=dim>—</span>':m.nearest}</td>`;
    tb.appendChild(tr)});
  const a=total?mPage*MPAGE+1:0, b=Math.min((mPage+1)*MPAGE,total);
  const _pinfo=total?(a+'–'+b+' of '+total+(f?' filtered':'')+' · page '+(mPage+1)+'/'+pages):'no monsters';
  ['','_b'].forEach(sfx=>{const mi=document.getElementById('mpinfo'+sfx);if(mi)mi.textContent=_pinfo;const mp=document.getElementById('mprev'+sfx);if(mp)mp.disabled=(mPage<=0);const mn=document.getElementById('mnext'+sfx);if(mn)mn.disabled=(mPage>=pages-1);});
  ['level','power'].forEach(c=>{const el=document.getElementById('sort_'+c);if(el){const act=(sortBy==c);el.textContent=act?(sortDir>0?'↑':'↓'):'⇅';el.className='sarrow'+(act?' act':'')}});try{renderFltChips()}catch(e){}}
async function sel(name,level,on){const m=MONS.find(x=>x.name==name&&x.level==level);if(m)m.on=on;try{renderPresets()}catch(e){}try{renderFltChips()}catch(e){}
  await j('/api/select',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({name,level,on})})}
async function clearSel(){if(!confirm('Deselect ALL active targets and start fresh?'))return;
  await j('/api/select',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({clear:true})});
  MONS.forEach(m=>m.on=false);render();try{renderPresets()}catch(e){}}
// ---- target filter: chip + autocomplete (Monster + Level suggestions). Picking a suggestion marks that monster as a target (same sel() the table checkboxes use), so it also gets checked in the list. Chips mirror the selected targets and remove on ×. ----
function fltMatches(){const inp=document.getElementById('flt');const q=(inp?inp.value:'').trim().toLowerCase();
  const rows=MONS.filter(m=>!m.on&&(!q||m.name.toLowerCase().includes(q)));
  rows.sort((a,b)=>(b.count-a.count)||a.name.localeCompare(b.name)||((a.level||0)-(b.level||0)));
  return rows.slice(0,60);}
function renderFltDD(){const inp=document.getElementById('flt'),dd=document.getElementById('dd_flt');if(!dd||!inp)return;
  const list=fltMatches();dd.innerHTML='';
  if(!list.length){dd.classList.remove('open');return}
  list.forEach(m=>{const d=document.createElement('div');d.className='opt';
    d.innerHTML=m.name+' <span class=dim>Lv'+(m.level||0)+' · '+(m.power?fmtK(m.power):'—')+(m.count?(' · '+m.count+'×'):'')+'</span>';
    d.onclick=()=>{sel(m.name,m.level,true);inp.value='';mPage=0;render();renderFltDD();inp.focus()};dd.appendChild(d)});
  dd.classList.add('open')}
function renderFltChips(){const box=document.getElementById('msbox_flt'),inp=document.getElementById('flt');if(!box||!inp)return;
  [...box.querySelectorAll('.chip')].forEach(c=>c.remove());
  MONS.filter(m=>m.on).sort((a,b)=>a.name.localeCompare(b.name)||((a.level||0)-(b.level||0))).forEach(m=>{
    const c=document.createElement('span');c.className='chip';c.innerHTML=m.name+' Lv'+(m.level||0)+' <b>&times;</b>';
    c.querySelector('b').onclick=(e)=>{e.stopPropagation();sel(m.name,m.level,false);render();};box.insertBefore(c,inp)})}
let STEALS=[];
let HIST=[];
let OVERLIMIT=new Set(),_olSig='';   // claves 'mode|name|level' con pérdidas recientes -> pill del target parpadea
const SHOW_OVERCAP=false;
let WIPED={},_wpSig='';   // 'modo|nombre|nivel' -> {n,ts}: targets bloqueados por AUTO-PROTECCION (aniquilaron el ejercito)   // parpadeo "al límite" OCULTO por ahora (a petición). Poner true para reactivarlo (todo lo demás sigue cableado: server + poll + CSS)
function histRow(e){
  const d=new Date((e.ts||0)*1000); const tm=('0'+d.getHours()).slice(-2)+':'+('0'+d.getMinutes()).slice(-2)+':'+('0'+d.getSeconds()).slice(-2);
  const dt=('0'+d.getDate()).slice(-2)+'/'+('0'+(d.getMonth()+1)).slice(-2)+'/'+String(d.getFullYear()).slice(-2);   // DD/MM/YY
  const k=e.kind; let typ,tcls;
  if(k=='steal'){typ='STEAL';tcls='h-steal';}
  else if(k=='farm'){typ=(e.mode=='rally'?'RALLY':'SOLO');tcls=(e.mode=='rally'?'h-rally':'h-solo');}
  else if(k=='join'){typ='JOIN';tcls='h-join';}
  else if(k=='heal'){typ='HEAL';tcls='h-heal';}
  else if(k=='refill'){typ='STAMINA';tcls='h-refill';}
  else if(k=='miss'){typ='MISS';tcls='h-miss';}
  else if(k=='skip'){typ='SKIP';tcls='h-skip';}
  else if(k=='truce'){typ='🛡 SHIELD';tcls='h-shield';}
  else if(k=='watchdog'){typ='⚠ FROZEN';tcls='h-miss';}
  else if(k=='blitz'){typ='⚡ BLITZ';tcls='h-skip';}            /* lanzamiento inmediato del rally: gasta gemas -> ambar, para que destaque en el historial */
  else if(k=='cancel_rally'){typ='✖ CANCEL';tcls='h-rally';}
  else{typ=(k||'').toUpperCase();tcls='h-solo';}
  // un robo/farm PERDIDO no debe parecer un éxito en la columna Type: gone -> MISS (rojo); lost -> rojo (mantiene la etiqueta)
  if((k=='steal'||k=='farm')&&(e.result=='gone'||e.result=='lost')){tcls='h-miss';if(e.result=='gone')typ='MISS';}
  let target='<span class=dim>—</span>';
  if(k=='join'){   /* AUTO-JOIN: "Monstruo Lx @x,y · alliance rally [TAG] Convocante" */
    const _mon=e.name?(e.name+(e.level?(' <span class=dim>L'+e.level+'</span>'):'')):'<span class=dim>monster</span>';
    const _at=e.tx?(' <span class=dim>@'+e.tx+','+e.ty+'</span>'):'';
    const _who='<span class=dim style=font-size:10px>'+(e.tag?('['+e.tag+'] '):'')+(e.leader||(e.war_id?('#'+e.war_id):''))+'</span>';   /* convocante en gris y pequeño: el protagonista de la fila es el MONSTRUO */
    target=_mon+_at+' <span class=dim>· alliance rally</span> '+_who;}
  else if(k=='steal'||k=='farm'||k=='skip'||(k=='miss'&&(e.name||e.tx))){target=(e.name||('#'+(e.tx||'')))+(e.level?(' <span class=dim>L'+e.level+'</span>'):'')+(e.tx?(' <span class=dim>@'+e.tx+','+e.ty+'</span>'):'');}
  else if(k=='heal'){target='<span class=dim>'+fmtThou(e.count||0)+' wounded troops</span>';}
  else if(k=='refill'){target='<span class=dim>monarch stamina</span>';}
  else if(k=='blitz'||k=='cancel_rally'){target='<span class=dim>alliance rally'+(e.war_id?(' #'+e.war_id):'')+(e.preset?(' · preset '+e.preset):'')+'</span>';}
  else if(k=='truce'){target='<span class=dim>'+(e.dur?(Math.round(e.dur/3600)+'h truce'):'peace shield')+'</span>';}
  const power=(e.power)?fmtK(e.power):'<span class=dim>—</span>';
  let enemy='<span class=dim>—</span>';
  if(k=='steal'||k=='skip'){let s=(e.alliance?('<b>['+e.alliance+']</b> '):'')+(e.attacker||'');
    if(e.atk_kind=='alliance')s+=' <span class="kd kd-rally" title="alliance rally'+(e.count>1?(' — '+e.count+' marches joined'):'')+'">⚔ RALLY'+(e.count>1?(' ×'+e.count):'')+'</span>';
    else if(e.atk_kind=='solo')s+=' <span class="kd kd-solo" title="single-player solo attack">SOLO</span>';
    if(e.enemy_speedup>0)s+=' <span style="color:#f0b229" title="the enemy advanced its rally with speedups/gems">⚡+'+e.enemy_speedup+'s</span>';if(s.replace(/<[^>]+>/g,'').trim())enemy=s;}
  let force='<span class=dim>—</span>';
  if(k=='steal'||k=='farm'||k=='join'||k=='miss'){let s=(e.troops?fmtThou(e.troops)+' tr':'')+(e.general?(' · '+e.general):'');if(s.trim())force=s;}
  else if(k=='skip'){let s=(e.their_kill?('<span title="when the enemy rally kills the monster">their kill '+e.their_kill+'s</span>'):'')+(e.our_eta?(' <span class=dim>vs our '+e.our_eta+'s</span>'):'');if(s.trim())force=s;}
  else if(k=='refill'){force='+'+fmtThou(e.gained||0)+' stamina <span class=dim>('+(e.items||0)+' items)</span>';}
  else if(k=='heal'){force='<span class=dim>resources+time (no gems)</span>';}
  const r=e.result||''; let rl,rcls='hr-'+r;
  if(r=='won')rl=e.confirmed?'✓ WON':'✓ won?';else if(r=='lost')rl=e.confirmed?'✗ LOST':'✗ lost?';else if(r=='gone'){rl=e.confirmed?'✗ GONE':'✗ gone?';rcls='hr-rejected';}else if(r=='sent')rl='⏳ pending';else if(r=='done'){rl='✓ done?';rcls='hr-skip';}else if(r=='rejected')rl='✕ rejected';else if(r=='ok')rl='✓ ok';else if(r=='joined')rl='✓ joined';else if(r=='fail'){rl='✗ fail';rcls='hr-lost';}else if(r=='too_slow'){rl='⏱ too slow';rcls='hr-rejected';}else if(r=='cant_win'){rl='✗ can\'t win';rcls='hr-lost';}else if(r=='low_power'){rl='low power';rcls='hr-skip';}else{rl=r||'—';rcls='hr-sent';}
  const note=e.note?(' <span class=dim style=font-size:10px>· '+String(e.note).replace(/([\d,]+)\s+lost/g,'<span class="hloss">$1 lost</span>')+'</span>'):'';   // resalta las PÉRDIDAS ('N lost') con pill ámbar (heridos, recuperables) — no toca 'no losses' ni 'lost the battle'
  return '<tr><td style="font-size:11px;white-space:nowrap;line-height:1.3"><span class=dim style=font-size:9px>'+dt+'</span><br><span class=dim>'+tm+'</span></td><td><span class="htag '+tcls+'">'+typ+'</span></td><td>'+target+'</td><td>'+power+'</td><td style=font-size:11px>'+enemy+'</td><td style=font-size:11px>'+force+'</td><td><span class="hres '+rcls+'"'+(r=='sent'?' title="Sent — awaiting the real battle report. A rally takes a while (assembly + march + combat); then it shows WON/LOST, or done? if no report is captured in time."':'')+'>'+rl+'</span>'+note+'</td></tr>';
}
async function loadHistory(){try{const d=await j('/api/history');HIST=d.events||[];renderHistory();}catch(e){}}
function renderHistory(){const tb=document.getElementById('htb');if(!tb)return;const f=(document.getElementById('hfilter')||{}).value||'all';
  const evs=HIST.filter(e=>f=='all'||e.kind==f);
  tb.innerHTML=evs.length?evs.map(histRow).join(''):'<tr><td colspan=7 class=dim>No '+(f=='all'?'':f+' ')+'events yet.</td></tr>';
  const inf=document.getElementById('histinfo');if(inf)inf.textContent=evs.length+' events'+(f!='all'?' · '+HIST.length+' total':'');}
async function clearHistory(){
  const sel=document.getElementById('hfilter'); const f=(sel&&sel.value)||'all';
  if(f=='all'){
    if(!confirm('Clear the WHOLE history (all event types)?'))return;
    await j('/api/history_clear',{method:'POST',headers:{'Content-Type':'application/json'},body:'{}'});HIST=[];
  }else{
    const lbl=(sel&&sel.selectedOptions&&sel.selectedOptions[0])?sel.selectedOptions[0].textContent.trim():f;
    if(!confirm('Clear ONLY the "'+lbl+'" events? ('+HIST.filter(e=>e.kind==f).length+' rows) The rest of the history is kept.'))return;
    await j('/api/history_clear',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({kind:f})});HIST=HIST.filter(e=>e.kind!=f);
  }
  renderHistory();}
async function loadSteals(){try{const d=await j('/api/steals');STEALS=d.targets||[];
  if(!exclInit){EXCL=new Set((d.steal_exclude||'').split(',').map(x=>x.trim()).filter(Boolean));renderExclChips();exclInit=true}
  const inf=document.getElementById('stealinfo');if(inf)inf.innerHTML=d.error?('<span class=bad>'+d.error+'</span>'):(STEALS.length+' enemy attacks · <b class=ok>'+(d.feasible||0)+'</b> stealable'+(d.target_tags?(' · targeting <b>'+d.target_tags+'</b>'):' · all alliances'));
  renderSteals()}catch(e){}}
function renderSteals(){const tb=document.getElementById('stb');if(!tb)return;tb.innerHTML='';
  if(!STEALS.length){tb.innerHTML='<tr><td colspan=13 class=dim>No active enemy attacks on monsters right now.</td></tr>';return}
  STEALS.forEach(s=>{const tr=document.createElement('tr');if(s.feasible)tr.className='selrow';
    const ph=s.phase||'';   // fase del rally ENEMIGO (de V3): wait=gathering, way=marching, combat, march=solo, scout
    const phLab=ph==='wait'?'WAIT':ph==='way'?'MARCH':ph==='combat'?'COMBAT':ph==='scout'?'SCOUT':(ph?'SOLO':'');
    const phTit=ph==='wait'?'enemy rally gathering (forming up)':ph==='way'?'enemy rally marching to the monster':ph==='combat'?'enemy rally fighting the monster':ph==='scout'?'scouting':(ph?'solo attack (not a rally)':'no phase data');
    tr.innerHTML=`<td>${s.name||('#'+s.id)}</td><td>${phLab?('<span class="ph ph-'+ph+'" title="'+phTit+'">'+phLab+'</span>'):'<span class=dim>—</span>'}</td><td>${s.level||''}</td><td class=grp>${s.group||''}</td><td>${s.tx&&s.ty?('<span class=copyc title="Click to copy coordinates" onclick="copyCoord(event,'+s.tx+','+s.ty+')">'+s.tx+','+s.ty+'</span>'):'<span class=dim>—</span>'}</td><td>${s.dist!=null?s.dist+' km':'<span class=dim>—</span>'}</td><td>${s.power?fmtK(s.power):'<span class=dim>?</span>'}</td><td>${s.atk_name||'<span class=dim>—</span>'}</td><td>${s.atk_tag||'<span class=dim>—</span>'}</td>
      <td>${s.their_kill}s <span class=dim>(${s.phase}${s.their_kill!=s.their_eta?' '+s.their_eta+'+'+(s.their_kill-s.their_eta):''})</span></td><td>${s.stealing?(s.steal_eta!=null?('<span class=ok title="ETA real de NUESTRA marcha de robo">'+Math.floor(s.steal_eta/60)+':'+('0'+(s.steal_eta%60)).slice(-2)+'</span>'):'<span class=dim>launching</span>'):('~'+s.our_eta+'s')}</td>
      <td>${s.stealing?'<span class=ok title="our steal march is on the way">🏴 Stealing… '+(s.steal_eta!=null?(Math.floor(s.steal_eta/60)+':'+('0'+(s.steal_eta%60)).slice(-2)):'launching')+'</span>':(s.recently_stolen?'<span class=dim title="Steal march already SENT to this tile in the last ~20 min — not re-attacking. This does NOT mean we won: whoever lands first (us or the enemy) gets the kill; the monster is being resolved.">↗ sent</span>':(s.feasible?'<span class=ok>✓ yes</span>':(s.excluded?'<span class=bad>excluded</span>':(s.low_power?'<span class=bad title="monster power below your Min power threshold — skipped">⬇ low power</span>':(s.targetable&&!s.winnable?(s.no_steal_preset?'<span class=bad title="No tienes ningún preset en modo 🏴 STEAL. Pon un preset en modo steal con tropas para poder robar.">no steal preset</span>':'<span class=bad title="preset '+fmtThou(s.preset_pow)+' vs monster '+fmtThou(s.power)+' · need '+fmtThou(s.need_pow)+' (win ratio)">too strong</span>'):(s.targetable&&s.match=='name'?'<span class=bad>id uncertain</span>':(s.targetable?'<span class=dim>too slow</span>':'<span class=bad>no id</span>')))))))}</td><td>${(s.stealing||s.recently_stolen)?'<span class=dim>—</span>':((s.id&&s.tx)?'<button class=sec style="padding:3px 9px;font-size:11px" onclick="stealOne('+s.tx+','+s.ty+','+s.id+','+(s.level||0)+')">⚔ Attack</button>':'<span class=dim>—</span>')}</td>`;
    tb.appendChild(tr)})}
async function stealOne(tx,ty,id,level){
  const s=(STEALS||[]).find(x=>x.tx==tx&&x.ty==ty)||{};
  if(s.stealing) return;                                  // ya se está robando
  if(!s.feasible){                                        // RED FLAG -> avisa el motivo y NO ataca
    const why = s.no_steal_preset ? 'No STEAL-mode preset with troops. Set a preset to 🏴 steal mode first (March queue).'
      : (s.targetable&&!s.winnable) ? ('Too strong: your steal preset '+fmtThou(s.preset_pow)+' vs monster '+fmtThou(s.power)+' (need '+fmtThou(s.need_pow)+'). Lower target or raise Win ratio.')
      : s.excluded ? 'Excluded by your never-steal list.'
      : s.low_power ? ('Low power: monster '+fmtThou(s.power)+' is below your Min power threshold. Lower "Min power (M)" to steal it.')
      : (s.match=='name') ? 'ID uncertain for this target, not safe to launch.'
      : ('Too far: we would arrive after the enemy kills it (our ETA '+(s.our_eta||0)+'s vs kill '+(s.their_kill||0)+'s).');
    alert(why); return;
  }
  const r=await j('/api/steal_one',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({tx,ty,id,level})});   // todo OK -> ataca directo (sin confirm ni alert de éxito)
  if(r&&!r.ok) alert('Steal failed: '+((r.error)||'unknown'));
  loadSteals()}
function copyCoord(ev,x,y){ev.stopPropagation();const el=ev.currentTarget,t=x+','+y;
  const ok=()=>{const o=el.textContent;el.textContent='✓ copied';el.classList.add('cok');setTimeout(()=>{el.textContent=o;el.classList.remove('cok')},900)};
  if(navigator.clipboard&&navigator.clipboard.writeText){navigator.clipboard.writeText(t).then(ok).catch(()=>fbCopy(t,ok))}else fbCopy(t,ok)}
function fbCopy(t,ok){try{const ta=document.createElement('textarea');ta.value=t;ta.style.cssText='position:fixed;opacity:0';document.body.appendChild(ta);ta.focus();ta.select();document.execCommand('copy');ta.remove();ok()}catch(e){}}
/* toggleSteal eliminado: el robo de rallies ahora corre bajo el AUTO global (toggle único tras el card de presets). */
function markStealDirty(){const b=document.getElementById('stealsavebtn');if(b)b.classList.add('dirty')}
function clearStealDirty(){const b=document.getElementById('stealsavebtn');if(b)b.classList.remove('dirty')}
(function(){['ttags','spt','sbuf','swr','sminpow','scmargin','swm'].forEach(id=>{const el=document.getElementById(id);if(el){el.addEventListener('input',markStealDirty);el.addEventListener('change',markStealDirty)}})})();
async function saveStealCfg(){await j('/api/config',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({target_tags:document.getElementById('ttags').value||''})});clearStealDirty();loadSteals()}
async function saveExpert(sec){var b;
  if(sec=='t'){b={margin:+document.getElementById('ex_mg').value||1.2,win_ratio:+document.getElementById('ex_wr').value||0.8,max_slots:+document.getElementById('ex_ms').value||6,cooldown:+document.getElementById('ex_cd').value||360,farm_seen_max:+document.getElementById('ex_fsm').value||120,rally_seen_max:+document.getElementById('ex_rsm').value||600,wipe_strikes:+document.getElementById('ex_ws').value||2,wipe_frac:+document.getElementById('ex_wf').value||90,wipe_window_h:+document.getElementById('ex_ww').value||6,farm_jitter:document.getElementById('ex_jit').checked};}
  else{b={steal_sec_per_tile:+document.getElementById('spt').value||1.5,steal_enemy_buffer:+document.getElementById('sbuf').value||90,win_ratio:+document.getElementById('swr').value||0.8,steal_min_power:+document.getElementById('sminpow').value||0,steal_combat_margin:+document.getElementById('scmargin').value||25,steal_win_margin:+document.getElementById('swm').value||0};clearStealDirty();}
  await j('/api/config',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(b)});
  var el=document.getElementById('exinfo_'+sec);if(el){el.textContent='✓ saved';setTimeout(function(){el.textContent='';},1500);}
  if(sec=='s')loadSteals();}
async function resetExpert(sec){if(!confirm('Reset '+(sec=='t'?'Targets':'Steal')+' Expert Mode to defaults?'))return;
  await j('/api/config',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({reset_expert:sec=='t'?'targets':'steals'})});
  var el=document.getElementById('exinfo_'+sec);if(el){el.textContent='↺ reset';setTimeout(function(){el.textContent='';},1500);}
  if(sec=='s')loadSteals();}
async function toggleExpert(sec){var sw=document.getElementById('expsw_'+sec),bd=document.getElementById('expbody_'+sec);var open=!sw.classList.contains('on');
  sw.classList.toggle('on',open);bd.style.display=open?'block':'none';
  await j('/api/config',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(sec=='t'?{expert_targets_open:open}:{expert_steals_open:open})});}
function showInfo(ev,el){ev.stopPropagation();var p=document.getElementById('infopop');if(!p){p=document.createElement('div');p.id='infopop';document.body.appendChild(p);}if(p.style.display=='block'&&p._o===el){p.style.display='none';return;}p.textContent=el.getAttribute('data-info')||'';p._o=el;p.style.display='block';var r=el.getBoundingClientRect(),pw=p.offsetWidth,ph=p.offsetHeight,left=r.left,top=r.bottom+6;if(left+pw>innerWidth-8)left=innerWidth-pw-8;if(left<8)left=8;if(top+ph>innerHeight-8)top=r.top-ph-6;if(top<8)top=8;p.style.left=left+'px';p.style.top=top+'px';}
document.addEventListener('click',function(e){var p=document.getElementById('infopop');if(p&&p.style.display=='block'&&!p.contains(e.target))p.style.display='none';});
function markRefillDirty(){const b=document.getElementById('refillsavebtn');if(b)b.classList.add('dirty')}
function clearRefillDirty(){const b=document.getElementById('refillsavebtn');if(b)b.classList.remove('dirty')}
async function toggleRefill(){const on=document.getElementById('refillchk').checked;
  if(on&&!confirm('Enable stamina auto-refill? When stamina drops below the threshold, the bot will consume stamina items from your bag to reach the target.')){document.getElementById('refillchk').checked=false;return}
  await j('/api/refill',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({on})})}
async function saveRefillCfg(){await j('/api/refill',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({threshold:+document.getElementById('rthr').value||0,target:+document.getElementById('rtgt').value||0})});clearRefillDirty()}
async function wheelCredits(){await j('/api/wheel_credits',{method:'POST',headers:{'Content-Type':'application/json'},body:'{}'})}
async function wheelSpin(){const n=+document.getElementById('wheeln').value||10;
  const cost=((window.WHEELST||{}).cost||9000);
  if(!confirm('Spin the Wheel of Fortune '+n+'×100 times?\n\nThis consumes ~'+fmtThou(cost*n)+' wheel credits (≈'+fmtThou(cost)+'/batch). It stops automatically when you run out — it will NOT buy credits with gems.\n\nRewards are random and include stamina lots.'))return;
  const wi=document.getElementById('wheelinfo');if(wi)wi.textContent='🎡 starting…';
  await j('/api/wheel_spin',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({n})})}
async function refillNow(){const el=document.getElementById('refillinfo');el.textContent='refilling…';
  const r=await j('/api/refill_now',{method:'POST',headers:{'Content-Type':'application/json'},body:'{}'});
  el.textContent=r.ok?(r.plan?('used '+r.plan.map(p=>'+'+(STAMGAIN[p[0]]||'?')+'×'+p[1]).join(', ')+' (~+'+r.gained+')'):(r.skipped||'nothing to do')):('✗ '+(r.error||'failed'));}
// Botón "Get Stamina": cobra los reward mails a mano (stamina que comparten los compañeros).
// El cobro es ASÍNCRONO: se dispara con POST y luego se consulta el último resultado con GET,
// porque el agente contesta unos segundos después (por eso no se puede devolver el conteo ya).
async function getStaminaMail(){
 const el=document.getElementById('mailinfo'), b=document.getElementById('mailbtn');
 if(b) b.disabled=true; if(el) el.textContent='checking mails…';
 try{
   const r=await j('/api/claim_mails',{method:'POST',headers:{'Content-Type':'application/json'},body:'{}'});
   if(!r || !r.ok){ if(el) el.textContent='✗ could not ask the agent (is it attached?)'; return; }
   for(let i=0;i<7;i++){
     await new Promise(s=>setTimeout(s,1500));
     const g=await j('/api/claim_mails');
     if(g && g.age!==null && g.age<=20){
       if(el) el.textContent = (g.found>0) ? ('✓ '+g.found+' mail(s) with rewards claimed') : '✓ no mails with rewards';
       try{ loadAccount(); }catch(e){}   // refresca el chip de stamina
       return;
     }
   }
   if(el) el.textContent='asked; the agent is taking a while — check the log';
 }catch(e){ if(el) el.textContent='✗ '+e; }
 finally{ if(b) b.disabled=false; }
}
function markTruceDirty(){const b=document.getElementById('trucesavebtn');if(b)b.classList.add('dirty')}
function clearTruceDirty(){const b=document.getElementById('trucesavebtn');if(b)b.classList.remove('dirty')}
async function toggleTruce(){const on=document.getElementById('trucechk').checked;
  if(on&&!confirm('Enable Auto Truce Agreement?\\n\\nWhen your Peace Shield drops below the set hours, the bot uses a Truce Agreement from your bag to keep the bubble up. It uses the LONGEST-duration agreement available first (fewer renewals) and NEVER uses gems.')){document.getElementById('trucechk').checked=false;return}
  await j('/api/truce',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({on})})}
async function saveTruceCfg(){await j('/api/truce',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({renew_h:+document.getElementById('trenewh').value||0})});clearTruceDirty()}
async function truceNow(){const el=document.getElementById('truceinfo');el.textContent='renewing…';
  const r=await j('/api/truce_now',{method:'POST',headers:{'Content-Type':'application/json'},body:'{}'});
  el.textContent=r.ok?(r.used?('✓ used '+Math.round((r.dur||0)/3600)+'h truce'):(r.skipped||'nothing to do')):('✗ '+(r.error||'failed'));}
function fmtDur(s){s=Math.max(0,s|0);const d=Math.floor(s/86400),h=Math.floor((s%86400)/3600),m=Math.floor((s%3600)/60);return d>0?(d+'d '+h+'h'):(h>0?(h+'h '+m+'m'):(m+'m'));}
function renderTruce(ac){
  const sl=document.getElementById('shieldleft'); if(sl){const left=(ac&&ac.peace_shield_left!=null)?ac.peace_shield_left:-1;
    if(left<0){sl.textContent='—';sl.style.color='#8b949e';}
    else if(left===0){sl.textContent='⚠ DOWN (no bubble)';sl.style.color='#f85149';}
    else{sl.textContent=fmtDur(left)+' left';sl.style.color=(left<7200?'#f0b229':'#3fb950');}}
  const ti=document.getElementById('truceitems'); if(ti){const its=(ac&&ac.truce_items)||[];
    ti.innerHTML=its.length?its.slice().sort((a,b)=>a.dur-b.dur).map(it=>'<b style=color:#79c0ff>'+Math.round(it.dur/3600)+'h</b>×'+it.num).join('  ·  '):'<span style=color:#f85149>none — bubble cannot be renewed!</span>';}
}
function markHealDirty(){const b=document.getElementById('healsavebtn');if(b)b.classList.add('dirty')}
function clearHealDirty(){const b=document.getElementById('healsavebtn');if(b)b.classList.remove('dirty')}
async function toggleHeal(){const on=document.getElementById('healchk').checked;
  if(on&&!confirm('Enable troop auto-heal?\n\nWhen the hospital queue is free and there are wounded troops, the bot will heal them ALL using RESOURCES + TIME (never gems/speedups). Big heals (high-tier troops) can take a long time and lock the hospital — watch the heal ETA.')){document.getElementById('healchk').checked=false;return}
  await j('/api/heal',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({on})})}
async function saveHealCfg(){await j('/api/heal',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({threshold:+document.getElementById('hthr').value||1})});clearHealDirty()}
// RALLY MONTHLY CARD — auto-join de la tarjeta (excluyente con el AUTO global). Estado leído del juego (autoRallyData).
let RC={active:false,secs:0,t0:0,cardExpire:0,owned:null};
function fmtRcLeft(s){s=Math.max(0,s|0);const h=Math.floor(s/3600),m=Math.floor((s%3600)/60);if(h>0)return h+'h '+m+'m';return m+'m '+(s%60)+'s';}
function fmtCardLeft(s){s=Math.max(0,s|0);const d=Math.floor(s/86400),h=Math.floor((s%86400)/3600),m=Math.floor((s%3600)/60);if(d>0)return d+'d '+h+'h';if(h>0)return h+'h '+m+'m';return m+'m';}
function renderRc(){const on=RC.active;const fl=document.getElementById('rcflag');if(fl){fl.textContent=on?'ON':'OFF';fl.className='autoflag '+(on?'on':'gray');}
  const card=document.getElementById('rcCard');if(card){card.classList.toggle('rc-on',on);card.classList.toggle('rc-off',!on);}
  const cel=document.getElementById('rcCardLeft');const cleft=RC.cardExpire>0?(RC.cardExpire-Math.floor(Date.now()/1000)):0;   // días de SUSCRIPCIÓN -> arriba a la derecha (azul; naranja si <3d)
  if(cel){if(RC.cardExpire>0&&cleft>0){cel.textContent=fmtCardLeft(cleft);cel.style.color=cleft<3*86400?'#f0883e':'#58a6ff';cel.style.display='';}else cel.style.display='none';}
  const row2=document.getElementById('rcRow2'),tw=document.getElementById('rcTimerWrap'),tel=document.getElementById('rcTimer');   // fila 2 SOLO cuando el auto-join está ON (timer 8h + estado)
  if(on){if(row2)row2.style.display='';const left=RC.secs>0?Math.max(0,RC.secs-Math.floor((performance.now()-RC.t0)/1000)):0;if(tw)tw.style.display=RC.secs>0?'':'none';if(tel&&RC.secs>0)tel.textContent=fmtRcLeft(left);}
  else if(row2)row2.style.display='none';}
async function toggleRallyCard(){const on=document.getElementById('rcchk').checked;
  if(on&&!confirm('Enable the Rally Monthly Card auto-join?\n\nThe card auto-joins alliance rallies on its own (8h timer, refreshed each time). This turns OFF the global AUTO — they are mutually exclusive.')){document.getElementById('rcchk').checked=false;return}
  await j('/api/rally_card',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({on})});
  RC.active=on;RC.secs=on?8*3600:0;RC.t0=performance.now();renderRc();}
setInterval(renderRc,1000);
async function healNow(){const el=document.getElementById('healinfo');const H=window.HEALST||{};const busy=(H.healing||0)>0;let cf=false;
  if(busy){if(!confirm('The hospital is already healing '+fmtThou(H.healing||0)+' troops.\n\nCancel that heal (returns those troops + resources) and re-heal ALL '+fmtThou(H.wounded||0)+' wounded now?'))return;cf=true;}
  else{if((H.wounded||0)<=0){el.textContent='no wounded';return;} if(!confirm('Heal all '+fmtThou(H.wounded||0)+' wounded troops now?\n\nUses resources + time (never gems).'))return;}
  el.textContent='healing…';
  const r=await j('/api/heal_now',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({cancel_first:cf})});
  el.textContent=r.ok?(r.skipped||('✓ healing '+fmtThou(r.wounded||0))):('✗ '+(r.error||'failed'));}
let EXCL=new Set(), exclInit=false;
function exclNames(){return [...new Set((MONS||[]).map(m=>m.name).filter(Boolean))].sort()}
function renderExclChips(){const box=document.getElementById('msbox_excl'),inp=document.getElementById('msin_excl');if(!box)return;
  [...box.querySelectorAll('.chip')].forEach(c=>c.remove());
  [...EXCL].forEach(nm=>{const c=document.createElement('span');c.className='chip';c.innerHTML=nm+' <b>&times;</b>';
    c.querySelector('b').onclick=()=>{EXCL.delete(nm);renderExclChips();saveExcl()};box.insertBefore(c,inp)})}
function renderExclDD(){const inp=document.getElementById('msin_excl'),dd=document.getElementById('dd_excl');if(!dd)return;
  const q=inp.value.trim().toLowerCase();
  const list=exclNames().filter(nm=>!EXCL.has(nm)&&(!q||nm.toLowerCase().includes(q))).slice(0,60);
  dd.innerHTML='';if(!list.length){dd.classList.remove('open');return}
  list.forEach(nm=>{const d=document.createElement('div');d.className='opt';d.textContent=nm;
    d.onclick=()=>{EXCL.add(nm);inp.value='';renderExclChips();renderExclDD();saveExcl();inp.focus()};dd.appendChild(d)});
  dd.classList.add('open')}
async function saveExcl(){await j('/api/config',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({steal_exclude:[...EXCL].join(',')})});loadSteals()}
let GENS=[], TROOPSL=[], PRESETS=[], dragIdx=null, tgtDragKey=null, tgtDragPi=null, MAX_SEND={}, MAX_SEND_CONF={}, _maxSendSig='';
async function loadAccount(){try{const a=await j('/api/account');GENS=(a.generals_list||[]).slice().sort((x,y)=>(y.power||0)-(x.power||0));TROOPSL=(a.troops||[]).slice().sort((x,y)=>(y.power||0)-(x.power||0));}catch(e){}}
function genOpts(s,dis){return '<option value=0>—</option>'+GENS.map(g=>{const sel=(g.id==s);
  if(g.marchable===false && !sel) return '';                                  // en servicio de edificio (no marchable) -> fuera de la lista
  const used=dis&&dis.has(g.id)&&!sel;
  const dd=used||(g.marchable===false&&sel);
  const tag=(g.marchable===false)?' · ⛔ on duty':(used?' · ⛔ in use':'');
  return '<option value='+g.id+(sel?' selected':'')+(dd?' disabled':'')+'>'+(g.name||('#'+g.id))+' · Lv.'+(g.level||0)+' · '+fmtK(g.power||0)+tag+'</option>'}).join('')}
function troopOpts(s){return '<option value=0>—</option>'+TROOPSL.map(t=>'<option value='+t.id+(t.id==s?' selected':'')+'>'+(t.name||('T'+t.tier))+' · T'+t.tier+' · '+fmtK((t.power||0)*(t.num||0))+' · ('+fmt(t.num)+' units)</option>').join('')}
function troopLine(i,ti,t,withAdd){return '<div class="row tline"><select class="sel ptt" onchange="collectPresets();renderPresets();markPresetsDirty()">'+troopOpts(t.t)+'</select> <span class=mul>×</span> <input type=number class=ptn data-p='+i+' data-ti='+ti+' value='+(t.n||0)+' oninput="clampTroop(this)"> <button class="sec del" onclick=delTroop('+i+','+ti+')>✕</button>'+(withAdd?' <button class="sec addt" onclick=addTroop('+i+')>+ ADD TROOP</button>':'')+'</div>'}
function troopTotalOf(tid){const t=TROOPSL.find(x=>x.id==tid);return t?(+t.num||0):0}
function troopUsedElsewhere(tid,line){let used=0;document.querySelectorAll('#presets .tline').forEach(ln=>{if(ln===line)return;const tt=+ln.querySelector('.ptt').value;if(tt===tid)used+=Math.max(0,+ln.querySelector('.ptn').value||0)});return used}
function clampTroop(el){const line=el.closest('.tline');if(line){const tid=+line.querySelector('.ptt').value;if(tid>0){const av=Math.max(0,troopTotalOf(tid)-troopUsedElsewhere(tid,line));let v=Math.max(0,Math.floor(+el.value||0));if(v>av){v=av;el.value=v}}
  const pi=+el.dataset.p, gid=((PRESETS[pi]||{}).general_id)||0, cap=Math.floor((MAX_SEND[gid]||0)*0.98);   // tope de marcha del general (max_send): la suma del preset no puede pasarlo
  if(cap>0){let others=0;document.querySelectorAll('.ptn[data-p="'+pi+'"]').forEach(o=>{if(o!==el)others+=Math.max(0,Math.floor(+o.value||0))});const left=Math.max(0,cap-others);if(Math.floor(+el.value||0)>left)el.value=left}}collectPresets();markPresetsDirty();refreshTroopAvail()}
// total units of each troop already assigned across ALL preset lines
function troopUsedMap(){const m={};document.querySelectorAll('#presets .tline').forEach(line=>{const tid=+line.querySelector('.ptt').value;if(!tid)return;const n=Math.max(0,Math.floor(+line.querySelector('.ptn').value||0));m[tid]=(m[tid]||0)+n});return m;}
// append the still-free units (total - assigned everywhere) after the total, in each troop option: "(N units) - (X left)"
function refreshTroopAvail(){const used=troopUsedMap();document.querySelectorAll('#presets .ptt').forEach(sel=>{[...sel.options].forEach(opt=>{const tid=+opt.value;if(!tid)return;const t=TROOPSL.find(x=>x.id==tid);if(!t)return;const rem=Math.max(0,(+t.num||0)-(used[tid]||0));opt.textContent=(t.name||('T'+t.tier))+' · T'+t.tier+' · '+fmtK((t.power||0)*(t.num||0))+' · ('+fmt(t.num)+' units) - ('+fmt(rem)+' left)';});});}
function renderPresets(){const e=document.getElementById('presets');if(!e)return;let h='';
  const USEDG=new Set();PRESETS.forEach(p=>{const g=+p.general_id||0,a=+p.assistant_id||0;if(g)USEDG.add(g);if(a)USEDG.add(a);});  // generales/asistentes ya usados -> no re-seleccionables
  PRESETS.forEach((p,i)=>{const aj=(p.mode==='autojoin');h+='<div class="preset'+(p.enabled?' on':'')+'" data-idx='+i+' ondragover="dragOver(event)" ondragleave="dragLeave(event)" ondrop="dragDrop(event,'+i+')">';
    h+='<div class="row prow"><span class=handle draggable=true ondragstart="dragStart(event,'+i+')" title="drag to reorder">⠿</span>';
    h+='<label class=pen-lbl><span class=toggle><input type=checkbox class=pen data-p='+i+' '+(p.enabled?'checked':'')+' onchange="collectPresets();renderPresets();markPresetsDirty()"><span class=slider></span></span> <span class=pnum>Preset '+(i+1)+'</span></label>';
    h+=' <span class=ppow id=pp_'+i+' title="Combat power of this preset (sum of troop power). Compare with the monster power in the Targets table to know if you can beat it.">⚔ '+fmtK(presetPow(p))+'</span>';
    h+=overrideHtml(p,i);
    h+='<span class=fld><span class=flbl>mode</span><select class="sel pmode m-'+p.mode+'" data-p='+i+' onchange="onPresetMode(this)"><option value=solo '+(p.mode=='solo'?'selected':'')+'>SOLO</option><option value=rally '+(p.mode=='rally'?'selected':'')+'>RALLY</option><option value=steal '+(p.mode=='steal'?'selected':'')+'>STEAL</option><option value=autojoin '+(p.mode=='autojoin'?'selected':'')+'>AUTO-JOIN</option></select></span>';
    h+=capChip(p)+'</div>';
    h+=targetSelectorHtml(p,i);
    h+='<div class="row prow grow"><span class=fld><span class=flbl>general</span><select class="sel pgen" data-p='+i+' onchange="collectPresets();renderPresets();markPresetsDirty()">'+genOpts(p.general_id,USEDG)+'</select></span><span class=fld><span class=flbl>assistant</span><select class="sel past" data-p='+i+' onchange="collectPresets();renderPresets();markPresetsDirty()">'+genOpts(p.assistant_id,USEDG)+'</select></span></div>';
    h+='<div class=ptroops data-p='+i+(aj?' style=display:none':'')+'>';var _tr=(p.troops||[]);_tr.forEach((t,ti)=>{h+=troopLine(i,ti,t,ti===_tr.length-1&&!aj)});if(!_tr.length&&!aj)h+='<button class="sec addt" onclick=addTroop('+i+')>+ ADD TROOP</button>';h+='</div>'+(aj?'<div class=dim style="font-size:11px;padding:3px 0">🤝 auto-join — joins with 1× T1 soldier (token march); your troops return when you switch mode.</div>':'')+''+'<div class=pmwrap><span class=pmarch id=pm_'+i+'></span>'+(p.mode=='rally'?rallyBtnsHtml(i):'')+'</div>'+'<span class=pcap id=pcap_'+i+' style="margin-left:8px;font-size:11px;color:#8b949e"></span><span class=pboost id=pboost_'+i+' style="margin-left:8px;font-size:11px"></span><span class=pwarn id=pw_'+i+' style="margin-left:8px;font-size:11px"></span></div>';});
  e.innerHTML=h;refreshTroopAvail();}
function presetPow(p){if(p.mode==='autojoin'){let s=0;const gg=GENS.find(x=>x.id==p.general_id);if(gg)s+=(+gg.power||0);const aa=GENS.find(x=>x.id==p.assistant_id);if(aa)s+=(+aa.power||0);const t1=TROOPSL.find(x=>x.id==pickT1());if(t1)s+=(+t1.power||0);return s;}  /* AUTO-JOIN marches with 1×T1 only — don't show the stored army's power */
  const gid=+p.general_id||0,cap=MAX_SEND[gid]||0;const tot=(p.troops||[]).reduce((a,tr)=>a+Math.max(0,+tr.n||0),0);const f=(cap>0&&tot>cap*0.98)?(cap*0.98/tot):1;/* clamp troops to the general's march cap (same as backend _eff_troops) so the shown power is what REALLY marches */let s=(p.troops||[]).reduce((a,tr)=>{const m=TROOPSL.find(x=>x.id==tr.t);return a+(m?(m.power||0)*Math.floor((+tr.n||0)*f):0)},0);const g=GENS.find(x=>x.id==p.general_id);if(g)s+=(+g.power||0);const a=GENS.find(x=>x.id==p.assistant_id);if(a)s+=(+a.power||0);return s}
// ── BLITZ / CANCEL del rally de alianza. SIEMPRE manuales (el bot nunca los lanza solo).
//    Blitz = "Attack Now" del juego: la marcha sale YA, sin esperar el temporizador de reunión. CUESTA 1.000 GEMAS.
//    Cancel = "Cancel Alliance War": disuelve el rally que lidera este preset y devuelve las tropas. Gratis.
function rallyBtnsHtml(i){return '<span class=rallybtns id=rlyb_'+i+' style="display:none">'
  +'<button class=cancelbtn id=clr_'+i+' onclick="cancelRallyBtn('+i+')" title="Cancel this preset&#39;s alliance rally (the game&#39;s &quot;Cancel Alliance War&quot;). The rally is dissolved, the general is freed and your troops come back. No gems.">\u2716 Cancel rally</button>'
  +'<button class=gemsbtn id=blz_'+i+' onclick="blitzRallyBtn('+i+')" title="BLITZ \u2014 launch this preset&#39;s rally RIGHT NOW without waiting for the assembly timer (the game&#39;s &quot;Attack Now&quot;). COSTS 1,000 GEMS.">\u26a1 Blitz <span style="font-size:10px;opacity:.85">1,000 \ud83d\udc8e</span></button>'
  +'<span class=rlymsg id=rly_'+i+'></span></span>';}
function rlyMsg(i,txt,cls){const el=document.getElementById('rly_'+i);if(el)el.innerHTML='<span class='+(cls||'dim')+'>'+txt+'</span>';}
// BLITZED[i] = clave del rally ya blitzeado por ese preset ("tx,ty"). Mientras siga siendo ESE rally el botón queda
// deshabilitado (evita pagar 1.000 gemas dos veces por lo mismo: el cambio de fase tarda en verse en el scan).
// Se limpia solo cuando el preset sale de 'gathering' (ese rally ya salió) o cuando empieza a reunir OTRO rally.
let BLITZED={};
function rlyKey(st){return (st&&st.tx!=null)?(st.tx+','+st.ty):'?';}
function syncRallyBtns(){
  const now=Date.now();
  for(let i=0;i<(PRESETS||[]).length;i++){
    const st=PM[i], gath=!!(st&&st.phase=='gathering'), box=document.getElementById('rlyb_'+i);
    const bz=BLITZED[i];
    if(bz){                                                          // ¿se puede volver a usar?
      if(gath&&bz.key!='?'&&rlyKey(st)!=bz.key)delete BLITZED[i];     // ya reúne OTRO rally -> re-armar
      else if(!gath&&(now-bz.ts)>20000)delete BLITZED[i];             // dejó de reunir hace >20s -> ese rally salió (el margen evita la carrera: entre scans el preset puede quedarse un instante sin marcha y volver a 'gathering' siendo el MISMO rally)
    }
    if(box)box.style.display=gath?'inline-flex':'none';               // OCULTOS salvo en 'gathering' (única fase en la que el juego permite Attack Now / Cancel)
    if(!gath)continue;
    const done=!!BLITZED[i];
    const b=document.getElementById('blz_'+i); if(b)b.disabled=done;
    const c=document.getElementById('clr_'+i); if(c)c.disabled=done;  // si ya va lanzado, cancelar tampoco aplica
    if(done)rlyMsg(i,'\u26a1 launched \u00b7 waiting for the march to start','ok');
  }
}
async function blitzRallyBtn(i){
  if(!confirm('BLITZ \u2014 launch preset '+(i+1)+'\u2019s rally NOW?\n\nThis is the game\u2019s "Attack Now": the rally marches immediately, without waiting for the assembly timer (allies who have not joined yet will be left out).\n\n\u26a0 IT COSTS 1,000 GEMS.'))return;
  const b=document.getElementById('blz_'+i); if(b)b.disabled=true;   // bloquea el doble click ANTES de saber la respuesta
  rlyMsg(i,'sending\u2026','dim');
  try{const r=await j('/api/rally_blitz',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({preset:i,cost:1000})});
    if(r.ok)BLITZED[i]={key:rlyKey(PM[i]),ts:Date.now()};            // queda deshabilitado hasta que ESE rally salga o empiece otro
    else if(b)b.disabled=false;                                      // fallo -> se puede reintentar
    rlyMsg(i, r.ok?('\u26a1 launched (war '+r.war_id+', '+fmtThou(r.cost||1000)+' gems)'):('\u2717 '+(r.error||'failed')), r.ok?'ok':'bad');
  }catch(e){if(b)b.disabled=false;rlyMsg(i,'\u2717 request failed','bad');}}
async function cancelRallyBtn(i){
  if(!confirm('Cancel preset '+(i+1)+'\u2019s alliance rally?\n\nThe rally is dissolved: your general is freed and the troops come back. Allies who joined are released too. No gems.'))return;
  const c=document.getElementById('clr_'+i); if(c)c.disabled=true;
  rlyMsg(i,'cancelling\u2026','dim');
  try{const r=await j('/api/rally_cancel',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({preset:i})});
    if(!r.ok&&c)c.disabled=false;
    rlyMsg(i, r.ok?('\u2716 cancelled (war '+r.war_id+')'):('\u2717 '+(r.error||'failed')), r.ok?'ok':'bad');
  }catch(e){if(c)c.disabled=false;rlyMsg(i,'\u2717 request failed','bad');}}
function capChip(p){if((p.mode||'')==='autojoin')return '';   // autojoin se une con 1xT1: el tope de marcha no aplica
  var gid=+p.general_id||0;if(!gid)return '';
  var cap=MAX_SEND[gid]||0,conf=!!(MAX_SEND_CONF&&MAX_SEND_CONF[gid]);var cls,txt,ti;
  if(!cap){cls='learn';txt='learning\u2026';ti='The bot has not probed this general yet. While farming it sends your configured troops and learns the real cap.';}
  else if(conf){cls='ok';txt=fmtThou(cap)+' \u2713';ti='March cap CONFIRMED: a march of this size went out successfully (or you set it by hand).';}
  else{cls='probe';txt='\u2264'+fmtThou(cap)+' \u2026';ti='Tuning: there were rejections, the real cap is below this value. Still converging.';}
  return '<span class="fld mcap mcap-'+cls+'" title="'+ti+'"><span class=flbl>march cap</span><span class=mcaprow><b>'+txt+'</b><span class=mcapb onclick="editCap('+gid+')" title="Set by hand">\u270e</span><span class=mcapb onclick="resetCap('+gid+')" title="Re-learn (reset)">\u21bb</span></span></span>';}
function editCap(gid){var cur=MAX_SEND[gid]||0;var v=prompt('March cap of this general (number of troops). Leave empty to RE-LEARN:',cur||'');if(v===null)return;var cap=Math.max(0,parseInt(String(v).replace(/[^0-9]/g,''),10)||0);setCap(gid,cap);}
function resetCap(gid){if(!confirm('Reset the cap of this general? The bot will re-learn it on its own.'))return;setCap(gid,0);}
async function setCap(gid,cap){var r=await j('/api/max_send',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({gid:gid,cap:cap})});MAX_SEND=r.max_send||MAX_SEND;MAX_SEND_CONF=r.max_send_conf||{};renderPresets();}
async function resetAllCaps(){if(!confirm('Reset the march cap of ALL generals? The bot will re-learn them.'))return;var r=await j('/api/max_send',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({reset_all:true})});MAX_SEND=r.max_send||{};MAX_SEND_CONF=r.max_send_conf||{};renderPresets();}
async function resetBlocks(){if(!confirm('Clear all auto-protection blocks? The bot will be able to attack the wiped monsters (e.g. Phoenix) again.'))return;await j('/api/reset_blocks',{method:'POST',headers:{'Content-Type':'application/json'},body:'{}'});WIPED={};_wpSig='';renderPresets();}
function refreshPresetPows(){try{collectPresets()}catch(e){}PRESETS.forEach((p,i)=>{const el=document.getElementById('pp_'+i);if(el)el.textContent='⚔ '+fmtK(presetPow(p))})}
function dragStart(ev,i){dragIdx=i;ev.dataTransfer.effectAllowed='move';try{ev.dataTransfer.setData('text','')}catch(e){}}
function dragOver(ev){ev.preventDefault();ev.currentTarget.classList.add('dragover')}
function dragLeave(ev){ev.currentTarget.classList.remove('dragover')}
function dragDrop(ev,i){ev.preventDefault();ev.currentTarget.classList.remove('dragover');
  if(dragIdx==null||dragIdx==i){dragIdx=null;return}
  collectPresets();const moved=PRESETS.splice(dragIdx,1)[0];PRESETS.splice(i,0,moved);dragIdx=null;
  renderPresets();savePresets()}
function collectPresets(){
  document.querySelectorAll('.pen').forEach(el=>PRESETS[+el.dataset.p].enabled=el.checked);
  document.querySelectorAll('.pmode').forEach(el=>PRESETS[+el.dataset.p].mode=el.value);
  document.querySelectorAll('.pgen').forEach(el=>PRESETS[+el.dataset.p].general_id=+el.value);
  document.querySelectorAll('.past').forEach(el=>PRESETS[+el.dataset.p].assistant_id=+el.value);
  PRESETS.forEach(p=>p.troops=[]);
  document.querySelectorAll('.ptroops').forEach(box=>{const i=+box.dataset.p;box.querySelectorAll('.row').forEach(line=>{const tt=+line.querySelector('.ptt').value||0,tn=Math.max(0,Math.floor(+line.querySelector('.ptn').value||0));if(tt>0)PRESETS[i].troops.push({t:tt,n:tn})})});}   // conserva las líneas con tipo elegido aunque n=0 (estás editando); las vacías/0 se filtran al GUARDAR (backend)
function addTroop(i){collectPresets();PRESETS[i].troops.push({t:0,n:0});renderPresets();markPresetsDirty()}
function delTroop(i,ti){collectPresets();PRESETS[i].troops.splice(ti,1);renderPresets();markPresetsDirty()}
function markPresetsDirty(){const b=document.getElementById('savebtn');if(b)b.classList.add('dirty')}
function clearPresetsDirty(){const b=document.getElementById('savebtn');if(b)b.classList.remove('dirty')}
function setModeBg(s){s.className='sel pmode m-'+s.value}
// pick the cheapest token troop for AUTO-JOIN: a T1 (tier 1) the account owns; fall back to the lowest-tier/lowest-power troop it has.
function pickT1(){const owned=(TROOPSL||[]).filter(t=>(+t.num||0)>0);if(!owned.length)return 0;
  owned.sort((a,b)=>((+a.tier||9)-(+b.tier||9))||((+a.power||0)-(+b.power||0)));return owned[0].id;}
// AUTO-JOIN swaps the preset's troops to a single T1 soldier (joining a rally only needs a token march) and backs up the real army; switching to any other mode restores it.
function onPresetMode(sel){const i=+sel.dataset.p;const newMode=sel.value;const oldMode=(PRESETS[i]||{}).mode;
  collectPresets();   // sync DOM -> PRESETS (mode = newMode, troops = current lines) BEFORE swapping
  const p=PRESETS[i];
  if(p){
    if(newMode==='autojoin'&&oldMode!=='autojoin'){
      p.troops_bak=JSON.parse(JSON.stringify(p.troops||[]));   // remember the army we had
      const t1=pickT1();p.troops=t1?[{t:t1,n:1}]:[];
    }else if(oldMode==='autojoin'&&newMode!=='autojoin'){
      if(p.troops_bak)p.troops=JSON.parse(JSON.stringify(p.troops_bak));   // restore it
      delete p.troops_bak;
    }
  }
  renderPresets();markPresetsDirty();}
function targetSelectorHtml(p,i){
  if(p.mode!='solo'&&p.mode!='rally')return '';
  const selm=(MONS||[]).filter(m=>m.on);
  const dp=(p.dist_prio!==false);   // toggle prioridad-por-distancia por preset (default ON)
  const distpill='<span class="tchip distpill'+(dp?' on':'')+'" onclick="toggleDistPrio('+i+')" title="Distance priority (per preset). ON = attack the NEAREST monster first. OFF = ignore distance and go by your monster priority order below (drag the pills), then by power.">⇄ '+(dp?'distance':'distance off')+'</span>';
  if(!selm.length)return '<div class="row ptgtrow"><span class=flbl>🎯 target</span> '+distpill+' <span class=dim style="font-size:11px">— mark monsters in the Targets tab first (this preset uses all of them) —</span></div>';
  const byKey={};selm.forEach(function(m){byKey[m.name+'|'+m.level]=m});
  const ordered=(p.targets||[]).filter(function(k){return byKey[k]});   // asignados Y aún marcados, EN ORDEN DE PRIORIDAD
  const assigned=new Set(ordered);
  var chips='';
  ordered.forEach(function(key,pos){var m=byKey[key];var oc=SHOW_OVERCAP&&OVERLIMIT.has(p.mode+'|'+key);var wp=WIPED[p.mode+'|'+key];chips+='<span class="tchip on ord'+(oc?' overcap':'')+(wp?' wiped':'')+'" draggable=true data-key="'+key+'" data-pi="'+i+'" ondragstart="tgtDragStart(event)" ondragover="tgtDragOver(event)" ondragleave="tgtDragLeave(event)" ondrop="tgtDrop(event)" onclick="toggleTarget('+i+',this)" title="'+(wp?('\u26d4 BLOCKED by auto-protection: this target WIPED OUT the army '+wp.n+' time(s) in the last 6h (lost \u226590% of the troops sent) \u2014 the bot will not attack it with this preset until the block expires (~6h) \u00b7 '):'')+(oc?'⚠ took troop losses in recent battles — near/over the preset limit · ':'')+'drag to reorder priority · click to remove"><span class=pri>'+(pos+1)+'</span>'+m.name+(m.level?' L'+m.level:'')+'</span>';});
  selm.forEach(function(m){var key=m.name+'|'+m.level;if(assigned.has(key))return;var oc=SHOW_OVERCAP&&OVERLIMIT.has(p.mode+'|'+key);var wp=WIPED[p.mode+'|'+key];chips+='<span class="tchip'+(oc?' overcap':'')+(wp?' wiped':'')+'" data-key="'+key+'" onclick="toggleTarget('+i+',this)" title="'+(wp?('\u26d4 BLOCKED by auto-protection: wiped out the army '+wp.n+' time(s) in the last 6h \u00b7 '):'')+(oc?'⚠ took troop losses in recent battles — near/over the preset limit · ':'')+'click to assign to this preset">'+m.name+(m.level?' L'+m.level:'')+'</span>';});
  const lbl=ordered.length?(ordered.length+' selected · priority order'):'all (default)';
  const qpill='<span class="tchip queuepill" onclick="viewQueue('+i+',this)" title="See the next monsters this preset will target (by its priority) — informational">☰ View Queue</span>';
  return '<div class="row ptgtrow"><span class=flbl>🎯 target</span> '+distpill+' <span class=tchips>'+chips+'</span> <span class=tgtlbl>'+lbl+'</span> '+qpill+'</div><div class="queuebox" id="qbox_'+i+'" style="display:none"></div>';
}
async function viewQueue(i,el){
  const box=document.getElementById('qbox_'+i); if(!box)return;
  if(box.style.display!=='none'){box.style.display='none';el.classList.remove('on');return;}
  el.classList.add('on');box.style.display='block';box.innerHTML='<span class=dim style="font-size:11px">loading…</span>';
  try{
    const r=await j('/api/queue?preset='+i);const q=(r&&r.queue)||[];
    if(!q.length){box.innerHTML='<span class=dim style="font-size:11px">Nothing eligible right now (no fresh monsters this preset can win).</span>';return;}
    box.innerHTML='<div class=qhdr>Up next · top '+q.length+'</div>'+q.map(function(m,idx){return '<div class=qitem><span class=qn>'+(idx+1)+'. '+m.name+' Lv'+m.level+' ('+fmtK(m.power)+')'+(m.blocked?' <span style="color:#e3b341;font-size:9px;font-weight:600" title="'+m.blocked+'">\u26a0 '+m.blocked+'</span>':'')+'</span><span class=qc>'+(m.x!=null?('('+m.x+','+m.y+')'):'')+'</span><span class=qd>- '+m.dist+'Km</span></div>';}).join('');
  }catch(e){box.innerHTML='<span class=bad style="font-size:11px">error loading queue</span>';}
}
function toggleTarget(i,el){collectPresets();var key=el.dataset.key,p=PRESETS[i];p.targets=p.targets||[];var k=p.targets.indexOf(key);if(k>=0)p.targets.splice(k,1);else p.targets.push(key);renderPresets();markPresetsDirty();}
function toggleDistPrio(i){collectPresets();var p=PRESETS[i];p.dist_prio=(p.dist_prio===false);renderPresets();markPresetsDirty();}
// OVERRIDE de winnability (aislado): checkbox + poder efectivo. Solo cambia a qué monstruos apunta el preset; NO toca las tropas. El input está en MILES (K); internamente se guarda el valor completo.
function suggestOverridePow(p){return Math.round(presetPow(p)*3/1e6)*1e6;}   // heurística: ⚔ × 3 (dato real: ~500M de marcha -> ~1.5B efectivo con dragones/bestias/buffs), alineado a MILLONES.
// El campo se edita en MILLONES (M) y la recomendación es el TOPE: no se permite un override por encima de ⚔×3, porque
// un valor inflado hace que el bot apunte a monstruos que no puede batir solo (caso real: rallies a Ymir con override 1B
// -> 22 ejércitos perdidos). Si hace falta más, primero hay que subir las tropas del preset.
function overrideHtml(p,i){
  if(p.mode!='solo'&&p.mode!='rally')return '';
  var on=!!p.override;
  var h=' <span class="ovbox'+(on?' on':'')+'"><label class=boostlbl title="Winnability override (ISOLATED). ON = use your own EFFECTIVE power (spirit beasts, dragons, buffs, alliance rally reinforcements…) to decide which monsters to attack, instead of the raw troop power, so you can target stronger monsters. It does NOT change the troops you send. OFF = normal behavior."><input type=checkbox class=ovchk data-p='+i+' '+(on?'checked':'')+' onchange="toggleOverride('+i+')"> ⚡ override</label>';
  if(on){
    var sug=suggestOverridePow(p); var val=(+p.override_pow||0)||sug;
    h+='<span class=fld><span class=flbl>override power (M)</span>'
      +'<span style="display:inline-flex;align-items:center;gap:4px"><input type=text class="ptn ovpow" data-p='+i+' value="'+fmtThou(Math.round(val/1e6))+'" onchange="setOverridePow('+i+',this)" title="Effective power in MILLIONS (M) used to decide which monsters to attack: win if effective power ≥ monster_power × win_ratio. MAXIMUM allowed = the recommendation '+fmtThou(Math.round(sug/1e6))+'M (⚔×3); a higher value would make the bot target monsters it cannot beat on its own. To go higher, add troops to the preset first.">'
      +'<button class=sec style="padding:4px 7px;font-size:11px" onclick="applyOverrideSug('+i+')" title="Use the suggestion ≈'+fmtK(sug)+'">≈</button>'
      +'<span class=dim style="font-size:10px">≈ '+fmtK(val)+'</span></span></span>';
  }
  h+='</span>';
  return h;
}
function toggleOverride(i){collectPresets();var p=PRESETS[i];p.override=!p.override;if(p.override&&!(+p.override_pow>0))p.override_pow=suggestOverridePow(p);renderPresets();markPresetsDirty();}
function setOverridePow(i,el){collectPresets();var p=PRESETS[i];var sug=suggestOverridePow(p);var v=Math.max(0,(parseInt(String(el.value).replace(/[^0-9]/g,''),10)||0)*1e6);if(sug>0&&v>sug){v=sug;alert('Override power capped at the recommendation: '+fmtThou(Math.round(sug/1e6))+'M (\u2694\u00d73).\n\nA higher value makes the bot pick monsters it cannot beat with its own troops. If you really need more, add troops to the preset first.');}p.override_pow=v;renderPresets();markPresetsDirty();}
function applyOverrideSug(i){collectPresets();PRESETS[i].override_pow=suggestOverridePow(PRESETS[i]);renderPresets();markPresetsDirty();}
// drag&drop de los pills de target (reordena p.targets = prioridad). stopPropagation para NO disparar el drag de reordenar PRESETS (handle ⠿).
function tgtDragStart(ev){var el=ev.currentTarget;tgtDragKey=el.dataset.key;tgtDragPi=+el.dataset.pi;ev.stopPropagation();ev.dataTransfer.effectAllowed='move';try{ev.dataTransfer.setData('text','')}catch(e){}}
function tgtDragOver(ev){ev.preventDefault();ev.stopPropagation();ev.currentTarget.classList.add('dragov')}
function tgtDragLeave(ev){ev.currentTarget.classList.remove('dragov')}
function tgtDrop(ev){ev.preventDefault();ev.stopPropagation();var el=ev.currentTarget;el.classList.remove('dragov');
  var i=+el.dataset.pi,key=el.dataset.key;
  if(tgtDragKey==null||tgtDragPi!==i||tgtDragKey===key){tgtDragKey=null;return}
  collectPresets();var p=PRESETS[i],arr=p.targets||[];
  var from=arr.indexOf(tgtDragKey),to=arr.indexOf(key);
  if(from<0||to<0){tgtDragKey=null;return}
  arr.splice(from,1);arr.splice(to,0,tgtDragKey);
  tgtDragKey=null;renderPresets();markPresetsDirty();}
(function(){const p=document.getElementById('presets');if(p){p.addEventListener('change',function(){markPresetsDirty();refreshPresetPows()});p.addEventListener('input',function(){markPresetsDirty();refreshPresetPows()})}})();
async function loadPresets(){await loadAccount();const r=await j('/api/presets');PRESETS=r.presets||[];while(PRESETS.length<6)PRESETS.push({enabled:false,mode:'solo',general_id:0,assistant_id:0,dist_prio:true,override:false,override_pow:0,troops:[],targets:[]});renderPresets();clearPresetsDirty()}
async function savePresets(){collectPresets();await j('/api/presets',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({presets:PRESETS})});document.getElementById('preset_hint').textContent='✓ saved '+new Date().toLocaleTimeString();clearPresetsDirty()}
async function saveCfg(){await j('/api/config',{method:'POST',headers:{'Content-Type':'application/json'},
  body:JSON.stringify({city_x:+document.getElementById('cx').value||0,city_y:+document.getElementById('cy').value||0,max_slots:+document.getElementById('ms').value||6,margin:+document.getElementById('mg').value||1.2,cap:+document.getElementById('cap').value||500000,target_fps:+document.getElementById('fps').value||0})});loadMons()}
function fmt(n){return (Math.round(n||0)).toLocaleString('en')}
function fmtK(n){n=Math.round(n||0);if(n>=1e9)return (n/1e9).toFixed(1).replace(/\.0$/,'')+'B';if(n>=1e6)return (n/1e6).toFixed(1).replace(/\.0$/,'')+'M';if(n>=1e3)return (n/1e3).toFixed(1).replace(/\.0$/,'')+'K';return ''+n;}
function renderPlan(r){const e=document.getElementById('applyres');let h='';
  if(r.error)h+='<div class=bad>⚠ '+r.error+'</div>';
  if(r.dry)h+='<div class=ok>Plan (preview) · free slots: '+r.free_slots+' · candidates: '+r.candidates+'</div>';
  else if(r.fired!=null)h+='<div class=ok>✓ '+r.fired+' launched (of '+r.candidates+' candidates)</div>';
  if(r.plan&&r.plan.length){h+='<table><thead><tr><th>#</th><th>Monster</th><th>Mode</th><th>Coords</th><th>Dist</th><th>M power</th><th>General</th><th>Troops to send</th><th>Attack</th></tr></thead><tbody>';
    r.plan.forEach((t,i)=>{const tr=(t.troops_h||[]).map(x=>'T'+x.tier+'×'+fmt(x.n)).join(' + ');
      h+='<tr><td>'+(i+1)+'</td><td>'+t.name+' L'+t.level+(t.blocked?' <span style="color:#e3b341;font-size:10px;font-weight:600" title="AUTO skips it: '+t.blocked+'. Still shown by the scanner, not a valid target right now.">\u26a0 '+t.blocked+'</span>':'')+'</td><td>'+t.mode+(t.has_tpl?'':' <span class=bad>(no preset)</span>')+'</td><td>'+t.x+','+t.y+'</td><td>'+t.dist+'</td><td>'+fmt(t.mpower)+'</td><td>'+(t.general_id?((t.gen_name||('#'+t.general_id))+' <span class=dim>atk'+t.gen_atk+'</span>'):'<span class=bad>—</span>')+'</td><td>'+tr+' <span class=dim>('+fmt(t.tunits)+'u)</span></td>'
      +'<td>'+(t.blocked?'<span class=dim title="'+t.blocked+'">\u2014</span>':'<button class=atkbtn onclick="attackOne('+t.x+','+t.y+',this)" title="Attack ONLY this target now">Attack</button>')+'</td></tr>';});
    h+='</tbody></table>';}
  e.innerHTML=h||'<span class=dim>no plan</span>';}
async function preview(){renderPlan(await j('/api/apply',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({dry:true})}))}
async function attackOne(x,y,btn){   // Attack de una fila: lanza SOLO ese objetivo (sin confirmación, por diseño)
  if(btn){btn.disabled=true;btn.textContent='⏳';}
  const res=await j('/api/apply',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({only:{x:x,y:y}})});
  if(!res||!res.ok){renderPlan(res||{error:'request failed'});return;}
  await preview();}                  // recarga el plan para poder seguir decidiendo el siguiente
async function apply(){if(!confirm('Launch the plan attacks/rallies NOW from your account?'))return;
  renderPlan(await j('/api/apply',{method:'POST',headers:{'Content-Type':'application/json'},body:'{}'}))}
async function toggleAuto(){const on=document.getElementById('autochk').checked;
  if(on&&!confirm('Enable global AUTO?\n\nEvery enabled preset runs in its OWN mode at once:\n• solo/rally → farm the marked monsters\n• 🏴 steal → hit stealable enemy rallies\n• 🤝 autojoin → join alliance rallies\n\nTurning it off stops all of them.')){document.getElementById('autochk').checked=false;return}
  await j('/api/auto',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({on})})}
async function cap(label){await j('/api/capture',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({label})});
  document.getElementById('caphint').textContent='⏺ Capture armed for "'+label+'": now do 1 manual '+label+' on a monster in the Pixel 7a.'}
async function reattach(){await j('/api/reattach',{method:'POST',headers:{'Content-Type':'application/json'},body:'{}'})}
async function reloadServer(){
  if(!confirm('↻ Reload Bot?\n\nRelaunches Evony and re-attaches the agent (the sweep resumes where it left off). The emulator and frida stay up, so it is quick — usually ~15-20s. If the emulator was fully off it cold-boots it (~1 min). Use it if the game froze or the bot got stuck.')) return;
  try{await j('/api/reload',{method:'POST',headers:{'Content-Type':'application/json'},body:'{}'})}catch(e){}
  document.getElementById('astat').innerHTML='<span class="astatpill ap-warn">● Reloading… (relaunching game, re-attaching)</span>';
  setTimeout(()=>location.reload(),45000)}
// README: botón del header -> modal a pantalla completa con el tutorial en un iframe (aislado).
// El modal + iframe se crean al primer clic (carga perezosa: el tutorial solo se baja al abrirlo).
function openReadme(){
  var m=document.getElementById('readme_modal');
  if(!m){
    m=document.createElement('div'); m.id='readme_modal';
    m.style.cssText='position:fixed;inset:0;z-index:99999;background:#0d1117;display:flex;flex-direction:column';
    m.innerHTML='<div style="display:flex;align-items:center;gap:10px;padding:8px 14px;background:#161b22;border-bottom:1px solid #30363d">'
      +'<b style="color:#e6edf3;font-size:14px">📖 Bot Guide</b><span style="color:#8b98a5;font-size:12px">README</span>'
      +'<button onclick="closeReadme()" style="margin-left:auto;background:#30363d;color:#e6edf3;border:0;border-radius:7px;padding:5px 12px;font-size:13px;cursor:pointer">✕ Close</button></div>'
      +'<iframe id=readme_frame title="README" style="flex:1;width:100%;border:0;background:#0d1117"></iframe>';
    document.body.appendChild(m);
    document.getElementById('readme_frame').src='/readme';
  }
  m.style.display='flex'; document.body.style.overflow='hidden';
}
function closeReadme(){ var m=document.getElementById('readme_modal'); if(m) m.style.display='none'; document.body.style.overflow=''; }
document.addEventListener('keydown',function(e){ if(e.key==='Escape') closeReadme(); });
async function stopBot(){
  if(!confirm('⏹ Stop Bot?\n\nCloses Evony (the emulator and frida stay up) and KEEPS the backend alive. Press “↻ Reload Bot” to bring it back instantly.')) return;
  try{await j('/api/stop_bot',{method:'POST',headers:{'Content-Type':'application/json'},body:'{}'})}catch(e){}
  document.getElementById('astat').innerHTML='<span class="astatpill ap-warn">⏸ Stopping… (backend stays alive · Reload to resume)</span>';
  setTimeout(()=>location.reload(),6000)}
let PM={},pmTimer=null;
function fmtClock(s){s=Math.max(0,Math.round(s));return Math.floor(s/60)+':'+String(s%60).padStart(2,'0')}
function fmtHealEta(s){s=Math.max(0,Math.round(s));const d=Math.floor(s/86400);let r=s-d*86400;const h=Math.floor(r/3600);r-=h*3600;const m=Math.floor(r/60);const ss=r-m*60;if(d>0)return d+'d '+h+'h';if(h>0)return h+'h '+m+'m';if(m>0)return m+'m '+ss+'s';return ss+'s';}
function renderPmarch(){
  document.querySelectorAll('.pmarch').forEach(el=>{if(!((el.id.slice(3)) in PM)&&el.innerHTML)el.innerHTML=''});
  for(const k in PM){const el=document.getElementById('pm_'+k);if(!el)continue;
    const m=PM[k],rem=Math.max(0,m.remaining-(performance.now()-m.t0)/1000),dur=m.dur||0;
    const frac=dur?Math.max(0,Math.min(1,(dur-rem)/dur)):0;
    let col,lab;
    if(m.phase=='gathering'){col='#a371f7';lab='⏳ Gathering · '+fmtClock(rem)}
    else if(m.phase=='returning'){col='#d29922';lab='↩ Returning · '+fmtClock(rem)}
    else if(m.blitz){col='#58a6ff';lab='⚡ Marching · '+fmtClock(rem)}   /* puente tras el Blitz: el rally YA va en camino; el tiempo es el viaje estimado hasta que el juego publique el dur real (pocos segundos) */
    else{col='#58a6ff';lab='⚔ Marching · '+fmtClock(rem)}
    let tgt=m.leader?('<span class=pmlead title="rally called by">'+(m.tag?('['+m.tag+'] '):'')+m.leader+'</span>'):'';   /* AUTO-JOIN: quién convoca el rally al que nos unimos */
    tgt+=m.name?('<span class=pmtgt title="target">'+(m.level?('Lv'+m.level+' '):'')+m.name+'</span>'):'';
    if(m.tx!=null&&m.ty!=null)tgt+='<span class=pmcoord title="target coords">('+m.tx+','+m.ty+')</span>';
    if(m.steal&&(m.atk_name||m.atk_tag))tgt+='<span class=pmsteal title="stolen from">'+(m.atk_tag?('['+m.atk_tag+'] '):'')+(m.atk_name||'')+'</span>';
    el.innerHTML=tgt+'<span class=pmlabel>'+lab+'</span><span class=pmbar><span class=pmfill style="width:'+Math.round(frac*100)+'%;background:'+col+'"></span></span>';
  }
  try{syncRallyBtns()}catch(e){}   // muestra/oculta y habilita/deshabilita los botones Blitz/Cancel segun la fase REAL
}
let STAM=null, stamTimer=null, STAMGAIN={}, REFILL_LOW=500, REFILL_HIGH=2000;
function fmtThou(n){try{return Number(n).toLocaleString('en-US')}catch(e){return ''+n}}
const BVAR={4332:'Super 10m',2967:'Adv 1h',996:'1h',4333:'Super 10m',2968:'Adv 1h',997:'1h',4334:'Super 10m',2969:'Adv 1h',998:'1h',2694:'Senior 200%',2693:'Medium 100%',2692:'Junior 50%',947:'Adv',946:'Basic'};
const BUFFORDER=[['march_size','March Size'],['attack','All Troops Attack'],['defense','All Troops Defense'],['hp','All Troops HP'],['march_speed','March Speed']];
function fmtBuffEta(s){s=Math.max(0,Math.round(s));const h=Math.floor(s/3600),m=Math.floor((s%3600)/60),ss=s%60;return (h>0?h+':':'')+String(m).padStart(2,'0')+':'+String(ss).padStart(2,'0');}
function buffEtaHtml(e){return e>0?('<span class=ok>● '+fmtBuffEta(e)+'</span>'):'<span class=dim>off</span>';}
let _buffSig='';
function renderBuffs(buffs){
  const box=document.getElementById('buffRows');if(!box)return;
  if(!buffs){box.innerHTML='<span class=dim>—</span>';_buffSig='';return;}
  const sig=JSON.stringify(BUFFORDER.map(kv=>{const b=buffs[kv[0]];return b?[!!b.gems,(b.variants||[]).filter(v=>v.num>0).map(v=>[v.id,v.num])]:0;}));   // firma de ESTRUCTURA (variantes+gems, SIN eta)
  if(sig===_buffSig){for(const kv of BUFFORDER){const b=buffs[kv[0]];if(!b)continue;const el=document.getElementById('buffeta_'+kv[0]);if(el){const eta=b.eta||0;el.dataset.eta=eta;el.innerHTML=buffEtaHtml(eta);}}return;}   // estructura IGUAL -> NO repintar (conserva la selección del usuario + el dropdown abierto); solo re-sincroniza el ETA
  const prevSel={};document.querySelectorAll('[id^=buffsel_]').forEach(s=>{prevSel[s.id]=s.value;});   // preserva la elección actual entre repintados
  let h='';
  for(const kv of BUFFORDER){const k=kv[0],label=kv[1];const b=buffs[k];if(!b)continue;const vs=(b.variants||[]).filter(v=>v.num>0);const eta=b.eta||0;let ctrl;
    if(b.gems){ctrl='<button class="sec gemsbtn" onclick="activateBuffGems(\''+k+'\','+(b.buy_id||0)+')" title="No item in bag — bought with gems">◆ Activate (gems)</button>';}
    else if(vs.length){const vss=vs.slice().sort((x,y)=>x.id-y.id);const prev=prevSel['buffsel_'+k];const want=(prev&&vss.some(v=>(''+v.id)===(''+prev)))?(''+prev):(''+vss[0].id);const opts=vss.map(v=>'<option value='+v.id+((''+v.id)===want?' selected':'')+'>'+(BVAR[v.id]||('#'+v.id))+' — '+v.num+'</option>').join('');ctrl='<select id=buffsel_'+k+' class=sel style="min-width:130px">'+opts+'</select> <button class=sec onclick="activateBuff(\''+k+'\')">Activate</button>';}
    else{ctrl='<span class=dim>no items in bag</span>';}
    h+='<div class=buffrow><span class=bufflbl>'+label+'</span><span class=buffeta id=buffeta_'+k+' data-eta='+eta+'>'+buffEtaHtml(eta)+'</span>'+ctrl+(k==='march_size'?(' <label class=boostlbl title="While a March Size buff is active, automatically scale solo/rally/steal marches by the real buff multiplier (auto-detected). Reverts when it expires."><input type=checkbox id=msboostchk onchange="toggleMsizeBoost(this)"> ⚡ auto-boost</label>'):'')+'</div>';}
  box.innerHTML=h;_buffSig=sig;
}
async function toggleMsizeBoost(el){try{await j('/api/msize_boost',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({on:el.checked})});}catch(e){}}
async function activateBuff(k){const sel=document.getElementById('buffsel_'+k);if(!sel)return;const id=parseInt(sel.value)||0;if(!id)return;const kv=BUFFORDER.find(x=>x[0]==k)||[];const lbl=kv[1]||k;if(!confirm('Activate '+lbl+' ('+(BVAR[id]||('#'+id))+')?\n\nConsumes 1 item.'))return;try{await j('/api/use_buff',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({buff_id:id})});}catch(e){}}
async function activateBuffGems(k,buyId){if(!buyId)return;const kv=BUFFORDER.find(x=>x[0]==k)||[];const lbl=kv[1]||k;if(!confirm('⚠ Activate '+lbl+' with GEMS?\n\nThis buff is not in your bag: it is BOUGHT with gems (~1250) and used instantly.\nIt is the only buff that spends gems, and the bot never does it automatically.'))return;try{await j('/api/buy_buff',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({item_id:buyId,amount:1})});}catch(e){}}
setInterval(function(){document.querySelectorAll('.buffeta').forEach(function(el){let e=parseInt(el.dataset.eta)||0;if(e>0){e--;el.dataset.eta=e;el.innerHTML=buffEtaHtml(e);}});},1000);
function renderStamina(ac){
  const box=document.getElementById('stamina'); if(!box) return;
  if(!ac||ac.stamina==null||ac.stamina<0){box.style.display='none'; return;}
  box.style.display='inline-flex';
  STAM={cur:ac.stamina, max:(ac.stamina_max>0?ac.stamina_max:0), full:(ac.stamina_full_in>0?ac.stamina_full_in:0), t0:performance.now()};
  paintStamina(); if(!stamTimer) stamTimer=setInterval(paintStamina,1000);
}
function paintStamina(){
  const box=document.getElementById('stamina'); if(!box||!STAM) return;
  const cur=STAM.cur, max=STAM.max;
  document.getElementById('stamVal').textContent=fmtThou(cur);
  // estado por umbrales del refill: low < threshold <= mid < target <= high
  const lo=REFILL_LOW, hi=Math.max(REFILL_HIGH, lo+1);
  const state=(cur>=hi)?'high':((cur>=lo)?'mid':'low');
  box.classList.remove('s-high','s-mid','s-low'); box.classList.add('s-'+state);
  // barra: progreso hacia el target (lleno cuando alto)
  const frac=hi>0?Math.max(0.04,Math.min(1,cur/hi)):1;
  document.getElementById('stamFill').style.width=Math.round(frac*100)+'%';
  let sub='';
  if(state==='low'){const left=(max>0&&cur<max)?Math.max(0,STAM.full-(performance.now()-STAM.t0)/1000):0; sub=left>0?('↻ '+fmtClock(left)):'low';}
  else if(state==='mid'){sub='medium';}
  else{sub='▲ stocked';}
  document.getElementById('stamSub').textContent=sub;
}
async function poll(){try{const s=await j('/api/status');
  const a=s.agent;
  const nm=(s.account&&s.account.name)?String(s.account.name).replace(/[&<>]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;'}[c])):'';
  document.getElementById('astat').innerHTML=(a.stopped_kick?'<span class="astatpill ap-warn" title="The game was closed to free resources. Press ↻ Reload Bot to bring it back.">⏸ Stopped · Reload to resume</span>':(a.attached?('<span class="astatpill '+(a.ready?(a.connected===0?'ap-bad':'ap-ok'):'ap-warn')+'"'+(a.connected===0?' title="The game was disconnected because someone logged into this account on another device. The emulator session is dead - restart the game on the emulator to resume."':'')+'>'+(a.ready?(a.connected===0?'● Disconnected · logged in elsewhere':('● Bot Running'+(nm?' · '+nm+(s.account&&s.account.power?' ('+fmtK(s.account.power)+')':''):''))):'● Attaching…')+'</span>'):'<span class="astatpill ap-bad">● No agent</span>'));
  const au=document.getElementById('autochk'); if(au&&document.activeElement!==au) au.checked=!!s.state.auto;
  {const _af=document.getElementById('autoflag'); if(_af){_af.textContent=s.state.auto?'ON':'OFF'; _af.className='autoflag '+(s.state.auto?'on':'off');}
   const _ac=document.getElementById('autoCard'); if(_ac){_ac.classList.toggle('auto-on',!!s.state.auto); _ac.classList.toggle('auto-off',!s.state.auto);}}
  const t=s.templates;document.getElementById('tpls').textContent='presets: '+(['solo','rally'].map(k=>t[k]?k+'✓':k+'✗').join(' '));
  const ac=s.account||{};
  REFILL_LOW=+s.state.refill_threshold||500; REFILL_HIGH=+s.state.refill_target||2000;
  renderStamina(ac);try{renderBuffs(ac.buffs)}catch(e){}
  if(!GENS.length && ac.generals){loadPresets()}
  document.getElementById('accinfo').innerHTML=(ac.free_slots!=null)?('account: <b>'+ac.free_slots+'</b> free slots · city '+ac.city_x+','+ac.city_y+' · '+((ac.troops||[]).length)+' troop types · '+ac.generals+' generals · max monster L'+ac.max_monster_level):'<span class=bad>account: no scan yet (wait ~10s after attach, or press ↻)</span>';
  const set=(id,v)=>{const el=document.getElementById(id); if(el&&document.activeElement!==el) el.value=v;};
  set('ms',s.state.max_slots); set('mg',s.state.margin); set('cap',s.state.cap); set('fps',s.state.target_fps);
    {const _st=s.state; const _bt=document.getElementById('expbody_t');
   if(_bt&&!_bt.contains(document.activeElement)){[['ex_mg','margin'],['ex_wr','win_ratio'],['ex_ms','max_slots'],['ex_cd','cooldown'],['ex_fsm','farm_seen_max'],['ex_rsm','rally_seen_max'],['ex_ws','wipe_strikes']].forEach(function(p){set(p[0],_st[p[1]]);}); set('ex_wf',Math.round((_st.wipe_frac!=null?_st.wipe_frac:0.9)*100)); set('ex_ww',_st.wipe_window_h!=null?_st.wipe_window_h:6); const _j=document.getElementById('ex_jit'); if(_j)_j.checked=(_st.farm_jitter!==false);}
   [['t','expert_targets_open'],['s','expert_steals_open']].forEach(function(p){const sw=document.getElementById('expsw_'+p[0]),bd=document.getElementById('expbody_'+p[0]);if(sw&&bd){const o=!!_st[p[1]];sw.classList.toggle('on',o);bd.style.display=o?'block':'none';}});}
  {const sd=document.getElementById('stealsavebtn'); if(!(sd&&sd.classList.contains('dirty'))){ set('ttags',s.state.target_tags||''); set('spt',s.state.steal_sec_per_tile); set('sbuf',s.state.steal_enemy_buffer); set('swr',s.state.win_ratio); set('sminpow',s.state.steal_min_power||0); set('scmargin',s.state.steal_combat_margin); set('swm',s.state.steal_win_margin); }}
  {const as=document.getElementById('autostat'); if(as){const ps=(PRESETS||[]).filter(p=>p.enabled&&(p.mode=='autojoin'||(p.troops||[]).length));   /* AUTO-JOIN no necesita tropas guardadas (el backend fuerza 1xT1 via _autojoin_troops) -> contarlo igual; solo/rally/steal SÍ las necesitan */ const c={solo:0,rally:0,steal:0,autojoin:0}; ps.forEach(p=>{c[p.mode]=(c[p.mode]||0)+1});
     as.innerHTML = s.state.auto ? ('<span class=ok>● running</span> · '+(c.solo+c.rally)+' farm · '+c.steal+' steal · '+c.autojoin+' autojoin') : ('<span class=dim>off · '+ps.length+' preset(s) ready</span>');}}
  {const rd=document.getElementById('refillsavebtn'); if(!(rd&&rd.classList.contains('dirty'))){ set('rthr',s.state.refill_threshold); set('rtgt',s.state.refill_target); }}
  const rc=document.getElementById('refillchk'); if(rc&&document.activeElement!==rc) rc.checked=!!s.state.refill;
  {const td=document.getElementById('trucesavebtn'); if(!(td&&td.classList.contains('dirty'))){ set('trenewh',s.state.truce_renew_h); }}
  const tc=document.getElementById('trucechk'); if(tc&&document.activeElement!==tc) tc.checked=!!s.state.auto_truce;
  renderTruce(ac);
  {const hc=document.getElementById('healchk'); if(hc&&document.activeElement!==hc) hc.checked=!!(s.state&&s.state.heal);
   const hd=document.getElementById('healsavebtn'); if(!(hd&&hd.classList.contains('dirty'))) set('hthr',(s.state&&s.state.heal_threshold)||1);
   const H=s.heal||{}; window.HEALST=H; const sw=(id,v)=>{const el=document.getElementById(id); if(el) el.textContent=v;};
   sw('hpWounded',(H.wounded==null||H.wounded<0)?'—':fmtThou(H.wounded));
   sw('hpHealing',(H.healing==null||H.healing<0)?'—':fmtThou(H.healing));
   const cw=document.getElementById('hpCapWrap'); if(cw){if(H.hosp_cap!=null&&H.hosp_cap>0){cw.style.display='';sw('hpCap',fmtThou(H.hosp_cap));}else cw.style.display='none';}
   const ew=document.getElementById('hpEtaWrap'); if(ew){if(H.heal_left!=null&&H.heal_left>0){ew.style.display='';sw('hpEta',fmtHealEta(H.heal_left));}else ew.style.display='none';}}
  {const R=s.rally_card||{}; const rk=document.getElementById('rcchk'); if(rk&&document.activeElement!==rk) rk.checked=!!R.active;
   const rActive=!!R.active, rSecs=+R.secs_left||0;   // re-siembra el contador local solo en cambio de estado o deriva >120s (evita saltos cada 2.5s)
   const localLeft=RC.active?Math.max(0,RC.secs-Math.floor((performance.now()-RC.t0)/1000)):0;
   if(rActive!==RC.active||(rActive&&Math.abs(localLeft-rSecs)>120)){RC.active=rActive;RC.secs=rSecs;RC.t0=performance.now();}
   RC.cardExpire=+R.card_expire||0; RC.owned=R.owned;
   const st=document.getElementById('rcstat'); if(st) st.innerHTML=rActive?'<span style="color:#3fb950">● auto-joining alliance rallies · global AUTO paused</span>':'';
   renderRc();}
  {const ji=document.getElementById('joininfo'); const gw=(ac.guild_wars||[]); const onAJ=(PRESETS||[]).some(p=>p.enabled&&p.mode=='autojoin');   /* alineado con el backend (_auto_join_cycle ya no exige tropas): el badge se enciende con el preset en autojoin, tenga o no tropas guardadas */
   if(ji) ji.innerHTML = onAJ ? (' · <span style="color:#3fb950">🤝 AUTO-JOIN on · alliance rallies: '+gw.length+(gw.length?(' ('+gw.filter(w=>w.joined).length+' joined)'):'')+'</span>') : (gw.length?(' · 🤝 '+gw.length+' alliance rally(ies) — set a preset to AUTO-JOIN to join them'):'');}
  {const si=(ac.stamina_items||[]); STAMGAIN={}; si.forEach(it=>{STAMGAIN[it.id]=it.gain}); const se=document.getElementById('stamitems'); if(se) se.innerHTML=si.length?si.slice().sort((a,b)=>a.gain-b.gain).map(it=>'<b style=color:#f0b229>+'+it.gain+'</b>×'+it.num).join('  ·  '):'<span style=color:#8b949e>none left</span>';}
  {const w=s.wheel||{}; window.WHEELST=w; const cost=(w.cost||9000); const rdy=s.agent&&s.agent.ready;
   const wc=document.getElementById('wheelcred');
   if(wc) wc.innerHTML=(w.credits>=0?(fmtThou(w.credits)+' <span class=dim>(~'+Math.floor(w.credits/cost)+' batches)</span>'):'—')
     +(rdy&&!w.running?(w.open?' <span style="color:#3fb950">· 🟢 wheel open</span>':' <span style="color:#f0883e">· 🔴 open the wheel in-game</span>'):'')
     +((w.vip>=0)?' <span class=dim title="General Blessing seleccionado (el bot lo respeta, no lo cambia)">· blessing vip '+w.vip+'</span>':'');
   const wb=document.getElementById('wheelbtn'); if(wb) wb.disabled=!!w.running;
   const wi=document.getElementById('wheelinfo');
   const gained=(w.items_before!=null&&w.items_after!=null)?Math.max(0,w.items_after-w.items_before):0;
   if(wi) wi.innerHTML = w.last_msg ? ('<span style="color:'+(w.running?'#d29922':(/stopped|error|⚠/.test(w.last_msg)?'#f85149':'#3fb950'))+'">'+w.last_msg+'</span>'+((gained>0&&!w.running)?(' · <span style="color:#3fb950">+'+fmtThou(gained)+' stamina to bag</span>'):'')) : '';
   /* Wheel of Fortune oculto: sin auto-fetch de créditos */ }
  const pmd=s.preset_marches||{},np={};for(const k in pmd)np[k]=Object.assign({},pmd[k],{t0:performance.now()});PM=np;renderPmarch();if(!pmTimer)pmTimer=setInterval(renderPmarch,1000);
  {const nm=(s.state&&s.state.max_send)||{}; MAX_SEND=nm; MAX_SEND_CONF=(s.state&&s.state.max_send_conf)||{}; const sig=JSON.stringify(nm); if(sig!==_maxSendSig){_maxSendSig=sig; if(!document.querySelector('#presets input:focus, #presets select:focus')) loadPresets();}}   // topes de marcha por general (max_send) -> si cambió, re-render (los presets se auto-adaptaron al tope)
  {const ol=s.overlimit||[]; const sig=ol.slice().sort().join('~'); if(sig!==_olSig){_olSig=sig; OVERLIMIT=new Set(ol); if(!document.querySelector('#presets input:focus, #presets select:focus')) renderPresets();}}
  {const wp=s.wiped||{}; const sig=JSON.stringify(wp); if(sig!==_wpSig){_wpSig=sig; WIPED=wp; if(!document.querySelector('#presets input:focus, #presets select:focus')) renderPresets();}}   // targets auto-bloqueados -> repintar los pills   // monstruos con pérdidas recientes -> re-pinta pills para (des)activar el parpadeo "al límite"
  {const pa=s.preset_avail||{}; let nShort=0; const MZ=s.msize||{};   // aviso presets + estado AUTO-BOOST March Size (indicador temporal por preset)
   {const cb=document.getElementById('msboostchk'); if(cb && document.activeElement!==cb) cb.checked=(MZ.boost!==false);}
   (PRESETS||[]).forEach((p,i)=>{const el=document.getElementById('pw_'+i); if(!el) return;
     const ce=document.getElementById('pcap_'+i); if(ce){const cap=Math.floor((MAX_SEND[(p.general_id)]||0)*0.98); ce.textContent=cap>0?('· max march '+fmtThou(cap)+' units'):'';}
     {const pb=document.getElementById('pboost_'+i); if(pb){const on=(MZ.boost!==false)&&(MZ.eta>0)&&(MZ.mult>1.001)&&['solo','rally','steal'].indexOf(p.mode)>=0; pb.innerHTML=on?('<span style="color:#3fb950" title="Las marchas de este preset se agrandan automáticamente por el buff March Size activo">⚡ March Size boost ×'+Number(MZ.mult).toFixed(3)+' ('+fmtThou((MZ.troops||{})[i]||0)+' troops) · '+fmtBuffEta(MZ.eta)+'</span>'):'';}}
     const a=pa[i];
     if(a && !a.ok && (a.short||[]).length){ nShort++;
       el.innerHTML='<span style="color:#f85149" title="This preset cannot launch now: the required troops are not available (in Hospital, marching elsewhere, or used by another preset).">⚠ cannot launch — '+a.short.map(x=>'need '+fmtK(x.need)+' '+x.name+' ('+fmtK(x.have)+' idle)').join(', ')+'</span>';
     } else el.innerHTML='';
   });
   const as2=document.getElementById('autostat'); if(as2 && nShort>0) as2.innerHTML += ' · <span style="color:#f85149">⚠ '+nShort+' preset(s) blocked (troops in Hospital/out)</span>';}
  const sv=document.getElementById('view_steals'); if(sv&&sv.classList.contains('show')) loadSteals();
  const hv=document.getElementById('view_history'); if(hv&&hv.classList.contains('show') && (window.scrollY||window.pageYOffset||0)<300) loadHistory();   // solo auto-refresca cerca del top (no salta el scroll al leer)
  document.getElementById('log').textContent=(s.log||[]).join('\n');
  document.getElementById('log').scrollTop=1e9;
}catch(e){}}
function toggleCard(name){const b=document.getElementById('body_'+name),c=document.getElementById('chev_'+name);if(!b)return;
  const col=!b.classList.contains('collapsed');b.classList.toggle('collapsed',col);if(c)c.classList.toggle('collapsed',col);
  localStorage.setItem('bot_collapse_'+name,col?'1':'0')}
function restoreCards(){['setup','presets'].forEach(name=>{if(localStorage.getItem('bot_collapse_'+name)=='1'){
  const b=document.getElementById('body_'+name),c=document.getElementById('chev_'+name);if(b)b.classList.add('collapsed');if(c)c.classList.add('collapsed')}});
  if(localStorage.getItem('bot_collapse_log')=='0'){const b=document.getElementById('body_log'),c=document.getElementById('chev_log');if(b)b.classList.remove('collapsed');if(c)c.classList.remove('collapsed')}}
function showView(name){const cur=localStorage.getItem('bot_view');const active=(cur==name)?'':name;  // re-click = ocultar
  document.querySelectorAll('.view').forEach(v=>v.classList.toggle('show',v.dataset.view==active));
  document.querySelectorAll('.pill-nav').forEach(pl=>pl.classList.toggle('active',pl.dataset.view==active));
  localStorage.setItem('bot_view',active);if(active=='steals')loadSteals();if(active=='history')loadHistory();if(active=='monsters')loadMons()}
function restoreView(){let v=localStorage.getItem('bot_view');if(v===null)v='monsters';
  document.querySelectorAll('.view').forEach(x=>x.classList.toggle('show',x.dataset.view==v));
  document.querySelectorAll('.pill-nav').forEach(pl=>pl.classList.toggle('active',pl.dataset.view==v));if(v=='steals')loadSteals();if(v=='history')loadHistory()}
(function(){const inp=document.getElementById('msin_excl');if(inp){inp.addEventListener('focus',renderExclDD);inp.addEventListener('input',renderExclDD);
  const box=document.getElementById('msbox_excl');if(box)box.addEventListener('click',()=>inp.focus());
  document.addEventListener('click',e=>{const ms=document.getElementById('ms_excl');if(ms&&!ms.contains(e.target)){const dd=document.getElementById('dd_excl');if(dd)dd.classList.remove('open')}})}})();
(function(){const inp=document.getElementById('flt');if(inp){inp.addEventListener('focus',renderFltDD);
  const box=document.getElementById('msbox_flt');if(box)box.addEventListener('click',e=>{if(e.target===box)inp.focus()});
  document.addEventListener('click',e=>{const ms=document.getElementById('ms_flt');if(ms&&!ms.contains(e.target)){const dd=document.getElementById('dd_flt');if(dd)dd.classList.remove('open')}})}})();
// ── APP HÍBRIDA: panel del escáner ──────────────────────────────────────────
// Ciclo propio (4s) y no dentro de poll(): el barrido es información de fondo y no
// tiene por qué competir con el refresco de marchas, que sí es urgente.
let SCAN_ON=true;
function fmtN(n){return (n||0).toLocaleString('en-US')}
async function scanPoll(){
  let d; try{ d=await j('/api/scanner') }catch(e){ return }
  const sw=d.sweep||{}, mt=d.mt||{};
  SCAN_ON = d.scan_on!==false;
  const alive = (d.last_batch||0)>0 && (Date.now()/1000 - d.last_batch) < 90;
  const pill=document.getElementById('scanPill');
  if(!SCAN_ON){ pill.textContent='PAUSED'; pill.style.background='#3a2f1a'; pill.style.color='#d29922' }
  else if(alive){ pill.textContent='SWEEPING'; pill.style.background='#132e1a'; pill.style.color='#3fb950' }
  else { pill.textContent='WAITING'; pill.style.background='#1f2937'; pill.style.color='#9ca3af' }
  document.getElementById('scObjs').textContent=fmtN(d.objs);
  document.getElementById('scSrv').textContent=d.server||'—';
  document.getElementById('scCfg').textContent=fmtN(d.cfg);
  const pct=d.progress_pct||0;
  document.getElementById('scBar').style.width=Math.min(100,pct)+'%';
  document.getElementById('scPct').textContent=pct.toFixed(1)+'%';
  document.getElementById('scLap').textContent=(sw.order?('· '+fmtN(sw.idx||0)+' / '+fmtN(sw.order)+' regions'):'')+(sw.pass?(' · lap '+sw.pass):'');
  document.getElementById('scReq').textContent=fmtN(sw.sent)+' / '+fmtN(sw.replies);
  const atk=d.attacks||0, aEl=document.getElementById('scAtk');
  aEl.textContent=fmtN(atk);
  aEl.style.color = atk>0 ? '#d29922' : '';          // hay marchas enemigas en vuelo ahora mismo
  document.getElementById('scMar').textContent=fmtN(d.marches);
  document.getElementById('scPly').textContent=fmtN(d.players);
  document.getElementById('scHold').textContent=fmtN((d.sweep||{}).holds);
  const rt=d.reply_ratio||0, rEl=document.getElementById('scRatio');
  rEl.textContent=rt.toFixed(2);
  rEl.style.color = (sw.sent>20 && rt<0.6) ? '#f85149' : '';   // ratio bajo = el server está frenando el barrido
  const gap=d.gap_avg_ms||0, gEl=document.getElementById('scGap');
  gEl.textContent=gap+'ms avg';
  gEl.style.color = gap>800 ? '#f85149' : (gap>400 ? '#d29922' : '#3fb950');
  document.getElementById('scGapMax').textContent=(d.gap_max_ms||0)+'ms';
  const fz=d.freezes||0, fEl=document.getElementById('scFrz');
  fEl.textContent=fz;
  fEl.style.color = fz>0 ? '#f85149' : '#3fb950';
  document.getElementById('scToggle').textContent = SCAN_ON ? '⏸ Pause sweep' : '▶ Resume sweep';
}
async function scanToggle(){
  const msg=document.getElementById('scMsg');
  try{ await j('/api/scanner',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({cmd:SCAN_ON?'pause':'resume'})});
    msg.textContent=SCAN_ON?'paused':'resumed'; setTimeout(()=>msg.textContent='',2500); }
  catch(e){ msg.textContent='error' }
  setTimeout(scanPoll,600);
}
async function scanCadence(){
  const ms=parseInt(document.getElementById('scCad').value)||350;
  const msg=document.getElementById('scMsg');
  try{ await j('/api/scanner',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({cmd:'cadence',ms:ms})});
    msg.textContent='cadence '+ms+'ms'; setTimeout(()=>msg.textContent='',2500); }
  catch(e){ msg.textContent='error' }
}
restoreCards();restoreView();
(async()=>{await loadMons();await loadPresets();poll();setInterval(poll,2500);scanPoll();setInterval(scanPoll,4000)})()
</script></body></html>"""

def main():
    load_state()
    load_history()                              # historial de la pestaña History (persistido)
    STATE["steal"] = STATE.get("auto", False)   # AUTO global: steal ya no es un modo aparte; se sincroniza con auto
    if STATE.get("auto"):                        # se recuerda entre sesiones (persistido en bot_state.json)
        logmsg("resumed saved mode: AUTO (global) ON (remembered across restart)")
    logmsg(f"bot_pixel backend on http://{BIND}:{PORT}  (emulator {EMULATOR}, V3 {_v3_base()})")
    threading.Thread(target=agent_thread, daemon=True).start()
    threading.Thread(target=auto_loop, daemon=True).start()
    srv = ThreadingHTTPServer((BIND, PORT), H)
    srv.serve_forever()

if __name__ == "__main__":
    main()
