#!/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 de V3 (http://<bind>:8772/api/data),
#     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__))
PORT = int(os.environ.get("BOT_PORT", "8780"))
BIND = os.environ.get("BOT_BIND", "127.0.0.1")
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_v1_session", hashlib.sha256).hexdigest() if AUTH_PASS else ""
EMULATOR = os.environ.get("BOT_EMULATOR", "emulator-6004")
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)
    "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
    "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)
    "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).
}
AUTO_FIRED = {}           # (wx,wy) -> ts del último disparo (cooldown anti re-pegar al mismo monstruo)
AUTO_LANES = {}           # preset_index -> {"target": (wx,wy), "ts": ts}  (1 marcha en vuelo por preset)
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, "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
            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}
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 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)
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 confirmado en ese tile, disparado ANTES del report
                if ev.get("kind") not in ("steal", "farm") or ev.get("confirmed"): continue
                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
                best = ev; break
            if best is not None:
                won = (res == 0); killed = (ll == 0)
                best["confirmed"] = True; best["result"] = "won" if won else "lost"
                best["dead"] = dead; best["survived"] = sur; best["left_life"] = ll
                best["note"] = ((("killed it, no losses" if dead == 0 else f"killed it, {dead:,} lost") if killed else "hit it, monster survived") if won else "lost the battle") + " · battle report"
                upd += 1
    return upd
_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
                entry = {"ts": float(T), "kind": "miss", "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
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}:8772"
    except Exception:
        V3_BASE = "http://127.0.0.1:8772"
    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))

def fetch_monsters():
    """Todos los monstruos de V3 (name, level, x, y, group)."""
    j = v3_get("/api/data?limit=100000")
    rows = j.get("rows") or j.get("all") or []
    out = []
    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
        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)})
    return out

def fetch_attacks():
    """Ataques activos de V3 (/api/attacks). Devuelve (lista_de_ataques, ally_tag_del_focus)."""
    j = v3_get("/api/attacks?focus_filter=0&include_helps=0")
    return (j.get("attacks") or [], (j.get("ally_tag") or "").strip())

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")
            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)
            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 == "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 == "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 == "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
                        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)")
            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)
    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')}")

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)}")

def attach_agent():
    """Asegura frida-server + Evony en 6004 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
    # frida-server LISTEN :27042
    r = sh(["adb", "-s", EMULATOR, "shell", "su", "-c", "netstat -anl 2>/dev/null | grep ':27042 ' | grep LISTEN"])
    if not (r and r.stdout.strip()):
        logmsg("starting frida-server on 6004 (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 marcha
    top = sh(["adb", "-s", EMULATOR, "shell", "dumpsys", "activity", "activities"])
    if not (top and "com.topgamesinc.evony" in (top.stdout or "")):
        logmsg("launching Evony on 6004...")
        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
            logmsg("✅ agent attached to 6004")
            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("❌ could not attach agent to 6004")
    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)
    if not ((hb and stale > 40) or never_ready): return None
    hot_ago = now - last_hot
    if last_hot and hot_ago > 90 and not ready and cold_age > 900:
        return "cold"
    if (stale > 80 or never_ready) and hot_ago > 150:
        return "hot"
    return "reattach"

def agent_thread():
    wd = {"last_game_restart": 0.0}
    while True:
        if not AGENT.get("script"):
            try: attach_agent()
            except Exception as e: logmsg(f"agent_thread 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
        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)

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 (depende del general+tier); aprendido en STATE['max_send'][gid]. 0.98 = margen."""
    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)
    if cap <= 0:
        # general SIN cap de marcha aprendido (el hook que lo aprendía se quitó por el crash) -> clampa al MENOR cap
        # conocido (o 2.5M) para que el server NO rechace la marcha por exceso de tropas (lo que daba falsos "rally-only").
        known = [int(v) for v in ms.values() if int(v or 0) > 0]
        cap = min(known) if known else 2500000
    if cap > 0:
        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)

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_preset >= poder_monstruo × margin.
    mpow<=0 (poder desconocido) => no bloquea. Calibrable con STATE['margin']."""
    mpow = float(mpow or 0)
    return mpow <= 0 or _preset_pow(p) >= 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.
    RALLY: cubre la fase de reunión (~rally_time) + lag de scan -> NO re-dispara durante la reunión.
    SOLO: 2 ciclos de scan (la marcha aparece rápido en march_targets)."""
    rt = int((t or {}).get("rally_time", 0) or 0)
    return rt + 120 if rt > 0 else 30

# 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 = {}
    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
    out = {}
    ld = STATE.get("lane_disp") or {}
    for i, p in enumerate(PRESETS):
        g = int(p.get("general_id", 0) or 0)
        m = by_gen.get(g) if g else None
        if not m: continue
        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)
        if int(m.get("mtype", 0) or 0) == 19 and atcity:           # rally de alianza REUNIÉNDOSE (gather, target=ciudad)
            phase = "gathering"
        else:
            phase = "returning" if atcity else "outbound"
        e = {"phase": phase, "remaining": rem, "dur": int(m.get("dur", 0) or 0), "x": wx, "y": wy}
        lane = AUTO_LANES.get(i)                              # live lane: target name/level/coords (in gathering/returning the march points at OUR city, not the monster)
        disp = None if lane else ld.get(str(i))              # persisted target context: used after a backend restart, when AUTO_LANES (memory) is empty
        if disp and phase == "outbound" and (int(disp.get("tx", -1)), int(disp.get("ty", -1))) != (wx, wy):
            disp = None                                      # an outbound march heading elsewhere than the remembered target is a different march -> 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 phase == "outbound":                            # last resort: an outbound march still points AT the target tile
            e["tx"] = wx; e["ty"] = wy
        nm = (lane or {}).get("name") or (disp or {}).get("name")
        if nm:                                               # target name+level: from the live lane, else the persisted context
            e["name"] = nm; e["level"] = int((lane or {}).get("level") or (disp or {}).get("level") or 0)
        src = lane if (lane and lane.get("steal")) else (disp if (disp and disp.get("steal")) else None)
        if src:                                              # steal: also surface who we stole the monster from (attacker + alliance)
            e["steal"] = True; e["atk_name"] = src.get("atk_name"); e["atk_tag"] = src.get("atk_tag")
        out[str(i)] = e
    return out

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)"
    queue = [p for p in PRESETS if p.get("enabled") and p.get("troops") and p.get("mode") not in ("steal", "autojoin")]
    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()
    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 _can_win(p, m.get("power", 0))],          # no enviar a batalla perdida
                      key=lambda m: (_is_rally_target(m) != want_rally,   # preferencia grupo->modo (NO bloqueo): rally->Boss/Event, solo->resto
                                     -int(m.get("power", 0) or 0), dist_from_city(m["x"], m["y"]) or 1e9))
        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
        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):
    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 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
        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}

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}")

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 now - ts > STATE["cooldown"]]:
        AUTO_FIRED.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; 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)
    for idx, p in queue:
        lane = AUTO_LANES.get(idx)
        g = int(p.get("general_id", 0) or 0)
        if lane:
            tgt = lane["target"]
            if tgt in in_flight: lane["seen"] = now; lane["dur"] = in_flight.get(tgt) or lane.get("dur", 0); continue   # marcha en vuelo a ese tile -> ocupado
            if now - lane.get("ts", 0) < lane.get("hold", 30): continue                                                 # dentro del HOLD del preset (RALLY: cubre la REUNIÓN ~7min) -> NO re-disparar (arregla las marchas duplicadas)
            if lane.get("seen") and now - lane["seen"] < (lane.get("dur") or 120) + 25: continue                        # la marcha se vio y aún no ha vuelto -> ocupado
        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()
        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 _can_win(p, m.get("power", 0))],          # no enviar a batalla perdida
                      key=lambda m: (_is_rally_target(m) != want_rally,   # preferencia grupo->modo (NO bloqueo)
                                     -int(m.get("power", 0) or 0), dist_from_city(m["x"], m["y"]) or 1e9))
        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)}
        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
        fired.append((idx, m))
        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))

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}
    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 > STATE["cooldown"]]: AUTO_FIRED.pop(k, None)
    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:
        logmsg(f"refill: stamina {cur} low but NO stamina items left in bag"); 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)

# ---- 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}
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 -> esperar a que termine
        HEAL["last_wounded"] = -1                                    # hay cura en curso = progreso -> resetea el tracker de "atascado"
        return
    if wounded < int(STATE.get("heal_threshold", 1) or 1): return    # nada (o menos del umbral) que curar
    now = time.time()
    # Si la cura anterior NO arrancó (cola vacía Y los mismos heridos), el server la está rechazando (recursos/capacidad):
    # reintentar MUY espaciado en vez de cada 30s -> evita el spam de HEAL idénticos en el History (bug visto 2026-07-23).
    stuck = (wounded == HEAL.get("last_wounded", -1))
    cooldown = 900 if stuck else 30                                  # 15 min si está atascado, 30s si hay algo nuevo/progreso
    if now - HEAL["last_fire"] < cooldown: return
    HEAL["last_fire"] = now; 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: la cura anterior no progresó)" if stuck 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 ~30s)

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)."""
    aj = [(i, p) for i, p in enumerate(PRESETS)
          if p.get("enabled") and p.get("troops") and p.get("mode") == "autojoin" and int(p.get("general_id", 0) or 0) > 0]
    if not aj: return
    wars = ACCOUNT.get("guild_wars", []) or []
    if not wars: return
    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 > 40]: _JOIN_GEN.pop(k, None)
    busy = {int(m.get("gid", 0) or 0) for m in ACCOUNT.get("march_targets", []) if int(m.get("gid", 0) or 0) > 0}
    busy |= {g for g, ts in _JOIN_GEN.items() if now - ts < 30}   # join recién enviado (aún no aparece en el scan)
    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 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)]
    if not joinable: return
    taken = set(); sent = []
    for idx, p in aj:
        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 busy: continue                                   # su general ya está marchando/uniéndose -> ocupado
        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
        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, lwx=int(w["lwx"]), lwy=int(w["lwy"]), general=_gen_name(g),
             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 cada 90s (lo hace el agente con la captura OFF -> sin colgarse)."""
    if not ACCOUNT.get("generals"): return        # espera a que la cuenta cargue
    now = time.time()
    if now - _GEN_STATES["last"] < 90: 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"):
                _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)
                    _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)
                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 == "/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/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")},
                "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")},
                "preset_marches": _preset_march_view(time.time()),
                "preset_avail": _preset_availability(),
            }))
        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/history":
            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))
                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/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),
                                   "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]
                    for pp in PRESETS: _clamp_preset_to_cap(pp)   # no permitir más tropas que el tope de marcha del general (si se conoce)
                AUTO_LANES.clear()
                save_state()
            return self._send(200, json.dumps({"ok": True, "presets": PRESETS}))
        if u.path == "/api/apply":
            return self._send(200, json.dumps(apply_and_fire(dry=bool(body.get("dry")))))
        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/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.
            save_state()
            logmsg(f"AUTO (global) {'ON' if STATE['auto'] else 'OFF'}")
            return self._send(200, json.dumps({"ok": True, "auto": STATE["auto"]}))
        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/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 == "/api/reload":
            try: self._send(200, json.dumps({"ok": True, "msg": "Full restart (emulator + frida + game + backend)… ~1 min"}))
            except Exception: pass
            here = os.path.dirname(os.path.abspath(__file__))
            logmsg("REINICIO COMPLETO solicitado desde la UI (emulador + frida + juego + backend)")
            try:
                import subprocess
                # REINICIO COMPLETO: mata el emulador 6004 + lo relanza desde snapshot + frida + juego + backend.
                # Detached (start_new_session) para sobrevivir al pkill del propio backend.
                subprocess.Popen(["bash", "-c", f"sleep 1; BOT_BIND='{BIND}' bash '{here}/full_restart.sh' > /tmp/bot_v1_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/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/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 V1 - 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 V1</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 4px} .sub{color:#8b949e;font-size:12px;margin-bottom:14px}
.card{background:#161b22;border:1px solid #30363d;border-radius:10px;padding:12px;margin-bottom:12px}
.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}
.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}
.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}
.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}
.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}
.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:10px;flex-wrap:wrap;padding:7px 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}
@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}
  .preset:has(.pmarch:not(:empty)){padding-right:200px}
  .pmarch{position:absolute;top:11px;right:12px;width:184px;margin:0;flex-direction:column;align-items:flex-end;gap:4px}
  .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:4px 0 2px}
.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}
.tgtlbl{font-size:11px;color:#8b949e}
.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}
.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 Server</button> <span class=bver title="which bot version is running">__BVARIANT__</span>
<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=sub>Pick targets + per-preset mode (rally/solo). ATTACK launches in free slots by distance. Data: V3 API.</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 <span class=dim style="text-transform:uppercase;letter-spacing:0;font-size:10px">· keeps your Peace Shield (bubble) up — applies a Truce Agreement from the bag before it drops · never gems</span></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=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>
    <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 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>
      <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>
  <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 class=dim style="text-transform:uppercase;letter-spacing:0;font-size:10px">· GLOBAL — runs every enabled preset in its own mode at once (solo/rally farm · 🏴 steal · 🤝 autojoin)</span></b></span>
    <span id=autostat class=dim style="font-size:11px"></span>
  </div>
</div>

<div class=card>
  <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 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=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=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>
    <span class=dim>Travel sec/tile <span class=info data-info="Our march speed: seconds per map tile. Arrival ETA = distance x this. This is only the STARTING value — the bot now auto-measures the real sec/tile per general from the marches that actually launch and uses that. Higher = assume slower (fewer steals); lower = faster." onclick="showInfo(event,this)">ⓘ</span></span> <input type=number id=spt value=1.5 step=0.1 style=width:60px>
    <span class=dim>Enemy land +s <span class=info data-info="Seconds an enemy ALLIANCE RALLY needs to march + land AFTER it finishes gathering (not visible from the scanner). Added to their gather ETA to estimate when the monster dies. Only applies to rallies still forming." onclick="showInfo(event,this)">ⓘ</span></span> <input type=number id=sbuf value=90 style=width:60px>
    <span class=dim>Win ratio <span class=info data-info="How much stronger than the monster a preset must be to attempt the steal: preset power / monster power. The scanner overstates monster power, so use below 1.0 (0.8 = we attempt monsters up to preset/0.8)." onclick="showInfo(event,this)">ⓘ</span></span> <input type=number id=swr value=0.8 step=0.05 style=width:60px>
    <span class=dim>Min power (M) <span class=info data-info="Do NOT steal monsters weaker than this, in MILLIONS of power (0 = no minimum). E.g. 10 = skip any monster under 10M power." onclick="showInfo(event,this)">ⓘ</span></span> <input type=number id=sminpow value=0 min=0 step=1 style=width:60px>
    <span class=dim>Combat margin (s) <span class=info data-info="Seconds the enemy needs to KILL the monster after landing. Extra window to reach a steal: we can arrive up to this many seconds after the enemy and still snipe the kill. Higher = more aggressive (more steals, but more wasted marches if they kill it first). 0 = only steal if we land before the enemy arrives." onclick="showInfo(event,this)">ⓘ</span></span> <input type=number id=scmargin value=25 min=0 step=5 style=width:60px>
    <span class=dim>Win margin (s) <span class=info data-info="Seconds of head start REQUIRED to attempt a steal: we go only if we arrive MORE than this many seconds before the enemy's kill time. 0 = attempt whenever we arrive even 1s before them (aggressive — grabs photo-finishes but risks wasted marches if our ETA is a little off, since our own march also needs a moment to kill). Raise to demand a bigger safety cushion (fewer steals, safer). Was hardcoded to 3." onclick="showInfo(event,this)">ⓘ</span></span> <input type=number id=swm value=0 min=0 step=1 style=width:60px>
    <button class=sec id=stealsavebtn onclick=saveStealCfg()>Save</button>
    <button class=sec onclick=loadSteals() title="Refresh active enemy attacks from V3">↻ 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=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=[];
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{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=='steal'||k=='farm'||k=='join'||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=='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>· '+e.note+'</span>'):'';
  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||'',steal_sec_per_tile:+document.getElementById('spt').value||1,steal_enemy_buffer:+document.getElementById('sbuf').value||0,win_ratio:+document.getElementById('swr').value||0.8,steal_min_power:+document.getElementById('sminpow').value||0,steal_combat_margin:+document.getElementById('scmargin').value||0,steal_win_margin:+document.getElementById('swm').value||0})});clearStealDirty();loadSteals()}
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'));}
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()}
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, MAX_SEND={}, _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){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></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+='<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+='<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>';
    h+='<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>';
    h+=(aj?'':'<button class="sec addt" onclick=addTroop('+i+')>+ ADD TROOP</button>')+'</div>';
    h+=targetSelectorHtml(p,i);
    h+='<div class=ptroops data-p='+i+(aj?' style=display:none':'')+'>';(p.troops||[]).forEach((t,ti)=>{h+=troopLine(i,ti,t)});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>':'')+'<span class=pmarch id=pm_'+i+'></span><span class=pcap id=pcap_'+i+' style="margin-left:8px;font-size:11px;color:#8b949e"></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 sp=0;const gg=GENS.find(x=>x.id==p.general_id);if(gg)sp+=(+gg.power||0);const aa=GENS.find(x=>x.id==p.assistant_id);if(aa)sp+=(+aa.power||0);const t1=TROOPSL.find(x=>x.id==pickT1());if(t1)sp+=(+t1.power||0);return sp;}let s=(p.troops||[]).reduce((a,tr)=>{const m=TROOPSL.find(x=>x.id==tr.t);return a+(m?(m.power||0)*(+tr.n||0):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}
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 tgt=new Set(p.targets||[]);
  if(!selm.length)return '<div class="row ptgtrow"><span class=flbl>🎯 target</span> <span class=dim style="font-size:11px">— mark monsters in the Targets tab first (this preset uses all of them) —</span></div>';
  const chips=selm.map(function(m){var key=m.name+'|'+m.level,on=tgt.has(key);return '<span class="tchip'+(on?' on':'')+'" data-key="'+key+'" onclick="toggleTarget('+i+',this)">'+m.name+(m.level?' L'+m.level:'')+'</span>';}).join('');
  const nActive=selm.filter(m=>tgt.has(m.name+'|'+m.level)).length;   // sólo cuenta targets aún seleccionados (consistente con el fallback a default del backend)
  const lbl=nActive?(nActive+' selected'):'all (default)';
  return '<div class="row ptgtrow"><span class=flbl>🎯 target</span> <span class=tchips>'+chips+'</span> <span class=tgtlbl>'+lbl+'</span></div>';
}
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(){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,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})});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></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+'</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></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 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 emulator-6004.'}
async function reattach(){await j('/api/reattach',{method:'POST',headers:{'Content-Type':'application/json'},body:'{}'})}
async function reloadServer(){
  if(!confirm('FULL restart?\n\nThis KILLS and relaunches the emulator (cold boot from snapshot) + frida-server + Evony + the backend. Takes ~1 minute and interrupts any active marches. Use it if the game froze or the bot is stuck.')) return;
  try{await j('/api/reload',{method:'POST',headers:{'Content-Type':'application/json'},body:'{}'})}catch(e){}
  document.getElementById('astat').innerHTML='<span class=warn>● full restart… (~1 min, emulator rebooting)</span>';
  setTimeout(()=>location.reload(),80000)}
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{col='#58a6ff';lab='⚔ Marching · '+fmtClock(rem)}
    let 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>';
  }
}
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>';}
function renderBuffs(buffs){const box=document.getElementById('buffRows');if(!box)return;if(!buffs){box.innerHTML='<span class=dim>—</span>';return;}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;const opts=vs.map(v=>'<option value='+v.id+'>'+(BVAR[v.id]||('#'+v.id))+' — '+v.num+'</option>').join('');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){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+'</div>';}box.innerHTML=h;}
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.attached?(a.ready?'<span class=ok>● Agent OK</span>':'<span class=warn>● Attaching…</span>')+' - Server #'+(a.server||s.state.server_id)+(nm?' · <span class=dim>👤 '+nm+(s.account&&s.account.power?' ('+fmtK(s.account.power)+')':'')+'</span>':''):'<span class=bad>● No agent</span>')+(s.state.auto?' · <span class=ok>🤖 AUTO ON</span>':'');
  const au=document.getElementById('autochk'); if(au&&document.activeElement!==au) au.checked=!!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);
  {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.troops||[]).length); 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 ji=document.getElementById('joininfo'); const gw=(ac.guild_wars||[]); const onAJ=(PRESETS||[]).some(p=>p.enabled&&p.mode=='autojoin'&&(p.troops||[]).length);
   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; 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 pa=s.preset_avail||{}; let nShort=0;                       // aviso: presets que NO pueden lanzarse (tropas en Hospital/marchando/usadas)
   (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 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')}})}})();
restoreCards();restoreView();
(async()=>{await loadMons();await loadPresets();poll();setInterval(poll,2500)})()
</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_v1 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()
