#!/usr/bin/env python3
"""
Evony Mesh — HUB (orquestador de la malla).  Fase 1: membership + liveness + dashboard.

Proceso INDEPENDIENTE. No toca ningún cliente de Evony ni ningún agente Frida: solo
recibe latidos (heartbeats) de los nodos por HTTP y muestra la salud de la flota.

Diseño (ver MESH_DESIGN.md):
  · Los nodos (bot_web con MESH_HUB_URL puesto) hacen POST /hb cada ~10 s con sus métricas.
  · El hub deriva el estado de cada nodo por la ANTIGÜEDAD de su último latido:
        sano       : age <  HEALTHY_MAX   (default 60 s)
        degradado  : HEALTHY_MAX <= age < DOWN_AFTER  (default 300 s = 5 min, CONSERVADOR)
        caído      : age >= DOWN_AFTER
    Un freeze/reinicio del juego (2-5 min) deja el nodo DEGRADADO pero NO lo saca de la
    malla: su partición no se redistribuye hasta que cae del todo. Anti-thrash.
  · "epoch" = contador que sube SOLO cuando cambia el conjunto de miembros ACTIVOS
    (sano+degradado). Sube al entrar un nodo nuevo o al caer uno tras el timeout; NO sube
    por un degradado transitorio. En Fase 2 el Partition Manager reparte en cada epoch.

Endpoints:
  POST /hb      -> {node_id, name, device, port, scan{}, agent{}, account{}}  (JSON)
  GET  /fleet   -> estado de toda la flota (JSON)  [lo consume el dashboard]
  GET  /        -> dashboard HTML (auto-refresh)
  GET  /health  -> {"ok": true}   (liveness del propio hub)

Arranque:
  MESH_HUB_PORT=8799 /usr/bin/python3 hub.py     (defaults abajo)
"""
import os, sys, json, time, threading, secrets, hmac, hashlib, urllib.request, subprocess
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import urlparse, parse_qs
from http.cookies import SimpleCookie

# ── Configuración (env-overridable) ──────────────────────────────────────────
HUB_PORT     = int(os.environ.get("MESH_HUB_PORT", "8799"))
HUB_BIND     = os.environ.get("MESH_HUB_BIND", "127.0.0.1")   # 127.0.0.1 = solo local; 0.0.0.0 = LAN/Tailscale (para el proxy de Plesk)
# Basic Auth SOLO para el dashboard y sus datos (/, /fleet, /balance). Se activa si están las 2 envs;
# vacío = sin auth (comportamiento de hoy). NUNCA hardcodear la pass aquí (se filtraría al repo).
# El tráfico de los NODOS (/hb, /objs, /health) NUNCA pide auth -> la malla no se ve afectada.
HUB_USER     = os.environ.get("MESH_HUB_USER", "")
HUB_PASS     = os.environ.get("MESH_HUB_PASS", "")
# Token de sesión DETERMINISTA (derivado de la pass, como los UIs de nodo) -> la cookie sigue válida
# tras reiniciar el hub = NO hay que volver a hacer login en cada cambio. Cambia solo si cambia la pass.
_HUB_TOKEN   = hmac.new(hashlib.sha256(HUB_PASS.encode()).digest(), b"mesh_hub_session", hashlib.sha256).hexdigest() if HUB_PASS else ""
# Fase 6 — Shortcuts en el hub: proxya endpoints de lectura/acción a un nodo seleccionado. El hub
# se autentica al nodo con el MISMO token de sesión que el nodo firma con su BOT_PASS (mismas creds).
NODE_PASS    = os.environ.get("BOT_PASS", "")
_NODE_TOKEN  = hmac.new(hashlib.sha256(NODE_PASS.encode()).digest(), b"bot_pixel_session", hashlib.sha256).hexdigest() if NODE_PASS else ""
# endpoints de nodo permitidos a través del proxy (solo Shortcuts: lectura + whisper; NO control del bot)
PROXY_WHITELIST = {"/api/quick", "/api/quick_list", "/api/monsters", "/api/attacks",
                   "/api/bubble", "/api/mesh_roster", "/api/send_coords",
                   "/api/resources", "/api/subcities", "/api/svs",   # scanner tabs (lectura)
                   "/api/members", "/api/whisper_here",
                   "/api/stop_bot", "/api/reload", "/api/auto", "/api/truce_now"}   # control por-nodo desde el hub (Stop/Restart/AUTO/renew bubble)
# Selección de Shortcuts GLOBAL del hub (familias|nivel elegidos) — persistida a disco para que sea
# la MISMA desde cualquier dispositivo/navegador (no localStorage). Se edita desde el Menu del panel.
SC_SEL_FILE   = os.path.join(os.path.dirname(os.path.abspath(__file__)), "sc_sel.json")
_SC_SEL_LOCK  = threading.Lock()
SC_WHISPER = ""   # destinatario por defecto del whisper (uid), GLOBAL y persistido
def _load_sc_sel():
    global SC_WHISPER
    try:
        d = json.load(open(SC_SEL_FILE))
        if isinstance(d, dict):     # formato nuevo: {"sel":[...], "whisper_to": "uid"}
            SC_WHISPER = str(d.get("whisper_to", "") or "")
            return set(str(x) for x in (d.get("sel") or []))
        return set(str(x) for x in d)   # formato antiguo: lista de familias
    except Exception:
        return set()
SC_SEL = _load_sc_sel()
def _save_sc_sel():
    try:
        with open(SC_SEL_FILE, "w") as f:
            json.dump({"sel": sorted(SC_SEL), "whisper_to": SC_WHISPER}, f)
    except Exception as e:
        print(f"[hub] save sc_sel err: {e}", flush=True)
# ---- Panel de Settings del HUB (persistido, global). Sección 1: aviso de burbuja por whisper. ----
HUB_SETTINGS_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "hub_settings.json")
HUB_SETTINGS = {"bubble_whisper_from": 0, "bubble_alert_uids": []}   # from=port del nodo emisor (0=default RexValerius); uids destinatarios
def _load_hub_settings():
    try:
        d = json.load(open(HUB_SETTINGS_FILE))
        if isinstance(d, dict):
            HUB_SETTINGS["bubble_whisper_from"] = int(d.get("bubble_whisper_from", 0) or 0)
            HUB_SETTINGS["bubble_alert_uids"] = [int(x) for x in (d.get("bubble_alert_uids") or []) if str(x).lstrip('-').isdigit()]
    except Exception: pass
_load_hub_settings()
def _save_hub_settings():
    try:
        with open(HUB_SETTINGS_FILE, "w") as f: json.dump(HUB_SETTINGS, f)
    except Exception as e:
        print(f"[hub] save hub_settings err: {e}", flush=True)
HEALTHY_MAX  = float(os.environ.get("MESH_HEALTHY_MAX", "60"))    # < esto = sano
DOWN_AFTER   = float(os.environ.get("MESH_DOWN_AFTER", "300"))    # >= esto = caído (conservador)
HB_EXPECT    = float(os.environ.get("MESH_HB_EXPECT", "10"))      # cada cuánto se espera un latido (informativo)
MAX_BODY     = 256 * 1024                                          # 256 KB: un latido nunca es tan grande
MAX_BODY_OBJS = 16 * 1024 * 1024                                   # 16 MB: push de objetos (deltas, pero holgado)

assert 0 < HEALTHY_MAX < DOWN_AFTER, "MESH_HEALTHY_MAX debe ser < MESH_DOWN_AFTER"

# ── Estado en memoria ────────────────────────────────────────────────────────
# NODES[node_id] = {
#   id, name, device, port, first_seen, last_hb, hb_count, addr,
#   scan{}, agent{}, account{}   (últimas métricas reportadas)
# }
_LOCK  = threading.RLock()
NODES  = {}
_EPOCH = {"n": 0, "members": frozenset(), "changed_at": time.time(), "last_change": "init"}
_STARTED = time.time()

# MESH Fase 3 — store agregado de monstruos. Clave (x,y) -> {x,y,t,id,lv,ts,node}.
# Dedup por coord conservando el MÁS fresco (ts mayor). Delta-push/pull para poco tráfico.
MESH_OBJS_TTL = float(os.environ.get("MESH_OBJS_TTL", "2700"))   # 45 min (ventana rally); se podan los más viejos
_OBJS_LOCK = threading.RLock()
OBJS_AGG = {}

# MESH Fase 4 — rallies abiertos que lidera algún nodo (para relleno cross-account).
# war_id -> {war_id, leader_node, lwx, lwy, tx, ty, mst, mname, mlevel, mpower, group, ts}.
# Se llena desde own_rallies del /hb; el líder lo re-reporta cada latido; si deja de
# reportarlo (rally disparado/cancelado) o el líder cae, se poda. Bajo _LOCK.
RALLIES_AGG = {}
_RALLY_STALE = float(os.environ.get("MESH_RALLY_STALE", "30"))   # sin re-reporte en 30s = rally ido


# ══ MESH Fase 5 — REBALANCEO POR THROUGHPUT (capacidad real de cada nodo) ═══════
# Los slices i%N ya están balanceados EN DENSIDAD (medido: ratio 1.04). Lo que
# desequilibra el refresco es que cada nodo escanea a distinta velocidad (reg/s):
# emulador/sesión más lento -> misma porción tarda más. Solución: repartir la
# periferia en B buckets fijos y darle a cada nodo un nº de buckets PROPORCIONAL a
# su capacidad medida -> se iguala el TIEMPO de barrido, no el conteo. Auto-escala
# (entra una cuenta -> se mide y recibe su parte) y se auto-cura (nodo lento/caído
# reparte su carga). El AGENTE NO cambia: ya filtra outer[i] con (i % n) ∈ cover
# para cualquier n/cover -> aquí basta con n=B y cover=buckets de este nodo.
MESH_BUCKETS   = int(os.environ.get("MESH_BUCKETS", "120"))     # granularidad (> nº nodos)
BALANCE_MODE   = os.environ.get("MESH_BALANCE", "weighted")     # "weighted" (por throughput, DEFAULT: flota heterogénea = el lento recibe menos buckets, frescura más pareja; churn mitigado con debounce+dead-band) | "count" (i%N, más simple)
REBAL_COOLDOWN = float(os.environ.get("MESH_REBAL_COOLDOWN", "120"))  # s mínimos entre recálculos
REBAL_DEV      = float(os.environ.get("MESH_REBAL_DEV", "0.20"))      # recomputa si desv. de sweep previsto supera esto
# DEBOUNCE del set de balanceo (fix del CHURN de covers = causa raíz de los freezes). El plan weighted
# se calculaba sobre 'scanning' (age<60 + ready + connected) -> un FREEZE transitorio (heartbeat>60s o
# ready=False durante un hot-relaunch, que se auto-cura en ~2-3min) sacaba al nodo del set -> cambiaba el
# key -> recompute TOTAL saltando el cooldown -> nuevas covers para TODOS -> cada nodo re-aplica la
# partición (re-deriva ~600 regiones en il2cpp) -> wedgea a los FÍSICOS -> más freezes (bucle). Con el
# debounce, un nodo que deja de escanear SIGUE contando para el balanceo hasta REBAL_DEBOUNCE s -> un
# freeze transitorio NO reshuffle-a a nadie (su franja queda ~stale unos min, aceptable < barrido ~5min);
# solo sale del set si lleva caído de verdad (muerte real) -> a lo sumo UN reshuffle.
REBAL_DEBOUNCE = float(os.environ.get("MESH_REBAL_DEBOUNCE", "240"))  # s que un nodo sigue contando para el balanceo tras dejar de escanear
_CAP_ALPHA     = 0.3                                            # suavizado EMA de reg/s
_BAL = {"assign": None, "key": None, "ts": 0.0}                # caché de la última asignación ponderada (anti-thrash)
# SAFEGUARD anti-churn: un nodo que se RELANZA a menudo (pass reseteó) recibe MENOS periferia -> covers
# más pequeños -> menos re-apply/wedge y sus buckets huérfanos NO se amontonan sobre otros flaky. Penaliza
# la cap del nodo por su rate reciente de relaunches. Con 0 relaunches el factor es 1.0 (no toca el balance
# sano); solo actúa en episodios. Solo REDUCE (nunca sube) y con SUELO (no deja a nadie sin cubrir del todo).
STAB_WINDOW = float(os.environ.get("MESH_STAB_WINDOW", "1800"))  # ventana (s) del rate de relaunches (30min)
STAB_K      = float(os.environ.get("MESH_STAB_K", "0.30"))       # penalización por relaunch en la ventana
STAB_FLOOR  = float(os.environ.get("MESH_STAB_FLOOR", "0.5"))    # factor mínimo (un flaky nunca baja de la mitad de su cap)

# --- espiral IDÉNTICA al agente (validada: core=421, outer=1731) ---
_S_MAP_MIN, _S_MAP_MAX = 0, 1200
_S_CX, _S_CY = 603, 570
_S_R_CORE, _S_STEP = 320, 28
_S_MAX_R = max(_S_CX - _S_MAP_MIN, _S_MAP_MAX - _S_CX, _S_CY - _S_MAP_MIN, _S_MAP_MAX - _S_CY)
_PERIPH = None
def _clampc(v): return max(_S_MAP_MIN, min(_S_MAP_MAX, int(round(v))))

def _periphery_coords():
    """outer[] — mismas coords y MISMO ORDEN que el agente (para que i%B coincida)."""
    global _PERIPH
    if _PERIPH is not None:
        return _PERIPH
    import math
    seen = set(); core = []; outer = []
    def push(x, y):
        x = _clampc(x); y = _clampc(y); k = (x, y)
        if k in seen: return
        seen.add(k)
        (core if math.hypot(x - _S_CX, y - _S_CY) <= _S_R_CORE else outer).append((x, y))
    push(_S_CX, _S_CY)
    r = 0
    while r < _S_MAX_R:
        s = _S_STEP; r = min(r + s, _S_MAX_R)
        x0, x1, y0, y1 = _S_CX - r, _S_CX + r, _S_CY - r, _S_CY + r
        x = x0
        while x <= x1: push(x, y0); x += s
        y = y0 + s
        while y <= y1: push(x1, y); y += s
        x = x1 - s
        while x >= x0: push(x, y1); x -= s
        y = y1 - s
        while y > y0: push(x0, y); y -= s
    _PERIPH = outer
    return outer

def _obj_region_key(ox, oy):
    """Atribuye un objeto a la región (lattice paso 28) que lo escanearía."""
    rx = _S_CX + round((ox - _S_CX) / _S_STEP) * _S_STEP
    ry = _S_CY + round((oy - _S_CY) / _S_STEP) * _S_STEP
    return (_clampc(rx), _clampc(ry))

_CORE_N = 421          # regiones del núcleo (validado). Lo escanean TODOS los nodos cada vuelta.
_CH_CACHED = None
def _order_len(periph_n):
    """Longitud del ORDEN interleaved de un nodo con periph_n regiones de periferia = mismo cálculo
    que el agente: núcleo intercalado cada CH regiones. CH se fija con la periferia COMPLETA (1731),
    igual para todos. Para periph_n < CH (caso normal con >=5 nodos) -> orden = núcleo + periferia.
    sweep_real = order_len / cap. El NÚCLEO es un suelo fijo por nodo -> limita al nodo lento."""
    import math
    global _CH_CACHED
    if _CH_CACHED is None:
        P = len(_periphery_coords())
        CH = max(8, round(12 * 1000 / 130 - _CORE_N))
        def fp(q): return _CORE_N * math.ceil(P / max(1, q)) + P
        while CH < P and fp(CH) * 130 / 1000 > 450:
            CH += 4
        _CH_CACHED = CH
    if periph_n <= 0:
        return _CORE_N
    return math.ceil(periph_n / _CH_CACHED) * _CORE_N + periph_n

_WTS = {"w": None, "reg": None, "ts": 0.0}
def _bucket_weights():
    """Peso por bucket = Σ regiones (1 base + objetos atribuidos). Densidad real del
    agregado (OBJS_AGG). Cacheado 60s. Devuelve (w[B], regcount[B])."""
    now = time.time()
    if _WTS["w"] is not None and now - _WTS["ts"] < 60:
        return _WTS["w"], _WTS["reg"]
    outer = _periphery_coords()
    B = MESH_BUCKETS
    dens = {}
    with _OBJS_LOCK:
        for (x, y) in OBJS_AGG.keys():
            k = _obj_region_key(x, y)
            dens[k] = dens.get(k, 0) + 1
    w = [0.0] * B; reg = [0] * B
    for i, (x, y) in enumerate(outer):
        b = i % B
        w[b] += 1.0 + dens.get((x, y), 0)
        reg[b] += 1
    _WTS.update(w=w, reg=reg, ts=now)
    return w, reg

def _node_caps_locked(scanning):
    """Capacidad (reg/s) de cada nodo que escanea. Usa la EMA cap_regps que mantiene
    _ingest_hb; si un nodo aún no tiene medida, usa la MEDIANA de la flota (parte justa
    hasta medirlo). Devuelve {nid: reg/s>0}."""
    caps = {}
    known = [NODES[n].get("cap_regps") for n in scanning if NODES[n].get("cap_regps")]
    known = sorted(c for c in known if c and c > 0)
    med = known[len(known) // 2] if known else 1.0
    now = time.time()
    for nid in scanning:
        c = NODES[nid].get("cap_regps")
        base = c if (c and c > 0) else med
        # SAFEGUARD anti-churn: penaliza la cap por el rate reciente de relaunches. 0 relaunches -> fac=1.0
        # (no toca el balance sano); flaky -> fac<1 (acotado a STAB_FLOOR) -> menos periferia -> menos churn.
        rl = [t for t in (NODES[nid].get("relaunches") or []) if now - t < STAB_WINDOW]
        NODES[nid]["relaunches"] = rl   # prune (bajo _LOCK)
        fac = max(STAB_FLOOR, 1.0 / (1.0 + STAB_K * len(rl)))
        caps[nid] = base * fac
    return caps

def _weighted_assign_locked(scanning):
    """Greedy: asigna cada bucket (más pesado primero) al nodo con menor load/cap ->
    iguala el TIEMPO de barrido previsto. Cobertura SIEMPRE completa. Con caché+cooldown
    (anti-thrash): solo recomputa si cambia el set de nodos, salta el cooldown y la
    desviación de sweep supera el umbral, o no hay caché. Devuelve {nid:{cover,weight,regions,cap}}."""
    ids = sorted(scanning)
    if not ids:
        return {}
    now = time.time()
    _w, reg = _bucket_weights()   # reg[b] = nº de regiones del bucket (densidad uniforme -> el conteo
                                  # ES el coste; balanceamos regiones/cap = TIEMPO real de barrido)
    caps = _node_caps_locked(ids)
    key = tuple(ids)
    # sweep real previsto de una asignación evaluada con las capacidades ACTUALES (no las
    # congeladas cuando se calculó) -> la histéresis juzga bien el plan viejo al cambiar cap.
    def _refresh(a):
        for nid in a:
            if nid in caps: a[nid]["cap"] = caps[nid]
        return a
    def _dev(a):
        sw = [_order_len(v["regions"]) / caps[nid] for nid, v in a.items() if caps.get(nid)]
        m = sum(sw) / len(sw) if sw else 0
        return max(abs(s - m) / m for s in sw) if m else 0
    cached = _BAL["assign"]
    if cached is not None and _BAL["key"] == key and (now - _BAL["ts"]) < REBAL_COOLDOWN:
        return _refresh(cached)   # dentro del cooldown y mismo set -> cover estable (cap al día para la UI)
    # (re)cálculo: greedy LPT core-aware. Cada bucket va al nodo que MINIMIZA su sweep real previsto
    # = (núcleo + regiones)/cap. Así el núcleo (suelo fijo por nodo) se tiene en cuenta y el nodo lento
    # recibe MENOS periferia (o ninguna) hasta que su suelo de núcleo iguala al resto.
    regs = {nid: 0 for nid in ids}
    cover = {nid: [] for nid in ids}
    for b in sorted(range(MESH_BUCKETS), key=lambda b: (-reg[b], b)):
        # sweep real previsto si este nodo se queda el bucket = order_len(regs+reg)/cap. order_len
        # incluye las repeticiones de núcleo (cada CH=436 regiones +1 núcleo) -> el greedy NO
        # sobrecarga a un nodo pasando el escalón de doble-núcleo.
        nid = min(ids, key=lambda i: (_order_len(regs[i] + reg[b]) / caps[i], i))
        cover[nid].append(b); regs[nid] += reg[b]
    out = {nid: {"cover": sorted(cover[nid]), "regions": regs[nid], "cap": caps[nid]} for nid in ids}
    # histéresis (DEAD-BAND): si el plan cacheado ya es ACEPTABLE (desv. de sweep <= REBAL_DEV con las
    # cap ACTUALES), CONSÉRVALO aunque un recompute fresco sea MARGINALMENTE mejor. Antes exigía además
    # `_dev(cached) <= _dev(out)`, pero `out` recién optimizado casi siempre gana por poco -> casi nunca
    # conservaba -> adoptaba covers nuevas cada recompute = CHURN (los físicos, con throughput ruidoso por
    # adb-WiFi, cambiaban de cover ~cada cooldown -> re-aplicar partición wedgea al agente -> freezes).
    # Ahora solo reasigna si el cacheado se DESEQUILIBRA de verdad (> REBAL_DEV), no por ruido/mejora marginal.
    if cached is not None and _BAL["key"] == key:
        if _dev(cached) <= REBAL_DEV:
            _BAL["ts"] = now
            return _refresh(cached)
    _BAL.update(assign=out, key=key, ts=now)
    return out


def _status_for(age):
    """Estado derivado de la antigüedad del último latido (segundos)."""
    if age is None:
        return "unknown"
    if age < HEALTHY_MAX:
        return "healthy"
    if age < DOWN_AFTER:
        return "degraded"
    return "down"


def _active_members_locked():
    """IDs de nodos ACTIVOS (sano+degradado): los que aún cuentan para la partición.
    Debe llamarse con _LOCK tomado."""
    now = time.time()
    out = set()
    for nid, n in NODES.items():
        age = now - n["last_hb"]
        if age < DOWN_AFTER:
            out.add(nid)
    return frozenset(out)


def _roster_locked():
    """Fase 5b — roster de miembros del mesh para el whisper ENRUTADO: [{node_id,name,port,uid}]
    de los nodos ACTIVOS con uid conocido. El nodo lo usa para poblar el selector 'Whisper from'
    y para enrutar (POST local a http://127.0.0.1:<port>/api/whisper_here). _LOCK tomado."""
    now = time.time()
    out = []
    for nid, n in NODES.items():
        if now - n["last_hb"] >= DOWN_AFTER:
            continue
        uid = int(n.get("uid", 0) or 0)
        port = n.get("port")
        if uid > 0 and port:
            out.append({"node_id": nid, "name": n.get("name", nid), "port": int(port), "uid": uid})
    out.sort(key=lambda r: r["name"].lower())
    return out


def _recompute_epoch_locked(reason=""):
    """Sube epoch SOLO si cambió el conjunto de miembros activos. _LOCK tomado."""
    members = _active_members_locked()
    if members != _EPOCH["members"]:
        added   = members - _EPOCH["members"]
        removed = _EPOCH["members"] - members
        _EPOCH["members"]     = members
        _EPOCH["n"]          += 1
        _EPOCH["changed_at"]  = time.time()
        parts = []
        if added:   parts.append("+" + ",".join(sorted(added)))
        if removed: parts.append("-" + ",".join(sorted(removed)))
        _EPOCH["last_change"] = (reason + " " if reason else "") + " ".join(parts)
        print(f"[hub] epoch -> {_EPOCH['n']}  ({_EPOCH['last_change']})  miembros={sorted(members)}", flush=True)
        return True
    return False


def _ingest_hb(payload, addr):
    """Registra/actualiza un nodo a partir de su latido. Devuelve la respuesta para el nodo."""
    nid = payload.get("node_id")
    if not isinstance(nid, str) or not nid.strip():
        raise ValueError("node_id ausente o no es string")
    nid = nid.strip()
    now = time.time()
    with _LOCK:
        n = NODES.get(nid)
        if n is None:
            n = {"id": nid, "first_seen": now, "hb_count": 0}
            NODES[nid] = n
            print(f"[hub] nodo NUEVO: {nid}  ({payload.get('name') or ''} @ {payload.get('device') or '?'})", flush=True)
        n["name"]     = str(payload.get("name") or nid)
        n["device"]   = str(payload.get("device") or "")
        n["port"]     = payload.get("port")
        n["addr"]     = addr
        prev_hb = n.get("last_hb")
        n["last_hb"]  = now
        n["hb_count"] = n.get("hb_count", 0) + 1
        # Fase 5 — capacidad (reg/s) por EMA, ANTES de sobrescribir el scan. Mide el avance
        # de idx en el orden interleaved. Solo cuenta si order NO cambió (una partición nueva
        # reinicia idx -> saltar esa muestra) y el avance es sano (0 < di < order).
        try:
            old = n.get("scan") or {}; new = payload.get("scan") or {}
            # el snapshot del nodo trae idx/order PLANOS en scan (no en scan.sweep)
            oidx, oord = old.get("idx"), old.get("order")
            nidx, nord = new.get("idx"), new.get("order")
            if (prev_hb and isinstance(oidx, int) and isinstance(nidx, int)
                    and oord and nord and oord == nord):
                di = nidx - oidx
                if di < 0: di += nord           # dio una vuelta
                dt = now - prev_hb
                if 0 < di < nord and 0.5 < dt < 120:
                    inst = di / dt
                    cur = n.get("cap_regps")
                    n["cap_regps"] = inst if not cur else (_CAP_ALPHA * inst + (1 - _CAP_ALPHA) * cur)
            # SAFEGUARD anti-churn: relaunch = el contador `pass` BAJÓ (se resetea al re-attach del agente).
            opass, npass = old.get("pass"), new.get("pass")
            if isinstance(opass, int) and isinstance(npass, int) and npass < opass:
                rl = [t for t in (n.get("relaunches") or []) if now - t < STAB_WINDOW]
                rl.append(now); n["relaunches"] = rl
        except Exception:
            pass
        # métricas (dicts opcionales; se guardan tal cual, ya vienen saneados del nodo)
        for k in ("scan", "agent", "account"):
            v = payload.get(k)
            if isinstance(v, dict):
                n[k] = v
        # Fase 5b — uid de la cuenta del nodo (para el roster del whisper enrutado)
        try:
            _uid = int((payload.get("account") or {}).get("uid", 0) or 0)
            if _uid > 0:
                n["uid"] = _uid
        except Exception:
            pass
        _recompute_epoch_locked(reason="hb")
        assignment = _assignments_locked().get(nid)   # Fase 2: partición para ESTE nodo
        # MESH Fase 4: registrar los rallies que este nodo lidera (own_rallies del snapshot)
        own = payload.get("own_rallies")
        if isinstance(own, list):
            reported = set()
            for r in own:
                try:
                    wid = int(r.get("war_id") or 0)
                    if wid <= 0:
                        continue
                    reported.add(wid)
                    RALLIES_AGG[wid] = {**r, "war_id": wid, "leader_node": nid, "ts": now}
                except Exception:
                    continue
            # quitar los rallies que ESTE líder ya no reporta (disparado/cancelado)
            for wid in [w for w, v in list(RALLIES_AGG.items()) if v.get("leader_node") == nid and w not in reported]:
                del RALLIES_AGG[wid]
        # rallies a rellenar por este nodo = los liderados por OTROS y aún joinables
        mesh_rallies = [v for v in RALLIES_AGG.values()
                        if v.get("leader_node") != nid and _rally_joinable(v, now)]
        return {"ok": True, "now": now, "epoch": _EPOCH["n"],
                "assignment": assignment,
                "mesh_rallies": mesh_rallies,
                "roster": _roster_locked(),   # Fase 5b — miembros del mesh para el whisper enrutado
                "thresholds": {"healthy_max": HEALTHY_MAX, "down_after": DOWN_AFTER, "hb_expect": HB_EXPECT}}


def _assignments_locked():
    """MESH Fase 2 — Partition Manager. Devuelve {node_id: {epoch, n, rank, cover}}.

    · n     = nº de nodos ACTIVOS (sano+degradado). Estable durante un freeze -> el nodo
              degradado conserva su rango -> recuperarse NO provoca reshuffle.
    · rank  = índice del nodo en sorted(activos) (determinista).
    · cover = rangos que ESTE nodo debe barrer: su rank base + (si está sano) su parte de
              los rangos HUÉRFANOS (los de nodos degradados, que ahora no barren),
              repartidos round-robin entre los sanos = COBERTURA BEST-EFFORT.
    El agente barre outer[i] donde (i % n) ∈ cover. Core lo barren todos (no entra aquí).
    Debe llamarse con _LOCK tomado."""
    now = time.time()
    active  = sorted(nid for nid, n in NODES.items() if now - n["last_hb"] < DOWN_AFTER)
    # SCANNING = late fresco Y el agente escanea de verdad (ready + juego conectado). Un nodo que
    # late pero con el agente caído (kick/enganchando) NO cubre su franja -> su rango es HUÉRFANO
    # y lo reparten los que SÍ escanean = AUTO-CURA de la cobertura (el mapa sigue completo).
    def _is_scanning(nid):
        n = NODES[nid]
        if now - n["last_hb"] >= HEALTHY_MAX:
            return False
        ag = n.get("agent") or {}
        return bool(ag.get("ready")) and ag.get("connected") == 1
    scanning = set(nid for nid in active if _is_scanning(nid))
    for nid in scanning:
        NODES[nid]["last_scan_ok"] = now      # sello para el DEBOUNCE del balanceo
    # SET DEBOUNCED para el plan WEIGHTED: un nodo que dejó de escanear sigue contando hasta
    # REBAL_DEBOUNCE s -> un freeze transitorio (se auto-cura en ~2-3min) NO reshuffle-a covers
    # (ese churn era la causa raíz de los freezes). Solo sale si lleva caído de verdad.
    bal_scanning = set(nid for nid in active
                       if nid in scanning or (now - NODES[nid].get("last_scan_ok", 0) < REBAL_DEBOUNCE))
    N = len(active)
    rank_of = {nid: i for i, nid in enumerate(active)}
    # rangos huérfanos = los de nodos activos que NO escanean ahora (degradados o agente caído)
    orphan_ranks = sorted(rank_of[nid] for nid in active if nid not in scanning)
    extra = {nid: [] for nid in active}
    ssorted = sorted(scanning)
    if ssorted:
        for j, orank in enumerate(orphan_ranks):
            extra[ssorted[j % len(ssorted)]].append(orank)
    # --- Fase 5: plan ponderado por throughput (se calcula SIEMPRE, para mostrarlo en la
    # UI aunque estemos en modo 'count'; se APLICA solo en modo 'weighted'). ---
    wplan = _weighted_assign_locked(bal_scanning) if bal_scanning else {}
    plan_meta = {}
    if wplan:
        # sweep previsto = (núcleo + periferia del nodo) / cap = order_len/cap (segundos reales,
        # incluye el suelo del núcleo). La media de balance se toma SOLO sobre los nodos que poseen
        # periferia (regions>0); un nodo con 0 buckets solo re-escanea núcleo -> es 'core-only'
        # (redundante), NO limita la frescura y NO cuenta ni se marca como 'dragging'.
        sweeps = {nid: (_order_len(v["regions"]) / v["cap"] if v["cap"] else 0) for nid, v in wplan.items()}
        own_sw = [sweeps[nid] for nid, v in wplan.items() if v["regions"] > 0]
        msweep = (sum(own_sw) / len(own_sw)) if own_sw else 0
        treg = sum(v["regions"] for v in wplan.values()) or 1
        tcap = sum(v["cap"] for v in wplan.values()) or 1.0
        for nid, v in wplan.items():
            sw = sweeps[nid]; owns = v["regions"] > 0
            plan_meta[nid] = {
                "cap": round(v["cap"], 3), "buckets": len(v["cover"]), "regions": v["regions"],
                "share": round(100.0 * v["regions"] / treg, 1),        # % de regiones que cubre
                "target": round(100.0 * v["cap"] / tcap, 1),           # parte justa = proporcional a su capacidad
                "sweep_s": round(sw, 1),
                "role": "owner" if owns else "core-only",              # core-only = redundante (no limita frescura)
                "balanced": bool(owns and msweep and abs(sw - msweep) / msweep <= 0.15)}

    out = {}
    if BALANCE_MODE == "weighted" and wplan:
        # APLICA: n=B buckets, cover=buckets ponderados de este nodo. Degradado -> cover=[] (su
        # carga ya la absorbieron los que escanean en el greedy -> cobertura completa).
        for nid in active:
            out[nid] = {"epoch": _EPOCH["n"], "n": MESH_BUCKETS, "rank": rank_of[nid],
                        "cover": wplan.get(nid, {}).get("cover", []),
                        "mode": "weighted", "plan": plan_meta.get(nid)}
    else:
        # COUNT (comportamiento ACTUAL i%N, intacto) + plan adjunto para la UI (modo sombra).
        for nid in active:
            r = rank_of[nid]
            cover = sorted(set([r] + extra[nid])) if nid in scanning else [r]
            out[nid] = {"epoch": _EPOCH["n"], "n": N, "rank": r, "cover": cover,
                        "mode": "count", "plan": plan_meta.get(nid)}
    return out


def _objs_ingest(node_id, rows):
    """MESH Fase 3: un nodo empuja monstruos que escaneó. Upsert por coord, gana el ts mayor.
    Devuelve cuántos aplicó."""
    applied = 0
    with _OBJS_LOCK:
        for r in rows or []:
            try:
                x = int(r.get("x") or 0); y = int(r.get("y") or 0)
                if not x or not y:
                    continue
                ts = int(r.get("ts") or 0)
                k = (x, y)
                cur = OBJS_AGG.get(k)
                if cur is not None and cur["ts"] >= ts:
                    continue
                OBJS_AGG[k] = {"x": x, "y": y, "t": int(r.get("t") or 0), "id": int(r.get("id") or 0),
                               "lv": int(r.get("lv") or 0), "ts": ts, "node": node_id}
                applied += 1
            except Exception:
                continue
    return applied


def _objs_since(since_ts, exclude_node=None):
    """MESH Fase 3: monstruos con ts > since_ts cuya versión MÁS fresca NO la aportó
    exclude_node (ese nodo ya la tiene en local) -> cada nodo recibe solo lo de los DEMÁS."""
    out = []
    with _OBJS_LOCK:
        for o in OBJS_AGG.values():
            if o["ts"] > since_ts and o["node"] != exclude_node:
                out.append({"x": o["x"], "y": o["y"], "t": o["t"], "id": o["id"], "lv": o["lv"], "ts": o["ts"]})
    return out


def _objs_prune():
    """Poda entradas más viejas que el TTL para acotar memoria."""
    cutoff = time.time() - MESH_OBJS_TTL
    with _OBJS_LOCK:
        stale = [k for k, o in OBJS_AGG.items() if o["ts"] < cutoff]
        for k in stale:
            del OBJS_AGG[k]
    return len(stale)


def _rally_joinable(v, now):
    """MESH Fase 4: un rally sigue joinable si el líder lo re-reportó hace poco y su muster
    no ha pasado. Debe llamarse con _LOCK tomado (lee RALLIES_AGG)."""
    if now - v.get("ts", 0) > _RALLY_STALE:
        return False
    mst = int(v.get("mst", 0) or 0)
    return mst == 0 or mst > now


def _rallies_prune():
    """Poda rallies stale (líder dejó de reportar / cayó) o con muster ya pasado."""
    now = time.time()
    with _LOCK:
        gone = [w for w, v in RALLIES_AGG.items() if not _rally_joinable(v, now)]
        for w in gone:
            del RALLIES_AGG[w]
    return len(gone)


def _fleet_snapshot():
    now = time.time()
    with _LOCK:
        assigns = _assignments_locked()
        nodes = []
        for nid, n in NODES.items():
            age = now - n["last_hb"]
            nodes.append({
                "id": nid, "name": n.get("name", nid), "device": n.get("device", ""),
                "port": n.get("port"), "addr": n.get("addr", ""),
                "status": _status_for(age),
                "hb_age": round(age, 1), "hb_count": n.get("hb_count", 0),
                "uptime": round(now - n.get("first_seen", now), 0),
                "assignment": assigns.get(nid),
                "scan": n.get("scan", {}), "agent": n.get("agent", {}), "account": n.get("account", {}),
            })
        nodes.sort(key=lambda x: x["name"].lower())
        members = sorted(_EPOCH["members"])
        # COBERTURA REAL del mapa (periferia): qué franja escanea cada nodo que DE VERDAD escanea
        # (late + agente ready + juego conectado). Un nodo que late pero con el agente caído NO
        # cubre su franja -> sale como HUECO. El core lo escanean todos (no entra en el reparto).
        id2name = {x["id"]: x["name"] for x in nodes}
        scanning = {x["id"]: (x["status"] == "healthy" and (x.get("agent") or {}).get("ready")
                              and (x.get("agent") or {}).get("connected") == 1) for x in nodes}
        coverage = None
        if assigns:
            n_part = int(next(iter(assigns.values()))["n"])
            mode = next(iter(assigns.values())).get("mode", "count")
            owner = {r: [] for r in range(n_part)}
            for nid, a in assigns.items():
                if scanning.get(nid):
                    for r in a.get("cover", []):
                        if 0 <= r < n_part:
                            owner[r].append(id2name.get(nid, nid))
            missing = [r for r in range(n_part) if not owner[r]]
            # Frescura REAL del mapa = peor barrido entre los nodos que POSEEN tiles (regions>0).
            # Un nodo con 0 buckets solo re-escanea el núcleo (redundante con los demás) -> NO
            # limita la frescura de ningún tile, así que NO cuenta para best/worst/spread.
            owners = [(nid, a.get("plan") or {}) for nid, a in assigns.items()
                      if scanning.get(nid) and (a.get("plan") or {}).get("regions")]
            sweeps = [p.get("sweep_s") for _, p in owners if p.get("sweep_s")]
            redundant = sum(1 for nid, a in assigns.items()
                            if scanning.get(nid) and not (a.get("plan") or {}).get("regions"))
            balance = None
            if sweeps:
                balance = {"mode": mode, "best_s": round(min(sweeps)), "worst_s": round(max(sweeps)),
                           "spread": round(max(sweeps) / min(sweeps), 2),
                           "balanced": sum(1 for _, p in owners if p.get("balanced")),
                           "total": len(sweeps), "redundant": redundant}
            coverage = {"n": n_part, "mode": mode, "complete": len(missing) == 0,
                        "missing": missing if len(missing) <= 24 else missing[:24],
                        "scanning": sum(1 for v in scanning.values() if v), "balance": balance,
                        # 'slices' solo en modo manejable (count); en weighted la UI usa el plan por nodo
                        "slices": ([{"rank": r, "owners": owner[r]} for r in range(n_part)]
                                   if n_part <= 24 else None)}
        with _OBJS_LOCK:
            mesh_objs = len(OBJS_AGG)
        rallies = sorted(
            [{"war_id": v["war_id"], "leader_node": v.get("leader_node"),
              "lwx": v.get("lwx"), "lwy": v.get("lwy"), "tx": v.get("tx"), "ty": v.get("ty"),
              "mname": v.get("mname"), "mlevel": v.get("mlevel"), "group": v.get("group"),
              "mst": v.get("mst"), "age": round(now - v.get("ts", now), 0)}
             for v in RALLIES_AGG.values() if _rally_joinable(v, now)],
            key=lambda r: (r.get("mst") or 0))
        return {
            "now": now, "hub_uptime": round(now - _STARTED, 0),
            "mesh_objs": mesh_objs, "rallies": rallies, "coverage": coverage,
            "epoch": _EPOCH["n"], "epoch_members": members,
            "epoch_changed_at": _EPOCH["changed_at"], "epoch_last_change": _EPOCH["last_change"],
            "thresholds": {"healthy_max": HEALTHY_MAX, "down_after": DOWN_AFTER, "hb_expect": HB_EXPECT},
            "counts": {
                "total": len(nodes),
                "healthy":  sum(1 for x in nodes if x["status"] == "healthy"),
                "degraded": sum(1 for x in nodes if x["status"] == "degraded"),
                "down":     sum(1 for x in nodes if x["status"] == "down"),
            },
            "nodes": nodes,
        }


# ============================ BUBBLE GUARDIAN (server-side, 24/7) ============================
# Objetivo: que NUNCA se caiga una burbuja. Corre en _liveness_loop (cada 2s), con throttles por nodo.
# ETA real = peace_shield_left (del último heartbeat) - antigüedad del heartbeat.
#  - ETA <= BUBBLE_RESCUE (5min) y nodo OPERATIVO -> /api/truce_now (renovación instantánea, sin downtime).
#  - ETA <= BUBBLE_RESCUE y nodo NO operativo (frozen/disconnected/stopped) -> /api/reload (recuperar; puede
#    caer un momento, pero es la única vía para un nodo colgado y minimiza el downtime).
# Nodos sin latido reciente (>DOWN_AFTER) se ignoran: sus datos son viejos y el POST no llegaría igual.
BUBBLE_RESCUE = 300     # s de ETA a partir de los cuales el hub actúa para renovar
_BUBBLE_ACT = {}        # node_id -> {"truce": ts, "reload": ts} (throttle por acción)
def _node_post(port, path, body=None, timeout=20):
    try:
        headers = {"Content-Type": "application/json"}
        if _NODE_TOKEN: headers["Cookie"] = "botsession=%s" % _NODE_TOKEN
        data = json.dumps(body).encode() if body is not None else b"{}"
        req = urllib.request.Request("http://127.0.0.1:%d%s" % (int(port), path), data=data, headers=headers, method="POST")
        with urllib.request.urlopen(req, timeout=timeout) as r:
            return json.loads(r.read() or b"{}")
    except Exception as e:
        return {"ok": False, "error": str(e)[:120]}
# Aviso de burbuja por WHISPER (server-side, 24/7): cuando un nodo baja de 2h, el nodo emisor (Settings:
# bubble_whisper_from, default RexValerius) susurra a los uids elegidos un coord-message a la ciudad del nodo
# en riesgo con la etiqueta '⚠️ BUBBLE ...' (el whisper existente es por coords; el texto va en el 'name').
BUBBLE_ALERT_ETA = 5400   # 90 min (antes 7200/2h): 2h era demasiado margen
_BUBBLE_ALERTED = {}        # node_id -> last-alert ts (throttle 30min)
# HARD RESET (solo nodos FÍSICOS): reinicio completo del dispositivo vía hard_reset.sh (Mac-side, background).
HARD_RESET_INFLIGHT = {}    # node_id -> ts de inicio; mientras esté vigente, el guardián/avisos IGNORAN el nodo (está reseteando)
HARD_RESET_LOCK = threading.Lock()  # atomiza el check-then-set del guard (server es threading -> doble clic no puede colar 2 runs)
HARD_RESET_TTL = 600        # s: ventana en la que el nodo se considera "reseteando" (el script tarda ~5-8min)
RESET_BUBBLE_MIN = 1800     # s: rechazar hard reset si la burbuja cae en < esto (el reset la dejaría caer) salvo force
MESH_SCRIPTS = os.path.expanduser("~/Desktop/Shortcuts/Mesh")
HARD_RESET_SH = os.path.join(MESH_SCRIPTS, "hard_reset.sh")
HARD_RESET_LOGDIR = os.path.expanduser("~/Desktop/Evony/Evony-Mesh/logs")
def _hard_reset_active(nid, now=None):
    now = now or time.time()
    ts = HARD_RESET_INFLIGHT.get(nid)
    return ts is not None and (now - ts) < HARD_RESET_TTL
def _rex_port_locked():
    for n in NODES.values():
        if "rexval" in (n.get("name", "") or "").lower(): return int(n.get("port") or 0)
    return 0
def _bubble_whisper_alert():
    uids = [int(x) for x in (HUB_SETTINGS.get("bubble_alert_uids") or [])]
    if not uids: return
    now = time.time()
    with _LOCK:
        from_port = int(HUB_SETTINGS.get("bubble_whisper_from") or 0) or _rex_port_locked()
        risky = []
        for nid, n in NODES.items():
            if _hard_reset_active(nid, now): continue      # HARD RESET en curso -> no avisar (reboot intencionado)
            acc = n.get("account") or {}; shl = acc.get("peace_shield_left")
            if shl is None: continue
            hb_age = now - (n.get("last_hb") or now)
            if hb_age > DOWN_AFTER: continue
            eta = int(shl) - hb_age
            if eta > BUBBLE_ALERT_ETA:
                _BUBBLE_ALERTED.pop(nid, None); continue   # sana -> re-armar aviso
            risky.append((nid, n.get("name", nid), int(acc.get("city_x") or 0), int(acc.get("city_y") or 0), eta))
    if not from_port or not risky: return
    for nid, name, cx, cy, eta in risky:
        if now - (_BUBBLE_ALERTED.get(nid) or 0) < 1800: continue   # throttle 30min por nodo en riesgo
        _BUBBLE_ALERTED[nid] = now
        label = ("BUBBLE DOWN: %s" % name) if eta <= 0 else ("BUBBLE LOW (%dmin): %s" % (round(max(0, eta) / 60.0), name))
        coords = [{"x": cx or 1, "y": cy or 1, "name": label[:40], "level": 0}]
        for uid in uids:
            r = _node_post(from_port, "/api/whisper_here", body={"coords": coords, "recipient_uid": int(uid)})
            print(f"[hub] BUBBLE WHISPER: '{label}' -> uid {uid} (from :{from_port}) ok={r.get('ok')} {r.get('error') or ''}", flush=True)
def _bubble_guardian():
    now = time.time()
    with _LOCK:
        snap = []
        for nid, n in NODES.items():
            if _hard_reset_active(nid, now): continue      # HARD RESET en curso -> no tocar (reboot intencionado); no intentar reload/truce
            acc = n.get("account") or {}; ag = n.get("agent") or {}
            shl = acc.get("peace_shield_left"); port = n.get("port")
            if shl is None or not port: continue
            hb_age = now - (n.get("last_hb") or now)
            if hb_age > DOWN_AFTER: continue            # sin latido reciente -> datos viejos, el POST no llegaría
            eta = int(shl) - hb_age
            op = bool(ag.get("ready")) and ag.get("connected") == 1 and not ag.get("stopped")
            snap.append((nid, int(port), eta, op, n.get("name", nid)))
    for nid, port, eta, op, name in snap:
        if eta > BUBBLE_RESCUE: continue
        act = _BUBBLE_ACT.setdefault(nid, {"truce": 0.0, "reload": 0.0})
        if op:
            if now - act["truce"] < 120: continue        # espera al re-scan que confirma la renovación
            act["truce"] = now
            r = _node_post(port, "/api/truce_now")
            print(f"[hub] BUBBLE GUARDIAN: {name} ETA {int(eta)}s (op) -> truce_now: ok={r.get('ok')} {r.get('used') or r.get('skipped') or r.get('error') or ''}", flush=True)
        else:
            if now - act["reload"] < 240: continue        # un reload en curso tarda ~1-3min en re-enganchar
            act["reload"] = now
            print(f"[hub] BUBBLE GUARDIAN: {name} ETA {int(eta)}s NO operativo -> reload (recuperar para renovar)", flush=True)
            _node_post(port, "/api/reload")

def _liveness_loop():
    """Recalcula epoch periódicamente para captar CAÍDAS (sin latido no hay POST que lo dispare)
    y poda el store agregado cada ~60s. También corre el BUBBLE GUARDIAN (nunca dejar caer una burbuja)."""
    ticks = 0
    while True:
        try:
            with _LOCK:
                _recompute_epoch_locked(reason="tick")
            _rallies_prune()   # Fase 4: quitar rallies disparados/cancelados/caídos (barato)
            try: _bubble_guardian()   # NUNCA dejar caer una burbuja: renueva (truce_now) o recupera (reload) los nodos en riesgo
            except Exception as e: print(f"[hub] bubble guardian err: {e}", flush=True)
            try: _bubble_whisper_alert()   # aviso por whisper a los usuarios elegidos cuando una burbuja baja de 2h
            except Exception as e: print(f"[hub] bubble whisper err: {e}", flush=True)
            ticks += 1
            if ticks % 30 == 0:   # ~cada 60s (bucle de 2s)
                n = _objs_prune()
                if n:
                    print(f"[hub] poda store agregado: -{n} entradas viejas", flush=True)
        except Exception as e:
            print(f"[hub] liveness err: {e}", flush=True)
        time.sleep(2)


# ── Dashboard HTML ───────────────────────────────────────────────────────────
DASHBOARD = r"""<!doctype html><html lang=en><head><meta charset=utf-8>
<meta name=viewport content="width=device-width,initial-scale=1">
<title>Alliance Mesh HUB</title>
<style>
:root{--bg:#f6f7f9;--card:#fff;--fg:#0f172a;--mut:#64748b;--bd:#e2e8f0;
      --ok:#16a34a;--okbg:#dcfce7;--warn:#d97706;--warnbg:#fef3c7;--down:#dc2626;--downbg:#fee2e2;--acc:#2563eb}
@media(prefers-color-scheme:dark){:root{--bg:#0b1120;--card:#111827;--fg:#e5e7eb;--mut:#94a3b8;--bd:#1f2937;
      --ok:#22c55e;--okbg:#052e16;--warn:#f59e0b;--warnbg:#3a2a06;--down:#ef4444;--downbg:#3b0d0d;--acc:#60a5fa}}
*{box-sizing:border-box}
body{margin:0;background:var(--bg);color:var(--fg);font:14px/1.5 -apple-system,Segoe UI,Roboto,sans-serif}
header{padding:16px 20px;border-bottom:1px solid var(--bd);display:flex;gap:16px;align-items:baseline;flex-wrap:wrap}
h1{font-size:18px;margin:0;font-weight:650}
.sub{color:var(--mut);font-size:12px}
.pill{display:inline-block;padding:2px 9px;border-radius:999px;font-size:12px;font-weight:600}
.pill.ok{background:var(--okbg);color:var(--ok)} .pill.warn{background:var(--warnbg);color:var(--warn)}
.pill.down{background:var(--downbg);color:var(--down)} .pill.mut{background:var(--bd);color:var(--mut)}
.pillgrp{display:inline-flex;flex-direction:column;gap:5px;align-items:flex-end}
.autorow{margin:8px 0 2px;display:flex;align-items:center;justify-content:space-between;gap:8px}
.autosw{display:inline-flex;align-items:center;gap:8px;cursor:pointer;user-select:none}
.autosw .track{position:relative;width:38px;height:20px;border-radius:999px;background:var(--bd);transition:background .15s;flex:none}
.autosw.on .track{background:var(--ok)}
.autosw .knob{position:absolute;top:2px;left:2px;width:16px;height:16px;border-radius:50%;background:#fff;box-shadow:0 1px 2px rgba(0,0,0,.4);transition:left .15s}
.autosw.on .knob{left:20px}
.autosw .lbl{font-size:11px;font-weight:700;letter-spacing:.04em;color:var(--mut)}
.autosw.on .lbl{color:var(--ok)}
.autosw.busy{opacity:.55;pointer-events:none}
main{padding:16px 20px;display:grid;grid-template-columns:repeat(auto-fill,minmax(300px,1fr));gap:14px}
.card{background:var(--card);border:1px solid var(--bd);border-radius:12px;padding:14px 16px;display:flex;flex-direction:column}
.card.healthy{border:1px solid rgba(63,185,80,.55);background:rgba(63,185,80,.06)}
.card.idle{border:1px solid rgba(56,139,253,.55);background:rgba(56,139,253,.07)}
.card.degraded{border:1px solid rgba(245,158,11,.55);background:rgba(245,158,11,.06)}
.card.down{border:1px solid rgba(239,68,68,.55);background:rgba(239,68,68,.06)}
.card.stopped{border:1px solid rgba(139,148,158,.6);background:rgba(139,148,158,.11)}
.card.unknown{border:1px solid rgba(139,148,158,.45);background:rgba(139,148,158,.05)}
.k.bubble-k{text-transform:uppercase;font-weight:800;letter-spacing:1px;font-size:12px;color:var(--fg)}
.bubtn{margin-left:8px;padding:2px 10px;font-size:9.5px;font-weight:800;border:1.5px solid var(--acc);background:rgba(56,139,253,.14);color:var(--acc);border-radius:10px;cursor:pointer;text-transform:uppercase;letter-spacing:.5px;vertical-align:middle}
.bubtn:hover{background:var(--acc);color:#fff;border-color:var(--acc)}
.bubtn:disabled{opacity:.5;cursor:default}
.moreinfo{margin-top:4px;border-top:1px solid var(--bd);padding-top:4px}
.moreinfo>summary{cursor:pointer;color:var(--acc);font-size:11px;font-weight:700;list-style:none;padding:2px 0;user-select:none}
.moreinfo>summary::-webkit-details-marker{display:none}
.moreinfo>summary::after{content:' +';font-weight:800}
.moreinfo[open]>summary::after{content:' −'}
.moreinfo-b{padding-top:3px}
#toasts{position:fixed;top:12px;right:12px;z-index:9999;display:flex;flex-direction:column;gap:8px;max-width:340px}
.toast{background:var(--warnbg);color:var(--warn);border:1px solid var(--warn);border-radius:9px;padding:10px 14px;font-size:13px;font-weight:700;box-shadow:0 6px 20px rgba(0,0,0,.28);transition:opacity .4s}
.toast.crit{background:var(--downbg);color:var(--down);border-color:var(--down)}
@keyframes bubblePulse{0%,100%{box-shadow:0 0 0 0 rgba(239,68,68,0)}50%{box-shadow:0 0 18px 5px rgba(239,68,68,.7)}}
.card.bubble-crit{animation:bubblePulse 1.05s ease-in-out infinite;border:1px solid rgba(239,68,68,.9)!important;background:rgba(239,68,68,.10)!important}
.row{display:flex;justify-content:space-between;gap:8px;margin:3px 0}
.k{color:var(--mut)} .v{font-variant-numeric:tabular-nums;text-align:right}
.name{font-weight:650;font-size:15px} .dev{color:var(--mut);font-size:12px}
.bar{height:6px;background:var(--bd);border-radius:4px;overflow:hidden;margin-top:6px}
.bar>i{display:block;height:100%;background:var(--acc)}
.stam{margin:9px 0 8px}
.stamtop{display:flex;justify-content:space-between;align-items:baseline;margin-bottom:4px}
.stamval{font-size:15px;font-weight:700;color:#f0b229;font-variant-numeric:tabular-nums;line-height:1}
.stamsub{font-size:9px;font-weight:600;text-transform:uppercase;letter-spacing:.07em;color:var(--mut);white-space:nowrap}
.stambar{height:5px;border-radius:3px;background:var(--bd);overflow:hidden}
.stamfill{display:block;height:100%;border-radius:3px;background:linear-gradient(90deg,#f0b229,#ffd76a);transition:width .5s ease}
.stam.s-high .stamval{color:var(--ok)} .stam.s-high .stamfill{background:linear-gradient(90deg,#2ea043,#3fb950)}
.stam.s-low .stamval{color:#f85149} .stam.s-low .stamfill{background:linear-gradient(90deg,#da3633,#f85149)}
.empty{color:var(--mut);padding:40px;text-align:center;grid-column:1/-1}
footer{padding:10px 20px;color:var(--mut);font-size:12px;border-top:1px solid var(--bd)}
.dot{display:inline-block;width:8px;height:8px;border-radius:50%;margin-right:6px;vertical-align:middle}
.dot.ok{background:var(--ok)} .dot.warn{background:var(--warn)} .dot.down{background:var(--down)} .dot.mut{background:var(--mut)}
#rallybar{padding:0 20px}
.rally{display:inline-flex;gap:8px;align-items:center;background:var(--card);border:1px solid var(--bd);
  border-left:4px solid var(--acc);border-radius:10px;padding:8px 12px;margin:0 10px 10px 0}
.rally .rn{font-weight:650} .rally .rm{color:var(--mut);font-size:12px}
.rtitle{font-size:12px;color:var(--mut);margin:6px 0 8px}
#coverage{padding:2px 20px 6px}
.covtitle{font-size:12px;color:var(--mut);margin:8px 0 8px}
.covstrip{display:flex;flex-wrap:wrap;gap:6px}
/* Fase 5 — balance por throughput */
.wbar{position:relative;height:7px;border-radius:5px;background:var(--bd);margin:3px 0 8px;overflow:visible}
.wbar i{position:absolute;left:0;top:0;height:100%;border-radius:5px;background:linear-gradient(90deg,#3b82f6,#22c55e)}
.wbar b{position:absolute;top:-2px;width:2px;height:11px;background:var(--mut);border-radius:1px}
.bb{font-size:11px;font-weight:700;padding:1px 6px;border-radius:6px;white-space:nowrap}
.bb.ok{color:var(--ok);background:var(--okbg)}
.bb.warn{color:var(--warn,#b45309);background:var(--warnbg,#fef3c7)}
.bb.mut{color:var(--mut);background:var(--bd)}
.nodeacts{display:flex;gap:8px;margin-top:auto;padding-top:10px;border-top:1px solid var(--bd)}
.nact{flex:1;border:1px solid var(--bd);background:var(--bg);color:var(--fg);font-size:12px;font-weight:600;padding:6px 8px;border-radius:7px;cursor:pointer}
.nact.stop{color:var(--warn);border-color:color-mix(in srgb,var(--warn) 45%,var(--bd))}
.nact.rel{color:var(--acc);border-color:color-mix(in srgb,var(--acc) 45%,var(--bd))}
.nact.hr{color:var(--down);border-color:color-mix(in srgb,var(--down) 50%,var(--bd));flex:1}
.nodeacts.hrrow{margin-top:8px;padding-top:0;border-top:none}
.nact:hover{filter:brightness(1.12)}
.nact:disabled{opacity:.6;cursor:default}
.mut2{color:var(--mut);font-weight:400}
.mbadge{font-size:11px;font-weight:700;padding:1px 7px;border-radius:6px}
.mbadge.on{color:#fff;background:#16a34a}
.mbadge.sh{color:var(--mut);background:var(--bd)}
.covcell{flex:1 1 100px;min-width:100px;padding:7px 10px;border-radius:8px;border:1px solid var(--bd);
  font-size:12px;text-align:center;background:var(--okbg)}
.covcell.gap{background:var(--downbg);border-color:var(--down)}
.covcell .cr{font-weight:700;color:var(--ok);font-variant-numeric:tabular-nums}
.covcell.gap .cr{color:var(--down)}
.covcell .co{display:block;font-size:11px;color:var(--fg);opacity:.9;margin-top:2px}
.mcov{display:flex;gap:3px;margin:2px 0 4px}
.mcell{flex:1;height:11px;border-radius:3px;background:var(--bd)}
.mcell.on{background:var(--acc)} .mcell.borrowed{background:var(--warn)}
/* Fase 6 — panel Shortcuts (proxy a un nodo) */
.sc{margin:0 20px 6px;border:1px solid var(--bd);border-radius:12px;background:var(--card);overflow:hidden}
.schead{display:flex;align-items:center;gap:12px;flex-wrap:wrap;padding:10px 14px}
.scbtn{border:0;background:#1b5296;color:#fff;font-weight:700;font-size:13px;padding:6px 12px;border-radius:8px;cursor:pointer}
.scview{font-size:12px;color:var(--mut)}
.scview select,.scwrap select{background:var(--bg);color:var(--fg);border:1px solid var(--bd);border-radius:6px;padding:3px 6px;font-size:12px}
.sctabs{display:flex;gap:6px}
.sctab{border:1px solid var(--bd);background:var(--bg);color:var(--mut);font-size:12px;padding:4px 10px;border-radius:7px;cursor:pointer}
.sctab.on{background:var(--acc);color:#fff;border-color:var(--acc)}
.scmeta{font-size:12px;color:var(--mut);margin-left:auto}
.scbody{padding:4px 14px 14px;border-top:1px solid var(--bd)}
.scfam{margin:10px 0 4px}
.scfam .fn{font-weight:700;font-size:13px}.scfam .fg{font-size:10px;color:var(--mut);text-transform:uppercase;letter-spacing:.04em;margin-left:6px}
.scpills{display:flex;flex-wrap:wrap;gap:7px;margin-top:5px}
.scpill{border:0;border-radius:8px;padding:5px 11px;font-size:12px;font-weight:700;cursor:pointer;color:#fff;background:var(--ok)}
.scpill.z{background:var(--bd);color:var(--mut);cursor:default}
.scpill.red{background:var(--down)}
.scpill.sel{background:var(--ok);box-shadow:0 0 0 2px var(--acc);position:relative}
.scpill.sel::after{content:'✓';position:absolute;top:-6px;right:-6px;background:var(--acc);color:#fff;border-radius:50%;width:15px;height:15px;font-size:10px;line-height:15px;text-align:center}
.scpill:not(.z):hover{filter:brightness(1.12)}
.scpill.active{box-shadow:0 0 0 2px var(--acc);opacity:.4}   /* pill de resultados cuyo listado está abierto: ring azul + contenido atenuado */
.scctl{display:flex;align-items:center;gap:14px;flex-wrap:wrap;margin:8px 0 12px;font-size:12px;color:var(--mut)}
.scctl label{display:flex;align-items:center;gap:6px}
.scctl input,.scctl select{background:var(--bg);color:var(--fg);border:1px solid var(--bd);border-radius:6px;padding:3px 6px;font-size:12px}
.scctl input{width:56px}
.scmenu{border:0;background:var(--acc);color:#fff;font-weight:700;font-size:13px;padding:6px 12px;border-radius:8px;cursor:pointer}
.scmenu.on{background:#1d4ed8}
.scapply{border:1px solid var(--bd);background:var(--bg);color:var(--fg);font-weight:600;font-size:12px;padding:5px 12px;border-radius:7px;cursor:pointer}
.sclist{margin:2px 0 12px;border:1px solid var(--bd);border-radius:10px;padding:0px 12px;background:var(--bg)}
/* Scanner tabs (informativo) — junto al botón Shortcuts */
.sntabs{display:flex;gap:6px;flex-wrap:wrap}
.snbar{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin:0 0 8px}
.snbar #snMeta{margin-left:0}
.sntab{border:1px solid var(--bd);background:var(--bg);color:var(--mut);font-weight:600;font-size:12px;padding:5px 11px;border-radius:8px;cursor:pointer}
.sntab:hover{color:var(--fg)}
.sntab.on{background:#1b5296;border-color:var(--acc);color:#fff}
.snfilters{margin:0 0 10px;display:flex;gap:10px 14px;align-items:flex-end;flex-wrap:wrap}
.snfld{display:flex;flex-direction:column;gap:4px}
.snfld>label{font-size:11px;color:var(--mut)}
.snfld input,.snfld select{background:var(--bg);color:var(--fg);border:1px solid var(--bd);border-radius:6px;padding:5px 7px;font-size:12px;min-width:70px}
.snfld input[type=number]{width:82px}
.snfld input#rf_ally,.snfld input#sf_ally,.snfld input#vf_ally{width:110px}
.snapply{border:0;background:var(--acc);color:#fff;font-weight:700;font-size:12px;padding:6px 12px;border-radius:7px;cursor:pointer}
.snclear{border:1px solid var(--bd);background:var(--bg);color:var(--mut);font-size:12px;padding:6px 12px;border-radius:7px;cursor:pointer}
.snhint{color:var(--mut);font-size:12px;padding:6px 2px}
#snPanel{max-height:460px;overflow:auto}
.sntbl{width:100%;border-collapse:collapse;font-size:12px;font-variant-numeric:tabular-nums}
.sntbl th{position:sticky;top:0;background:var(--card);text-align:left;color:var(--mut);font-weight:600;padding:6px 10px;border-bottom:1px solid var(--bd);z-index:1;white-space:nowrap}
.sntbl th.snsort{cursor:pointer;user-select:none}
.sntbl th.snsort:hover{color:var(--fg)}
.sntbl td{padding:5px 10px;border-bottom:1px solid var(--bd);white-space:nowrap}
.sntbl tbody tr:hover td{background:rgba(255,255,255,.03)}
.snlvl{color:#f2c14e;font-weight:700}
.sntag{background:var(--bd);color:var(--fg);padding:1px 8px;border-radius:9px;font-size:11px}
.sncopy{color:var(--acc);cursor:pointer;font-family:ui-monospace,SFMono-Regular,Menlo,monospace}
.sncopy:hover{text-decoration:underline}
.sncoord{color:var(--acc);font-family:ui-monospace,SFMono-Regular,Menlo,monospace}
.snfree{color:#39d98a;font-weight:600}
.snbusy{color:#e0a13a}
.snq{padding:1px 8px;border-radius:9px;font-size:11px;font-weight:600;text-transform:capitalize;color:#0b1220}
.snq-white{background:#e6ebf5}.snq-green{background:#39d98a}.snq-blue{background:#4f9dff;color:#fff}
.snq-purple{background:#b06bff;color:#fff}.snq-gold{background:#f2c14e}.snq-red{background:#ff6b6b;color:#fff}
/* ── Responsive móvil ─────────────────────────────────────────── */
@media(max-width:640px){
  html,body{overflow-x:hidden}
  header{padding:12px 12px;gap:8px}
  #scMeta{display:none}   /* "N on map" en la fila de tabs: estorba en móvil */
  main{padding:12px 10px;grid-template-columns:1fr;gap:10px}
  .card{padding:12px 12px}
  .sc{margin:0 10px 8px}
  .schead{padding:10px 10px;gap:8px}
  .scbody{padding:8px 10px 12px}
  .scctl{gap:8px 10px}
  .scctl input{width:52px}
  .sntab{padding:6px 10px}
  .snbar{gap:8px}
  /* filtros: 2 por fila, ocupando el ancho, COMPACTOS */
  .snfilters{gap:5px 8px;margin:0 0 8px}
  .snfld{flex:1 1 44%;min-width:0;gap:1px}
  .snfld>label{font-size:9.5px;line-height:1.2}
  .snfld input,.snfld select{width:100%;min-width:0;box-sizing:border-box;padding:4px 6px;font-size:12px}
  .snfld input[type=number]{width:100%}
  .snfld input#rf_ally,.snfld input#sf_ally,.snfld input#vf_ally{width:100%}
  /* la barra de acciones (Apply/Clear) a fila completa */
  .snfld:last-child{flex:1 1 100%;margin-top:2px}
  .snapply,.snclear{padding:6px 14px;font-size:12px}
  /* tabla: scroll horizontal táctil dentro del panel; celdas compactas */
  #snPanel{max-height:62vh;overflow:auto;-webkit-overflow-scrolling:touch;overscroll-behavior:contain;border-radius:8px}
  .sntbl{font-size:11.5px}
  .sntbl th{padding:7px 9px}
  .sntbl td{padding:7px 9px}
  #snMeta{font-size:11px}
  /* Shortcuts: controles cómodos */
  .scctl label{font-size:12px}
}
.sclrow{display:flex;justify-content:space-between;gap:10px;font-size:12px;padding:3px 0;border-bottom:1px solid var(--bd);font-variant-numeric:tabular-nums}
.sclrow .co{color:var(--acc)}
.sctable{width:100%;border-collapse:collapse;font-size:12px;margin-top:8px;font-variant-numeric:tabular-nums}
.sctable th{text-align:left;color:var(--mut);font-weight:600;font-size:11px;text-transform:uppercase;letter-spacing:.03em;padding:4px 10px;border-bottom:1px solid var(--bd)}
.sctable td{padding:5px 10px;border-bottom:1px solid var(--bd)}
.sctable td.co{color:var(--acc)}
.sctable td.pw{text-align:right}
.sctable th.pw{text-align:right}
.scwrap{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin:8px 0}
.scwrap button{border:0;background:var(--ok);color:#fff;font-weight:700;font-size:12px;padding:5px 12px;border-radius:7px;cursor:pointer}
.scwrap .mut{color:var(--mut);font-size:12px}
.scbtns{display:flex;flex-wrap:wrap;gap:10px;margin:14px 0 4px}
.scgold{border:1px solid #7a5c11;background:rgba(210,153,34,.12);color:#d29922;font-weight:700;font-size:13px;padding:8px 14px;border-radius:9px;cursor:pointer}
.scgold:hover{background:rgba(210,153,34,.2)}
.scgold .c{opacity:.75;font-weight:400}
.scgold.on{background:rgba(210,153,34,.28)}
.scgold.active{background:rgba(210,153,34,.3);box-shadow:0 0 0 2px var(--acc);opacity:.4}   /* botón cuyo listado está abierto: ring azul + contenido atenuado */
.scmore{border:1px solid var(--bd);background:var(--bg);color:var(--mut);font-size:12px;padding:5px 10px;border-radius:7px;cursor:pointer}
/* Info icon + popover (agrupa summary + epoch + coverage) */
.infowrap{position:relative;display:inline-flex;align-items:center;margin-left:10px}
.infobtn{border:1.5px solid var(--acc);background:var(--card);color:var(--acc);width:27px;height:27px;border-radius:50%;font:italic 700 16px/1 Georgia,'Times New Roman',serif;cursor:pointer;display:inline-flex;align-items:center;justify-content:center;padding:0;transition:background .12s,color .12s}
.infobtn:hover,.infobtn.open{background:var(--acc);color:#fff;border-color:var(--acc)}
.gearbtn{border:1.5px solid var(--acc);background:var(--card);color:var(--acc);width:27px;height:27px;border-radius:50%;font-size:21px;line-height:1;cursor:pointer;display:inline-flex;align-items:center;justify-content:center;padding:0;margin-left:10px;transition:background .12s,color .12s}
.gearbtn:hover{background:var(--acc);color:#fff}
.setoverlay{display:none;position:fixed;inset:0;background:rgba(0,0,0,.45);z-index:9998}
.setoverlay.open{display:block}
.setpanel{position:absolute;top:0;right:0;height:100%;width:380px;max-width:92vw;background:var(--card);border-left:1px solid var(--bd);box-shadow:-8px 0 28px rgba(0,0,0,.35);padding:16px 18px;overflow-y:auto;box-sizing:border-box}
.sethead{display:flex;align-items:center;justify-content:space-between;font-size:17px;margin-bottom:14px}
.setx{border:0;background:transparent;color:var(--mut);font-size:24px;cursor:pointer;line-height:1;padding:0 4px}
.setsec{border:1px solid var(--bd);border-radius:10px;padding:12px 14px;margin-bottom:14px}
.settitle{font-weight:800;font-size:13px;margin-bottom:10px}
.setrow{display:flex;flex-direction:column;gap:5px;margin-bottom:12px}
.setrow>label{font-size:11px;font-weight:700;color:var(--mut);text-transform:uppercase;letter-spacing:.4px}
.setrow select{background:var(--bg);color:var(--fg);border:1px solid var(--bd);border-radius:7px;padding:6px 8px;font-size:13px}
.setmut{font-size:11px;color:var(--mut);line-height:1.4}
.chipbox{display:flex;flex-wrap:wrap;gap:6px;align-items:center;border:1px solid var(--bd);border-radius:8px;padding:7px;min-height:22px}
.chip{display:inline-flex;align-items:center;gap:5px;background:var(--acc);color:#fff;border-radius:20px;padding:3px 6px 3px 11px;font-size:12px;font-weight:600}
.chip b{cursor:pointer;font-weight:800;opacity:.85} .chip b:hover{opacity:1}
.chipadd{border:1px dashed var(--bd);background:var(--bg);color:var(--acc);border-radius:20px;padding:3px 8px;font-size:12px;cursor:pointer}
.setsave{background:var(--acc);color:#fff;border:0;border-radius:8px;padding:7px 16px;font-weight:700;cursor:pointer}
.infopop{position:absolute;top:calc(100% + 8px);right:0;z-index:60;min-width:min(92vw,380px);max-width:min(92vw,600px);max-height:80vh;overflow:auto;background:var(--card);border:1px solid var(--bd);border-radius:12px;padding:14px 16px;box-shadow:0 12px 34px rgba(0,0,0,.38);display:none}
.infopop.open{display:block}
.infohead{display:flex;align-items:center;justify-content:space-between;font-size:15px;font-weight:700;margin:-2px 0 10px}
.infox{border:0;background:transparent;color:var(--mut);font-size:24px;cursor:pointer;line-height:1;padding:0 2px}
.infox:hover{color:var(--fg)}
.infosec{margin:0 0 12px}.infosec:last-child{margin-bottom:0}
.infopop #coverage{padding:0}.infopop .covtitle{margin-top:0}
/* móvil: popover de info fijado a los bordes (colocado DESPUÉS de la base para ganar en orden) */
@media(max-width:640px){ .infopop{position:fixed;top:58px;left:10px;right:10px;min-width:0;max-width:none;width:auto;max-height:80vh} }
</style></head><body>
<header>
  <h1>Alliance Mesh HUB</h1>
  <span style="flex:1"></span>
  <span id=conn class="sub"></span>
  <span class=infowrap>
    <button id=infoBtn class=infobtn title="More info" aria-label="More info" onclick="toggleInfo(event)">i</button>
    <div id=infoPop class=infopop>
      <div class=infohead><b>Mesh info</b><button class=infox onclick="closeInfo(event)" aria-label="Close">×</button></div>
      <div class=infosec><span id=summary class=sub></span></div>
      <div class=infosec><span id=epoch class="pill mut"></span></div>
      <div class=infosec id=coverage></div>
    </div>
  </span>
  <button id=gearBtn class=gearbtn title="Settings" aria-label="Settings" onclick="openSettings()">⚙</button>
</header>
<div id=setOverlay class=setoverlay onclick="if(event.target===this)closeSettings()">
  <div class=setpanel>
    <div class=sethead><b>⚙ Settings</b><button class=setx onclick="closeSettings()" aria-label="Close">×</button></div>
    <div class=setsec>
      <div class=settitle>✉️ Whisper From (global)</div>
      <div class=setrow><label for=setWFrom>Whisper From</label><select id=setWFrom></select></div>
      <div class=setmut>The account that sends EVERY whisper from the hub — both the target-coord whispers (Shortcuts tab · "Send whisper") and the bubble-low alerts below. Global setting.</div>
    </div>
    <div class=setsec>
      <div class=settitle>🛡️ Bubble alert</div>
      <div class=setrow><label>Notify users when bubble &lt; 2h</label><div id=setUsers class=chipbox></div></div>
      <div class=setmut>When any node's bubble drops below 90min, the "Whisper From" account (above) whispers these alliance members a clickable coord to the node's city with a "BUBBLE LOW …" label. Runs 24/7 on the hub.</div>
    </div>
    <div class=setfoot><button class=setsave onclick="saveSettings()">Save</button> <span id=setStatus class=sub></span></div>
  </div>
</div>
<section id=shortcuts class=sc>
  <div class=schead>
    <button id=scToggle class=scbtn>⚡ Shortcuts <span id=scArrow>▾</span></button>
    <div class=sntabs>
      <button class="sntab" data-tab=resources onclick="snTab('resources')">Resources</button>
      <button class="sntab" data-tab=subcities onclick="snTab('subcities')">Subcities</button>
      <button class="sntab" data-tab=svs onclick="snTab('svs')">SVS</button>
    </div>
    <span id=scMeta class=scmeta></span>
  </div>
  <div id=scBody class=scbody style="display:none">
    <div class=scctl>
      <button id=scMenu class=scmenu>≡ Menu <span id=scMenuArr>▾</span></button>
      <label>Results <input id=scN type=number value=40 min=1 max=200></label>
      <label>Sort by <select id=scSort><option value=dist>Distance</option><option value=power>Power</option></select></label>
      <label title="The global 'Whisper From' account (Settings ⚙) will whisper the coords to this alliance member">Send whisper to <select id=scWTo></select></label>
      <button id=scApply class=scapply>Apply</button>
      <span id=scFamCount class=scmeta></span>
    </div>
    <div id=scMenuList style="display:none"></div>
    <div id=scFamilies></div>
    <div id=scBtns class=scbtns></div>
    <div id=scPanel class=sclist style="display:none"></div>
  </div>
</section>
<div id=snWrap class=sc style="display:none">
  <div class=scbody>
    <div id=snFilters class=snfilters></div>
    <div class=snbar><span id=snMeta class=scmeta></span></div>
    <div id=snPanel class=sclist></div>
  </div>
</div>
<section id=rallybar></section>
<main id=grid><div class=empty>Loading…</div></main>
<footer id=foot></footer>
<script>
// Info popover (summary + epoch + coverage): hover lo muestra (CSS); click lo FIJA; clic fuera lo cierra.
function toggleInfo(e){ e.stopPropagation();
  var pop=document.getElementById('infoPop'), btn=document.getElementById('infoBtn');
  var open=pop.classList.toggle('open'); btn.classList.toggle('open', open); }
function closeInfo(e){ if(e){ e.stopPropagation(); }
  document.getElementById('infoPop').classList.remove('open');
  document.getElementById('infoBtn').classList.remove('open'); }
document.addEventListener('click', function(e){
  var w=document.querySelector('.infowrap');
  if(w && !w.contains(e.target)){ document.getElementById('infoPop').classList.remove('open'); document.getElementById('infoBtn').classList.remove('open'); }
});
// Switch AUTO por card: POST /px/<port>/api/auto {on}. Optimista (AUTO_PENDING) para que el re-render de
// tick() no revierta el toggle mientras el nodo aún no ha latido el nuevo estado.
var AUTO_PENDING={};   // port -> {on, ts}
var MOREINFO_OPEN={};  // port -> bool: recuerda si el desplegable "More Info" está abierto (sobrevive al re-render de tick())
function onMoreToggle(el){ try{ MOREINFO_OPEN[el.getAttribute('data-port')] = el.open; }catch(e){} }
async function toggleAuto(el){
  const port=el.getAttribute('data-port'); if(!port) return;
  const on=!el.classList.contains('on');
  AUTO_PENDING[port]={on:on, ts:Date.now()};
  el.classList.toggle('on', on); el.classList.add('busy');
  try{
    const r=await fetch('/px/'+port+'/api/auto',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({on:on})});
    const j=await r.json();
    if(!(r.ok&&j.ok)){ delete AUTO_PENDING[port]; el.classList.toggle('on', !on); }   // falló -> revertir
  }catch(e){ delete AUTO_PENDING[port]; el.classList.toggle('on', !on); }
  finally{ el.classList.remove('busy'); }
}
const CLS={healthy:'ok',degraded:'warn',down:'down',unknown:'mut'};
const LBL={healthy:'HEALTHY',degraded:'DEGRADED',down:'DOWN',unknown:'?'};
function fmtAge(s){ if(s==null)return '—'; if(s<90)return s.toFixed(0)+'s'; if(s<5400)return (s/60).toFixed(1)+'m'; return (s/3600).toFixed(1)+'h'; }
function esc(s){ return String(s==null?'':s).replace(/[<>&]/g,c=>({'<':'&lt;','>':'&gt;','&':'&amp;'}[c])); }
function num(v){ return v!=null?v:'—'; }
function fmtDur(s){ s=Math.max(0,Math.floor(s||0)); const d=Math.floor(s/86400),h=Math.floor(s%86400/3600),m=Math.floor(s%3600/60); if(d>0)return d+'d '+h+'h'; if(h>0)return h+'h '+m+'m'; if(m>0)return m+'m'; return s+'s'; }
function card(n){
  const sc=n.scan||{}, ag=n.agent||{}, ac=n.account||{}, asg=n.assignment||{};
  const prog=(sc.progress_pct!=null)?sc.progress_pct:0;
  const cvr=asg.cover||[], borrowed=cvr.filter(r=>r!==asg.rank);
  const plan=asg.plan||null, modeW=(asg.mode==='weighted');
  const areaTxt = !asg.n ? '—' : (asg.n===1 ? 'full map (single node)'
    : modeW ? ((plan?plan.buckets:cvr.length)+' buckets · '+(plan?plan.regions:'?')+' regions')
    : ('slice '+(asg.rank+1)+' of '+asg.n+(borrowed.length?(' +'+borrowed.length+' borrowed'):'')+' (~'+Math.round(100*cvr.length/asg.n)+'% of periphery)'));
  // mini-strip de slices solo con pocos (modo count); en weighted (120 buckets) no cabe -> se usa la barra de share
  const mcov = (asg.n>1 && asg.n<=24) ? '<div class=mcov title="Periphery slices this node scans — blue=own, amber=covering for a down node">'
    + Array.from({length:asg.n},(_,r)=>{const on=cvr.includes(r); return '<span class="mcell'+(on?(r===asg.rank?' on':' borrowed'):'')+'"></span>';}).join('') + '</div>' : '';
  // Fase 5 — bloque de balance por throughput (plan ponderado). Se muestra SIEMPRE que haya plan:
  // en modo weighted es lo aplicado; en count es la RECOMENDACIÓN (sombra).
  let balHtml='', capacityRow='';
  if(plan){
    const bal=plan.balanced, coreOnly=(plan.role==='core-only');
    const badge = coreOnly ? '<span class="bb mut">core-only backup</span>'
                : modeW ? (bal?'<span class="bb ok">balanced ✅</span>':'<span class="bb warn">rebalancing ⚠</span>')
                        : (bal?'<span class="bb ok">would stay balanced</span>':'<span class="bb warn">would rebalance ⚠</span>');
    const sweepTxt = plan.sweep_s ? (Math.floor(plan.sweep_s/60)+'m'+String(Math.round(plan.sweep_s%60)).padStart(2,'0')+'s'+(coreOnly?' (core loop)':'')) : '—';
    const shareW = Math.max(2,Math.min(100, plan.share||0));
    const tgtL = Math.max(0,Math.min(100, plan.target||0));
    capacityRow = `<div class=row><span class=k>Capacity</span><span class=v>${plan.cap!=null?plan.cap.toFixed(2)+' reg/s':'—'}</span></div>`;
    balHtml = `
    <div class=row><span class=k>Work share</span><span class=v>${plan.share}% <span class=mut2>(target ${plan.target}%)</span></span></div>
    <div class="wbar" title="Assigned work share vs equal-share target"><i style="width:${shareW}%"></i><b style="left:${tgtL}%"></b></div>
    <div class=row><span class=k>Est. sweep</span><span class=v>${sweepTxt} · ${badge}</span></div>`;
  }
  // Pill del header = estado real del AGENTE·JUEGO cuando el nodo LATE (healthy); si el nodo
  // dejó de latir (degraded/down) muestra la liveness (los datos del agente ya son viejos).
  let pillCls, pillTxt;
  if(n.status==='down'){ pillCls='down'; pillTxt='DOWN'; }
  else if(n.status==='degraded'){ pillCls='warn'; pillTxt='DEGRADED'; }
  else if(ag.stopped){ pillCls='mut'; pillTxt='⏸ Stopped'; }
  else if(ag.ready && ag.connected===1){ pillCls='ok'; pillTxt='● Bot Running'; }
  else if(ag.connected===0){ pillCls='down'; pillTxt='● Disconnected'; }
  else if(ag.attached){ pillCls='warn'; pillTxt='● Attaching…'; }
  else { pillCls='down'; pillTxt='● No agent'; }
  // AUTO de cada bot: chip de ESTADO (ON=verde, OFF=gris, pausado por stamina=ámbar; atenuado si no opera)
  // + SWITCH de control. OPTIMISTA: al togglear refleja el cambio YA, aunque el latido del nodo tarde en
  // confirmarlo (AUTO_PENDING), sin que el re-render de tick() lo revierta a mitad.
  let autoChip='', autoSwitch='', autoOn=!!ag.auto, autoPaused=!!ag.auto_paused, autoKnown=(ag.auto!==undefined);
  if(autoKnown){
    const op = ag.ready && ag.connected===1 && !ag.stopped;
    const pend=AUTO_PENDING[n.port];
    if(pend){ if(autoOn===pend.on || (Date.now()-pend.ts>15000)){ delete AUTO_PENDING[n.port]; } else { autoOn=pend.on; autoPaused=false; } }
    let acls,at;
    if(autoPaused){ acls=op?'warn':'mut'; at='AUTO ⏸ stamina'; }
    else if(autoOn){ acls=op?'ok':'mut'; at='AUTO ON'; }
    else { acls='mut'; at='AUTO OFF'; }
    autoChip=`<span class="pill ${acls}" title="Auto-farm/rally engine">${at}</span>`;
    autoSwitch=`<div class="autosw${autoOn?' on':''}" data-port="${n.port}" onclick="toggleAuto(this)" role=switch aria-checked="${autoOn}" title="Toggle auto-farm/rally engine"><span class=track><span class=knob></span></span><span class=lbl>AUTO</span></div>`;
  }
  // COLOR del card = estado real del BOT (no solo el latido del nodo): verde=healthy+AUTO ON · azul(idle)=healthy pero
  // AUTO OFF · ámbar=enganchando/AUTO pausado por stamina · rojo=desconectado/down · gris=stopped/unknown.
  let cardCls;
  if(n.status!=='healthy'){ cardCls=n.status; }
  else if(ag.stopped){ cardCls='stopped'; }
  else if(ag.connected===0){ cardCls='down'; }
  else if(!(ag.ready && ag.connected===1)){ cardCls='degraded'; }
  else if(autoPaused){ cardCls='degraded'; }
  else if(autoKnown && !autoOn){ cardCls='idle'; }
  else { cardCls='healthy'; }
  // Stamina bar (like the bot): progress toward the refill TARGET (~2000), not stamina_max
  // (which is the regen cap =100 — you stock thousands above it with items). stocked/medium/low.
  const stam=ac.stamina, STARGET=2000;
  const spct=(stam!=null)?Math.max(4,Math.min(100,stam/STARGET*100)):0;
  // 3 estados por umbral: >=2000 STOCKED (verde) · 500-2000 medium (amarillo, por defecto) · <500 LOW (rojo)
  const sstate=(stam>=STARGET)?'STOCKED':((stam>=500)?'medium':'LOW');
  const scls=(stam>=STARGET)?' s-high':((stam>=500)?'':' s-low');
  const stamHtml = (stam!=null) ? `<div class="stam${scls}">
      <div class=stamtop><span class=stamval>⚡ ${Number(stam).toLocaleString()}</span><span class=stamsub>${sstate}</span></div>
      <div class=stambar><span class=stamfill style="width:${spct}%"></span></div></div>` : '';
  // BUBBLE (peace shield): peace_shield_left en s. Color por ETA restante: <2h rojo · 2-6h amarillo · >6h verde.
  // <1h (o expirado) -> el card entero parpadea con aura roja (bubble-crit) = aviso visual destacado.
  const shl=ac.peace_shield_left;
  let bubbleHtml='', bubbleCrit=false;
  if(shl!=null){
    let col,txt;
    if(shl<=0){ col='var(--down)'; txt='none'; bubbleCrit=true; }
    else { txt='Active · '+fmtDur(shl); col=(shl<7200)?'var(--down)':((shl<21600)?'var(--warn)':'var(--ok)'); if(shl<3600) bubbleCrit=true; }
    const bubBtn = n.port ? ` <button class="bubtn" data-port="${n.port}" data-nm="${esc(n.name||'')}" onclick="renewBubble(this)" title="Renew the peace bubble now (uses 1 Truce Agreement from the bag — longest available; no gems)">renew</button>` : '';
    bubbleHtml=`<div class="row" style="margin-bottom:10px"><span class="k bubble-k">BUBBLE${bubBtn}</span><span class=v style="color:${col};font-weight:800;font-size:10px">${txt}</span></div>`;
  }
  return `<div class="card ${cardCls}${bubbleCrit?' bubble-crit':''}">
    <div class=row><span class=name>${esc(n.name)}</span><span class=pillgrp><span class="pill ${pillCls}">${pillTxt}</span></span></div>
    ${autoSwitch?`<div class=autorow>${autoSwitch}${autoChip}</div>`:''}
    ${stamHtml}
    ${bubbleHtml}
    <div class=row><span class=k>Map objects</span><span class=v>${num(sc.objs)}</span></div>
    <div class=row><span class=k>Scan area</span><span class=v>${areaTxt}</span></div>
    ${mcov}
    ${balHtml}
    <div class=row><span class=k>Sweep</span><span class=v>${num(sc.idx)}/${num(sc.order)} · pass ${num(sc.pass)}</span></div>
    <div class=bar><i style="width:${Math.max(0,Math.min(100,prog))}%"></i></div>
    <div class=row><span class=k>Free slots</span><span class=v>${num(ac.free_slots)}</span></div>
    <div class=row><span class=k>Marches</span><span class=v>${num(ac.active_marches)}</span></div>
    <details class=moreinfo${MOREINFO_OPEN[n.port]?' open':''} data-port="${n.port}" ontoggle="onMoreToggle(this)"><summary>More Info</summary><div class=moreinfo-b>
      <div class=row><span class=k>Last heartbeat</span><span class=v>${fmtAge(n.hb_age)} · ${n.hb_count} total</span></div>
      ${capacityRow}
      <div class=row><span class=k>Reply ratio · freezes</span><span class=v>${num(sc.reply_ratio)} · ${num(sc.freezes)}</span></div>
    </div></details>
    ${n.port?`<div class=nodeacts>
      <button class="nact stop" data-port="${n.port}" data-nm="${esc(n.name||'')}" onclick="nodeCtl(this,'stop_bot')" title="Close Evony on this node (emulator + backend stay alive)">■ Stop</button>
      <button class="nact rel" data-port="${n.port}" data-nm="${esc(n.name||'')}" onclick="nodeCtl(this,'reload')" title="Relaunch the game and re-attach">↻ Restart</button>
    </div>
    ${n.device?`<div class="nodeacts hrrow">
      <button class="nact hr" data-node="${esc(n.id||'')}" data-nm="${esc(n.name||'')}" data-emu="${(n.device.indexOf('emulator-')===0)?1:0}" onclick="hardReset(this)" title="Full reset recovery: physical → reboot device; emulator → cold-boot the AVD. Then frida → relaunch Evony → reattach. A few minutes. Does NOT clear login/data.">⏻ Hard Reset</button>
    </div>`:''}`:''}
  </div>`;
}
async function nodeCtl(btn, action){
  var port=btn.getAttribute('data-port'), nm=btn.getAttribute('data-nm')||('node '+port);
  var verb=(action==='stop_bot')?'Stop':'Restart';
  var det=(action==='stop_bot')?'Closes Evony (emulator + backend stay alive). Use Restart to bring it back.':'Relaunches the game and re-attaches (sweep resumes).';
  if(!confirm(verb+' '+nm+'?\n\n'+det)) return;
  var t=btn.textContent; btn.disabled=true; btn.textContent='⏳';
  try{ var r=await fetch('/px/'+port+'/api/'+action,{method:'POST',headers:{'Content-Type':'application/json'},body:'{}'}); var j=await r.json(); btn.textContent=(r.ok&&j.ok)?'✓ '+verb.toLowerCase()+'ing':'✗'; }
  catch(e){ btn.textContent='✗'; }
  finally{ setTimeout(function(){try{btn.disabled=false;btn.textContent=t;}catch(e){}},3000); }
}
async function hardReset(btn){
  var node=btn.getAttribute('data-node'), nm=btn.getAttribute('data-nm')||node;
  var emu=btn.getAttribute('data-emu')==='1';
  var msg = emu
    ? ('HARD RESET '+nm+'?\n\nFull emulator cold-boot (~2-3 min downtime):\nkill emulator → cold-boot AVD → frida-server → relaunch Evony → reattach.\n\nContinue?')
    : ('HARD RESET '+nm+'?\n\nFull physical device reboot (~5 min downtime):\nreboot → frida-server → dismiss keyguard → relaunch Evony → reattach.\n\nContinue?');
  if(!confirm(msg)) return;
  await doHardReset(node, nm, btn, false);
}
async function doHardReset(node, nm, btn, force){
  var t=btn.textContent; btn.disabled=true; btn.textContent='⏳';
  try{
    var body=force?{node:node,force:true}:{node:node};
    var r=await fetch('/hard_reset',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});
    var j=await r.json();
    if(r.ok && j.ok){ btn.textContent='✓ resetting'; alert('Hard reset started on '+nm+' (~5 min).\nThe device reboots and comes back scanning on its own.'); }
    else if(j.error==='bubble_low'){
      btn.disabled=false; btn.textContent=t;
      if(confirm((j.msg||('Bubble low on '+nm))+'\n\nForce the hard reset anyway (the bubble may drop)?')){ return doHardReset(node, nm, btn, true); }
    }
    else { btn.textContent='✗'; alert('Could not hard-reset '+nm+':\n'+(j.msg||j.error||'unknown')); }
  }catch(e){ btn.textContent='✗'; }
  finally{ setTimeout(function(){try{btn.disabled=false;btn.textContent=t;}catch(e){}},4000); }
}
async function renewBubble(btn){
  var port=btn.getAttribute('data-port'), nm=btn.getAttribute('data-nm')||('node '+port);
  if(!confirm('Renew the peace bubble on '+nm+' now?\n\nUses 1 Truce Agreement from the bag (longest duration available). No gems.')) return;
  var t=btn.textContent; btn.disabled=true; btn.textContent='⏳';
  try{ var r=await fetch('/px/'+port+'/api/truce_now',{method:'POST',headers:{'Content-Type':'application/json'},body:'{}'}); var j=await r.json();
    if(j.ok && j.used){ btn.textContent='✓ renewed'; }
    else if(j.ok && j.skipped){ btn.textContent='already up'; }
    else { btn.textContent='✗'; alert('Could not renew '+nm+': '+(j.error||'unknown')); }
  }catch(e){ btn.textContent='✗'; }
  finally{ setTimeout(function(){try{btn.disabled=false;btn.textContent=t;}catch(e){}},3500); }
}
// ===================== SETTINGS PANEL =====================
var SETTINGS={wfrom:0,uids:[],members:[],nodes:[],rexPort:8796};
// "Whisper From" GLOBAL: nodo emisor de TODOS los whispers (coords de target + bubble alerts). Se persiste en el hub
// (clave bubble_whisper_from). Se carga al iniciar la página (no solo al abrir Settings) para que scWhisper lo respete.
async function loadWFrom(){ try{ var r=await fetch('/settings',{cache:'no-store'}); var j=await r.json();
    SETTINGS.wfrom=(j.settings&&j.settings.bubble_whisper_from)||0; SETTINGS.nodes=j.nodes||[]; SETTINGS.rexPort=j.rex_port||8796; }catch(e){} }
function wfromPort(){ return SETTINGS.wfrom || SETTINGS.rexPort || SC.rexPort || 8796; }   // puerto del nodo "Whisper From" (fallback: RexValerius)
function wfromName(){ var p=wfromPort(); var n=(SETTINGS.nodes||[]).find(function(x){return x.port===p;}); return n?n.name:'RexValerius'; }
function openSettings(){ document.getElementById('setOverlay').classList.add('open'); loadSettings(); }
function closeSettings(){ document.getElementById('setOverlay').classList.remove('open'); }
async function loadSettings(){
  try{ var r=await fetch('/settings',{cache:'no-store'}); var j=await r.json();
    SETTINGS.wfrom=(j.settings&&j.settings.bubble_whisper_from)||0;
    SETTINGS.uids=((j.settings&&j.settings.bubble_alert_uids)||[]).map(Number);
    SETTINGS.nodes=j.nodes||[]; SETTINGS.rexPort=j.rex_port||8796;
  }catch(e){}
  var sel=document.getElementById('setWFrom'); var defPort=SETTINGS.wfrom||SETTINGS.rexPort;
  sel.innerHTML=SETTINGS.nodes.map(function(n){return '<option value="'+n.port+'"'+(n.port===defPort?' selected':'')+'>'+esc3(n.name)+'</option>';}).join('')||'<option value=0>— no nodes —</option>';
  try{ var rp=SETTINGS.rexPort||8796; var rr=await fetch('/px/'+rp+'/api/members',{cache:'no-store'}); var dd=await rr.json(); SETTINGS.members=dd.members||[]; }catch(e){ SETTINGS.members=[]; }
  renderUsers();
}
function nameFor(uid){ var m=SETTINGS.members.find(function(x){return String(x.uid)===String(uid);}); return m?m.name:('u'+uid); }
function renderUsers(){
  var box=document.getElementById('setUsers');
  var chips=SETTINGS.uids.map(function(u){return '<span class=chip>'+esc3(nameFor(u))+' <b onclick="removeUser('+u+')">×</b></span>';}).join('');
  var avail=SETTINGS.members.filter(function(m){return SETTINGS.uids.indexOf(Number(m.uid))<0;});
  var add=avail.length?('<select class=chipadd onchange="addUser(this.value);this.selectedIndex=0"><option value="">+ add…</option>'+avail.map(function(m){return '<option value="'+m.uid+'">'+esc3(m.name||('u'+m.uid))+'</option>';}).join('')+'</select>'):'';
  box.innerHTML=(chips||'<span class=setmut>none selected</span>')+' '+add;
}
function addUser(uid){ uid=Number(uid); if(uid&&SETTINGS.uids.indexOf(uid)<0){ SETTINGS.uids.push(uid); renderUsers(); } }
function removeUser(uid){ SETTINGS.uids=SETTINGS.uids.filter(function(x){return x!==Number(uid);}); renderUsers(); }
async function saveSettings(){
  var wf=parseInt(document.getElementById('setWFrom').value||'0')||0;
  var st=document.getElementById('setStatus'); st.textContent='saving…';
  try{ var r=await fetch('/settings',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({bubble_whisper_from:wf,bubble_alert_uids:SETTINGS.uids})}); var j=await r.json();
    st.textContent=(j&&j.ok)?'✓ saved':'✗ error'; SETTINGS.wfrom=wf;
  }catch(e){ st.textContent='✗ error'; }
  setTimeout(function(){try{st.textContent='';}catch(e){}},2500);
}
// AVISO de burbuja: toast en la página + notificación de dispositivo (Notification API) cuando una burbuja
// esté CAÍDA o le queden <=2h. Throttle 15min por nodo; se re-arma cuando la burbuja vuelve a estar sana (>2h).
var BUBBLE_NOTIFIED={};
function showToast(msg, critical){
  var c=document.getElementById('toasts'); if(!c){ c=document.createElement('div'); c.id='toasts'; document.body.appendChild(c); }
  var t=document.createElement('div'); t.className='toast'+(critical?' crit':''); t.textContent=msg; c.appendChild(t);
  setTimeout(function(){ t.style.opacity='0'; setTimeout(function(){try{t.remove();}catch(e){}},400); }, 9000);
}
function bubbleNotify(nodes){
  var now=Date.now();
  (nodes||[]).forEach(function(n){
    var ac=n.account||{}, shl=ac.peace_shield_left;
    if(shl==null) return;
    var eta = shl - (n.hb_age||0);              // ETA real en s
    if(eta>7200){ delete BUBBLE_NOTIFIED[n.name]; return; }   // sana (>2h) -> re-armar el aviso
    if(now-(BUBBLE_NOTIFIED[n.name]||0) < 900000) return;     // throttle 15 min por nodo
    BUBBLE_NOTIFIED[n.name]=now;
    var down=(eta<=0);
    var msg = down ? ('⚠️ '+n.name+': BUBBLE DOWN') : ('🛡️ '+n.name+': bubble '+fmtDur(eta)+' left');
    showToast(msg, down);
    try{ if(window.Notification && Notification.permission==='granted') new Notification('Alliance Mesh — bubble alert', {body:msg, tag:'bubble-'+n.name, requireInteraction:down}); }catch(e){}
  });
}
async function tick(){
  let d;
  try{
    const r=await fetch('/fleet',{cache:'no-store'});
    if(r.status===401){ location.href='/'; return; }        // sesión caducada -> a la pantalla de login
    if(!r.ok){ document.getElementById('conn').textContent='⚠️ hub restarting…'; return; }  // 503 mientras reinicia
    d=await r.json();
  }catch(e){ document.getElementById('conn').textContent='⚠️ hub unreachable'; return; }
  if(!d || !d.counts){ document.getElementById('conn').textContent='⚠️ hub restarting…'; return; }
  document.getElementById('conn').textContent='';
  const c=d.counts;
  document.getElementById('summary').innerHTML=
    `<span class="dot ok"></span>${c.healthy} healthy · ${c.degraded} degraded · ${c.down} down · ${c.total} total`
    + (d.mesh_objs!=null ? ` · 🗺️ ${d.mesh_objs} monsters in the mesh` : '');
  document.getElementById('epoch').textContent='epoch '+d.epoch+' · '+d.epoch_members.length+' active';
  // Map coverage + balance mode. count -> tira de slices; weighted -> resumen de balance.
  const cov=d.coverage; const cbx=document.getElementById('coverage');
  if(cov && cov.n){
    const b=cov.balance;
    const modeBadge = (cov.mode==='weighted')
      ? '<span class="mbadge on">⚖ WEIGHTED (throughput) · ACTIVE</span>'
      : '<span class="mbadge sh">⚖ WEIGHTED · SHADOW (recommendation only — count mode active)</span>';
    const balTxt = b ? ` · map refresh ${Math.round(b.best_s/60*10)/10}–${Math.round(b.worst_s/60*10)/10} min (nodes owning tiles) · spread ${b.spread}× · ${b.balanced}/${b.total} balanced`
                     + (b.redundant ? ` · ${b.redundant} core-only backup` : '') : '';
    const compTxt = cov.complete?'<b style="color:var(--ok)">COMPLETE ✅</b>'
      :('<b style="color:var(--down)">⚠ INCOMPLETE — '+(cov.missing||[]).length+' slice(s) NOT scanned</b>');
    let body='';
    if(cov.slices){   // modo count: tira de slices por rank
      body = '<div class=covstrip>'+cov.slices.map(s=>{
        const gap=(s.owners||[]).length===0;
        return `<div class="covcell${gap?' gap':''}"><span class=cr>slice ${s.rank+1}/${cov.n}</span>`
          +`<span class=co>${gap?'⚠ NOT SCANNED':esc(s.owners.join(', '))}</span></div>`;
      }).join('')+'</div>';
    } else if(cov.mode==='weighted'){   // modo weighted: una celda por nodo con su PROPIEDAD ponderada (no hay slices i%N)
      const owners=(d.nodes||[]).map(n=>({name:n.name, p:(n.assignment&&n.assignment.plan)||null}))
        .filter(o=>o.p && o.p.regions>0)                 // solo los que poseen periferia (los core-only no)
        .sort((a,b)=>(b.p.share||0)-(a.p.share||0));
      if(owners.length){
        body='<div class=covstrip>'+owners.map(o=>
          `<div class=covcell><span class=cr>${esc(o.name)}</span>`
          +`<span class=co>${o.p.share}% · ${o.p.buckets} buckets · ~${o.p.sweep_s}s</span></div>`
        ).join('')+'</div>';
      }
    }
    cbx.innerHTML=`<div class=covtitle>🗺️ Map coverage — ${modeBadge} · ${cov.scanning} scanning node(s); core scanned by all · ${compTxt}${balTxt}</div>${body}`;
  } else cbx.innerHTML='';
  // Phase 4: open mesh rallies (led by a node)
  const rb=document.getElementById('rallybar'); const rs=d.rallies||[];
  rb.innerHTML = (false /* MESH-RALLY oculto temporalmente (2026-08-28) — re-activar: false->true */ && rs.length)
    ? `<div class=rtitle>⚔️ ${rs.length} mesh rally(s) — led by your nodes, available to fill</div>`
      + rs.map(r=>`<span class=rally><span class=rn>${esc(r.mname||('war '+r.war_id))}${r.mlevel?(' L'+r.mlevel):''}</span>`
        +`<span class=rm>leader ${esc(r.leader_node)} · (${r.tx},${r.ty})${r.group?(' · '+esc(r.group)):''}</span></span>`).join('')
    : '';
  const g=document.getElementById('grid');
  g.innerHTML = d.nodes.length ? d.nodes.map(card).join('') : '<div class=empty>No nodes yet. Waiting for heartbeats…</div>';
  scSyncNodes(d.nodes);
  bubbleNotify(d.nodes);   // aviso (toast + notificación de dispositivo) si una burbuja está caída o <=2h
  const th=d.thresholds;
  document.getElementById('foot').textContent=
    `Hub up ${fmtAge(d.hub_uptime)} · thresholds: healthy <${th.healthy_max}s, down ≥${th.down_after}s · last change: ${d.epoch_last_change||'—'}`;
}
try{ if(window.Notification && Notification.permission==='default') Notification.requestPermission(); }catch(e){}
tick(); setInterval(tick, 2000);
loadWFrom(); setInterval(loadWFrom, 60000);   // "Whisper From" global fresco (cambios desde otra pestaña/sesión) para scWhisper

// ===== Fase 6 — Shortcuts en el hub: vista GLOBAL de la malla, como en los bots =====
// Menu = selección (familias|nivel) GLOBAL y persistida en el hub (misma en todo dispositivo, vía /sc_sel).
// Shortcuts (Menu cerrado) = solo las preferencias seleccionadas + botones MyPresets/ActiveAttacks/Bubble/EventBoss.
var SC={port:0,sel:new Set(),fams:[],menuOpen:false,N:40,sort:'dist',showEmpty:false,rexPort:0,showOwn:false,bubMin:120,atkRows:[],atkAlly:'',bubRows:[],bubTotal:0,whisperTo:'',act:''};
// SC.act = identificador de lo que está MOSTRÁNDOSE en el panel ('k:<fam|lv>' un pill, o 'btn:<mypresets|attacks|bubble|eventboss>').
// Resalta el clicable activo. scHiliteActive() lo aplica en vivo (sin re-render) y el render lo hornea (persiste en refrescos).
function scHiliteActive(){ try{ document.querySelectorAll('#scFamilies .scpill, #scBtns .scgold').forEach(function(b){ b.classList.toggle('active', b.getAttribute('data-act')===SC.act); }); }catch(e){} }
function esc3(v){return String(v==null?'':v).replace(/[&<>]/g,function(c){return {'&':'&amp;','<':'&lt;','>':'&gt;'}[c];});}
function scKey(f,l){return f+'|'+l;}
function scSyncNodes(nodes){   // elige por dentro un nodo SANO que escanea; su store ES la vista global
  if(SC.port && (nodes||[]).some(function(n){return n.port===SC.port&&n.agent&&n.agent.ready&&n.agent.connected===1;})) return;
  var h=(nodes||[]).filter(function(n){return n.port&&n.agent&&n.agent.ready&&n.agent.connected===1;});
  var pick=h[0]||(nodes||[]).filter(function(n){return n.port;})[0];
  if(pick&&pick.port!==SC.port){ SC.port=pick.port; var b=document.getElementById('scBody'); if(b&&b.style.display!=='none') scLoad(); }
}
async function scGet(path){ var r=await fetch('/px/'+SC.port+path,{cache:'no-store'}); if(!r.ok) throw new Error('HTTP '+r.status); return r.json(); }
async function scLoadSel(){ try{ var r=await fetch('/sc_sel',{cache:'no-store'}); var j=await r.json(); SC.sel=new Set(j.sel||[]); SC.whisperTo=j.whisper_to||''; }catch(e){} }
async function scToggle(fam,lv){
  var k=scKey(fam,lv); if(SC.sel.has(k))SC.sel.delete(k); else SC.sel.add(k);   // optimista
  scRenderMenu();
  try{ await fetch('/sc_sel',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({toggle:k})}); }catch(e){}
}
async function scLoad(){ if(!SC.port)return; var m=document.getElementById('scMeta'); m.textContent='loading…';
  try{
    await scLoadSel();
    if(!SC.rexPort){ try{ var ro=await scGet('/api/mesh_roster'); var rx=(ro.roster||[]).filter(function(m){return /rexval/i.test(m.name);})[0]; SC.rexPort=rx?rx.port:8796; }catch(e){ SC.rexPort=8796; } }
    var _wto=document.getElementById('scWTo'); if(_wto&&!_wto.options.length) scFillMembers('scWTo');
    var q=await scGet('/api/quick'); SC.fams=q.families||[];
    var aN=0; try{ var at=await scGet('/api/attacks'); aN=(at.attacks||[]).length; }catch(e){}
    var mpN=0,ebN=0; SC.fams.forEach(function(f){ mpN+=(f.live||0); if(/event/i.test(f.group)) ebN+=(f.live||0); });
    var famsLive=SC.fams.filter(function(f){return (f.live||0)>0;}).length;
    document.getElementById('scFamCount').textContent=famsLive+' families on map now';
    var _ga=function(id){return SC.act==='btn:'+id?' active':'';};
    document.getElementById('scBtns').innerHTML=
      '<button class="scgold'+_ga('mypresets')+'" data-act="btn:mypresets" onclick="scMyPresets()">★ MyPresets <span class=c>· '+mpN+'</span></button>'+
      '<button class="scgold'+_ga('attacks')+'" data-act="btn:attacks" onclick="scAttacks()">⚔ ActiveAttacks <span class=c>· '+aN+'</span></button>'+
      '<button class="scgold'+_ga('bubble')+'" data-act="btn:bubble" onclick="scBubble()">🫧 Bubble</button>'+
      '<button class="scgold'+_ga('eventboss')+'" data-act="btn:eventboss" onclick="scEventBoss()">⚡ EventBoss <span class=c>· '+ebN+' on map</span></button>';
    if(SC.menuOpen) scRenderMenu(); else scRenderShortcuts();
    m.textContent=mpN+' on map';
  }catch(e){ m.textContent='⚠ '+e.message; }
}
function scPillHtml(f,l,menu){
  var nmj=esc3(f.name).replace(/'/g,"\\'"); var k=scKey(f.name,l.lv); var z=!l.count, seld=SC.sel.has(k);
  if(menu){ var cls=seld?'sel':(l.count?'red':'z');
    return '<button class="scpill '+cls+'"'+(" onclick=\"scToggle('"+nmj+"',"+l.lv+")\"")+'>Lv'+l.lv+(l.count?' ·'+l.count:'')+'</button>'; }
  var act='k:'+k;
  return '<button class="scpill'+(SC.act===act?' active':'')+'" data-act="'+esc3(act)+'"'+(" onclick=\"scList(this,'"+nmj+"',"+l.lv+")\"")+'>Lv'+l.lv+' ·'+l.count+'</button>';
}
function scFamRow(f,menu,levels){
  var pills=(levels||f.levels||[]).map(function(l){return scPillHtml(f,l,menu);}).join('');
  return '<div class=scfam><span class=fn>'+esc3(f.name)+'</span><span class=fg>'+esc3(f.group)+(f.live!=null?' · total '+f.live:'')+'</span><div class=scpills>'+pills+'</div></div>';
}
function scRenderMenu(){   // TODAS las familias×nivel con selección (rojo=en mapa sin elegir, verde✓=elegido, gris=0)
  document.getElementById('scMenuList').style.display='';
  document.getElementById('scFamilies').style.display='none';
  document.getElementById('scBtns').style.display='none';
  document.getElementById('scPanel').style.display='none';
  var live=SC.fams.filter(function(f){return (f.live||0)>0;}), empty=SC.fams.filter(function(f){return !(f.live||0);});
  var html=live.map(function(f){return scFamRow(f,true);}).join('');
  if(empty.length){ html+='<div style="margin-top:14px"><button class=scmore onclick="SC.showEmpty=!SC.showEmpty;scRenderMenu()">'+(SC.showEmpty?'▾ ':'▸ ')+'Show '+empty.length+' families with none on map right now</button>'+(SC.showEmpty?('<div style="margin-top:10px">'+empty.map(function(f){return scFamRow(f,true);}).join('')+'</div>'):'')+'</div>'; }
  document.getElementById('scMenuList').innerHTML=html||'<div style="color:var(--mut);font-size:12px">No families.</div>';
}
function scRenderShortcuts(){   // solo las preferencias seleccionadas (que estén en mapa)
  document.getElementById('scMenuList').style.display='none';
  document.getElementById('scFamilies').style.display='';
  document.getElementById('scBtns').style.display='';
  var box=document.getElementById('scFamilies'); var rows='';
  SC.fams.forEach(function(f){
    var lv=(f.levels||[]).filter(function(l){return l.count&&SC.sel.has(scKey(f.name,l.lv));});
    if(lv.length) rows+=scFamRow(f,false,lv);
  });
  box.innerHTML=rows||'<div style="color:var(--mut);font-size:12px">No preferences yet — open <b>≡ Menu</b> to add targets to your Shortcuts.</div>';
}
function scPanelBox(){ var p=document.getElementById('scPanel'); p.style.display=''; return p; }
function scScrollPanel(){ var p=document.getElementById('scPanel'); if(!p) return;
  requestAnimationFrame(function(){ var vh=window.innerHeight||document.documentElement.clientHeight; var r=p.getBoundingClientRect();
    if(r.top<0 || r.bottom>vh) p.scrollIntoView({behavior:'smooth', block:(r.height>vh?'start':'nearest')}); }); }
function scSortRows(rows){ if(SC.sort==='power') rows.sort(function(a,b){return (b.power||0)-(a.power||0);}); return rows; }
async function scList(el,fam,lv){ SC.act=(el&&el.getAttribute)?(el.getAttribute('data-act')||''):('k:'+scKey(fam,lv)); scHiliteActive();
  var box=scPanelBox(); box.innerHTML='Loading '+esc3(fam)+' Lv'+lv+'…';
  try{ var d=await scGet('/api/quick_list?fam='+encodeURIComponent(fam)+'&lv='+lv+'&limit='+SC.N);
    scRenderCoords(box,esc3(fam)+' Lv'+lv,scSortRows(d.shown||[]),d.total,fam,lv);
  }catch(e){ box.innerHTML='⚠ '+e.message; }
}
async function scEventBoss(){ SC.act='btn:eventboss'; scHiliteActive(); var box=scPanelBox(); box.innerHTML='Loading event bosses…';
  try{ var ev=SC.fams.filter(function(f){return /event/i.test(f.group)&&(f.live||0)>0;}).map(function(f){return f.name;});
    if(!ev.length){ box.innerHTML='<div style="color:var(--mut);font-size:12px">No event bosses on map.</div>'; return; }
    var d=await scGet('/api/quick_list?fam='+encodeURIComponent(ev.join(','))+'&lv=0&limit='+SC.N);
    scRenderCoords(box,'⚡ EventBoss',scSortRows(d.shown||[]),d.total);
  }catch(e){ box.innerHTML='⚠ '+e.message; }
}
async function scMyPresets(){ SC.act='btn:mypresets'; scHiliteActive(); var box=scPanelBox(); box.innerHTML='Loading…';
  try{ var keys=[]; SC.fams.forEach(function(f){ (f.levels||[]).forEach(function(l){ if(l.count&&SC.sel.has(scKey(f.name,l.lv))) keys.push(f.name+'|'+l.lv); }); });
    if(!keys.length){ box.innerHTML='<div style="color:var(--mut);font-size:12px">No selected presets — add targets in the Menu.</div>'; return; }
    var d=await scGet('/api/quick_list?keys='+encodeURIComponent(keys.join(','))+'&limit='+SC.N);
    scRenderCoords(box,'★ MyPresets',scSortRows(d.shown||[]),d.total);
  }catch(e){ box.innerHTML='⚠ '+e.message; }
}
async function scAttacks(){ SC.act='btn:attacks'; scHiliteActive(); var box=scPanelBox(); box.innerHTML='Loading attacks…';
  try{ var d=await scGet('/api/attacks'); SC.atkRows=d.attacks||[]; SC.atkAlly=d.ally_tag||''; scRenderAttacks(); }
  catch(e){ box.innerHTML='⚠ '+e.message; }
}
function scActBtns(){ var wn=esc3(wfromName()); return ' <button onclick="scCopy(this)" title="Copy these coords to the clipboard">⧉ Copy</button> <button onclick="scWhisper(this)" title="'+wn+' (the global \'Whisper From\' account) whispers these coords to the alliance member chosen in the top bar (Send whisper to)">✉ Send whisper (from '+wn+')</button>'; }
function scRenderAttacks(){ var box=document.getElementById('scPanel'); box.style.display='';
  var rows=SC.atkRows||[], ally=SC.atkAlly||'';
  var shown=SC.showOwn?rows:rows.filter(function(a){return String(a.tag||'')!==String(ally);});
  var chk='<label style="font-weight:400;color:var(--mut);margin-left:12px"><input type=checkbox id=scOwnAtk'+(SC.showOwn?' checked':'')+' onchange="SC.showOwn=this.checked;scRenderAttacks()"> Show own alliance</label>';
  box.innerHTML='<div class=scwrap><b>⚔ Active Attacks</b> <span class=mut>'+shown.length+'</span>'+chk+scActBtns()+'</div>'+(shown.length?shown.map(function(a){return '<div class=sclrow><span>'+esc3(a.tname||('mob '+a.tg_id))+' Lv'+(a.tlevel||'')+' <span style="color:var(--mut)">('+esc3(a.kind||'')+(a.tag?' ['+esc3(a.tag)+']':'')+')</span></span><span class=co>('+a.tx+','+a.ty+')</span><span style="color:var(--mut)">'+(a.eta!=null?'eta '+a.eta+'s ':'')+esc3(a.name||'')+'</span></div>';}).join(''):'<div style="color:var(--mut);font-size:12px">No active attacks'+(SC.showOwn?'':' (own alliance hidden)')+'.</div>');
  box._coords=shown.map(function(a){return {name:(a.tname||''),level:(a.tlevel||0),x:a.tx,y:a.ty,dist:0};}).filter(function(c){return c.x&&c.y;});
  scScrollPanel();
}
async function scBubble(){ SC.act='btn:bubble'; scHiliteActive(); var box=scPanelBox(); box.innerHTML='Loading…';
  try{ var d=await scGet('/api/bubble?limit='+SC.N+'&min='+(SC.bubMin||0)); SC.bubRows=d.shown||[]; SC.bubTotal=d.total; scRenderBubble(); }
  catch(e){ box.innerHTML='⚠ '+e.message; }
}
function scRenderBubble(){ var box=document.getElementById('scPanel'); box.style.display='';
  var rows=SC.bubRows||[];
  var ctl='<label style="font-weight:400;color:var(--mut);margin-left:12px">Min Monarch Power <input id=scBubMin type=number value="'+SC.bubMin+'" min=0 style="width:64px"> M <button class=scapply onclick="SC.bubMin=parseInt(document.getElementById(\'scBubMin\').value||\'0\')||0;scBubble()">Apply</button></label>';
  var tbl=rows.length
    ? '<table class=sctable><thead><tr><th>Name</th><th>Alliance</th><th class=pw>Power</th><th>Coords</th></tr></thead><tbody>'
      + rows.map(function(o){return '<tr><td>'+esc3(o.name||'player')+'</td><td>'+esc3(o.tag||'—')+'</td><td class=pw>'+(o.power>0?(Math.round((o.power/1e6)*10)/10)+'M':'—')+'</td><td class=co>('+o.x+','+o.y+')</td></tr>';}).join('')
      + '</tbody></table>'
    : '<div style="color:var(--mut);font-size:12px">No unshielded players ≥ '+SC.bubMin+'M.</div>';
  box.innerHTML='<div class=scwrap><b>🫧 Bubble</b> <span class=mut>'+(SC.bubTotal||rows.length)+'</span>'+ctl+scActBtns()+'</div>'+tbl;
  box._coords=rows.map(function(o){return {name:(o.name||''),level:0,x:o.x,y:o.y,dist:o.dist||0};}).filter(function(c){return c.x&&c.y;});
  scScrollPanel();
}
function scRenderCoords(box,title,rows,total,fam,lv){
  var rhtml=rows.map(function(o){return '<div class=sclrow><span>'+esc3(o.name||fam||'')+' Lv'+(o.level||lv||'')+'</span><span class=co>('+o.x+','+o.y+')</span><span style="color:var(--mut)">'+(o.dist!=null?Math.round(o.dist)+' km':'')+'</span></div>';}).join('');
  box.innerHTML='<div class=scwrap><b>'+title+'</b> <span class=mut>'+rows.length+'/'+(total||rows.length)+'</span>'+scActBtns()+'</div>'+rhtml;
  box._coords=rows.map(function(o){return {name:(o.name||fam||''),level:(o.level||lv||0),x:o.x,y:o.y,dist:o.dist||0};}).filter(function(c){return c.x&&c.y;});
  scScrollPanel();
}
function scCopy(btn){ var box=document.getElementById('scPanel'); var L=box._coords||[]; if(!L.length)return;
  var txt=L.map(function(o){return 'Lv'+(o.level||'')+' '+(o.name||'')+' (xy: '+(o.x||0)+' '+(o.y||0)+') '+Math.round(o.dist||0)+'km';}).join(String.fromCharCode(10));
  try{ navigator.clipboard.writeText(txt); }catch(e){ var ta=document.createElement('textarea'); ta.value=txt; document.body.appendChild(ta); ta.select(); try{document.execCommand('copy');}catch(e2){} document.body.removeChild(ta); }
  var t=btn.textContent; btn.textContent='✓ Copied'; setTimeout(function(){try{btn.textContent=t;}catch(e){}},1200);
}
async function scFillMembers(id){ var sel=document.getElementById(id); if(!sel)return;
  try{ var rp=SC.rexPort||8796; var r=await fetch('/px/'+rp+'/api/members',{cache:'no-store'}); var d=await r.json();
    sel.innerHTML=(d.members||[]).map(function(m){return '<option value="'+m.uid+'"'+(String(m.uid)===String(SC.whisperTo)?' selected':'')+'>'+esc3(m.name||('u'+m.uid))+'</option>';}).join('')||'<option value=0>— no members —</option>';
    if(SC.whisperTo) sel.value=SC.whisperTo;
    sel.onchange=function(){ SC.whisperTo=sel.value; fetch('/sc_sel',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({whisper_to:sel.value})}).catch(function(){}); };
  }catch(e){ sel.innerHTML='<option value=0>— members unavailable —</option>'; }
}
async function scWhisper(btn){ var box=document.getElementById('scPanel'); var coords=box._coords||[]; if(!coords.length)return;
  var rp=wfromPort();   // nodo "Whisper From" GLOBAL (Settings); fallback RexValerius
  var toUid=parseInt((document.getElementById('scWTo')||{}).value||'0')||0;   // destinatario elegido (miembro de la alianza)
  if(!toUid){ btn.textContent='✗ pick member'; setTimeout(function(){try{btn.textContent='Send';}catch(e){}},2000); return; }
  var t=btn.textContent; btn.disabled=true; btn.textContent='⏳';
  try{ var r=await fetch('/px/'+rp+'/api/whisper_here',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({coords:coords,recipient_uid:toUid})}); var j=await r.json(); btn.textContent=(r.ok&&j.ok)?('✓ '+j.count):'✗'; }
  catch(e){ btn.textContent='✗'; }
  finally{ setTimeout(function(){try{btn.disabled=false;btn.textContent=t;}catch(e){}},3000); }
}
// ── Scanner (informativo): tabs Resources/Farm/Subcities/SVS. Carga SOLO al pulsar un tab.
// Reutiliza el nodo sano ya elegido por Shortcuts (SC.port); su store es la vista del sweep.
// ── Scanner tabs (informativo, formato de la vieja app). Filtros + tabla ordenable por cabecera.
var SCN={tab:null,rows:[],cols:[],sortIdx:-1,dir:1};
async function snGet(path){ var p=SC.port; if(!p) throw new Error('no healthy node'); var r=await fetch('/px/'+p+path,{cache:'no-store'}); if(!r.ok) throw new Error('HTTP '+r.status); return r.json(); }
function snNum(n){ try{return (n||0).toLocaleString();}catch(e){return String(n||0);} }
function snAbbr(n){ n=n||0; if(n>=1e6) return (n/1e6).toFixed(2)+'M'; if(n>=1e3) return Math.round(n/1e3)+'K'; return String(n); }
function snAgo(s){ s=s||0; if(s<60) return s+' sec ago'; if(s<3600) return Math.floor(s/60)+' min ago'; if(s<86400) return Math.floor(s/3600)+' h ago'; return Math.floor(s/86400)+' d ago'; }
function snDist(r){ return (r.dist==null)?'—':(snNum(r.dist)+' km'); }
function snCopy(x,y){ try{ navigator.clipboard.writeText(x+','+y); }catch(e){} }
function snCopyCell(r){ return '<a class=sncopy onclick="snCopy('+r.x+','+r.y+')" title="Copy coords">'+r.x+','+r.y+' &#9873;</a>'; }
function snOcc(r){ return r.free?'<span class=snfree>Free</span>':esc3(r.owner||r.tag||'taken'); }
function snOpt(sel,val,txt){ return '<option value="'+val+'"'+(String(sel)===String(val)?' selected':'')+'>'+txt+'</option>'; }
function snCommon(pfx,cur){ cur=cur||{};
  return '<div class=snfld><label>Alliance tag</label><input id="'+pfx+'ally" placeholder="e.g. NBB" value="'+(cur.ally||'')+'"></div>'+
    '<div class=snfld><label>Level min</label><input id="'+pfx+'lvmin" type=number value="'+(cur.lvmin||'')+'"></div>'+
    '<div class=snfld><label>Level max</label><input id="'+pfx+'lvmax" type=number value="'+(cur.lvmax||'')+'"></div>'+
    '<div class=snfld><label>Center X</label><input id="'+pfx+'cx" type=number value="'+(cur.cx||'')+'"></div>'+
    '<div class=snfld><label>Center Y</label><input id="'+pfx+'cy" type=number value="'+(cur.cy||'')+'"></div>'+
    '<div class=snfld><label>Radius (empty=all)</label><input id="'+pfx+'rad" type=number placeholder="whole map" value="'+(cur.rad||'')+'"></div>';
}
function snActions(){ return '<div class=snfld><label>&nbsp;</label><span><button class=snapply onclick="snApply()">Apply</button> <button class=snclear onclick="snClear()">Clear</button></span></div>'; }
function snFilterBar(tab){
  if(tab==='resources'){
    return '<div class=snfld><label>Resource type</label><select id="rf_rtype">'+snOpt('',0,'All')+snOpt('',1,'Food')+snOpt('',2,'Wood')+snOpt('',3,'Stone')+snOpt('',4,'Iron')+'</select></div>'+
      '<div class=snfld><label>Occupied</label><select id="rf_occ">'+snOpt('','','All')+snOpt('','free','Free')+snOpt('','occupied','Occupied')+'</select></div>'+
      snCommon('rf_')+
      '<div class=snfld><label>Sort</label><select id="rf_sort">'+snOpt('','available','Available')+snOpt('','level','Level')+snOpt('','dist','Distance')+snOpt('','recent','Recent')+'</select></div>'+
      '<div class=snfld><label>Limit</label><input id="rf_limit" type=number value="500"></div>'+snActions();
  }
  if(tab==='subcities'){
    return '<div class=snfld><label>Quality</label><select id="sf_q">'+snOpt('','','All')+snOpt('',1,'White')+snOpt('',2,'Green')+snOpt('',3,'Blue')+snOpt('',4,'Purple')+snOpt('',5,'Gold')+snOpt('',6,'Red')+'</select></div>'+
      '<div class=snfld><label>Culture</label><select id="sf_cu">'+snOpt('','','All')+snOpt('',1,'European')+snOpt('',2,'American')+snOpt('',3,'Chinese')+snOpt('',4,'Russian')+snOpt('',5,'Korean')+snOpt('',6,'Arabia')+snOpt('',7,'Japan')+'</select></div>'+
      '<div class=snfld><label>Occupied</label><select id="sf_occ">'+snOpt('','','All')+snOpt('','free','Free')+snOpt('','occupied','Occupied')+'</select></div>'+
      snCommon('sf_')+
      '<div class=snfld><label>Sort</label><select id="sf_sort">'+snOpt('','quality','Quality')+snOpt('','level','Level')+snOpt('','dist','Distance')+snOpt('','recent','Recent')+'</select></div>'+
      '<div class=snfld><label>Limit</label><input id="sf_limit" type=number value="500"></div>'+snActions();
  }
  // svs
  return '<div class=snfld><label>State</label><select id="vf_state">'+snOpt('','','All')+snOpt('','free','No bubble')+snOpt('','shielded','Shielded')+'</select></div>'+
    '<div class=snfld><label>Alliance tag</label><input id="vf_ally" placeholder="e.g. NBB"></div>'+
    '<div class=snfld><label>Center X</label><input id="vf_cx" type=number></div>'+
    '<div class=snfld><label>Center Y</label><input id="vf_cy" type=number></div>'+
    '<div class=snfld><label>Sort</label><select id="vf_sort">'+snOpt('','power','Power')+snOpt('','dist','Distance')+snOpt('','recent','Recent')+'</select></div>'+
    '<div class=snfld><label>Limit</label><input id="vf_limit" type=number value="500"></div>'+snActions();
}
function snVal(id){ var e=document.getElementById(id); return e?e.value.trim():''; }
function snQuery(tab){
  var qs=[], add=function(k,id){ var v=snVal(id); if(v!=='') qs.push(k+'='+encodeURIComponent(v)); };
  if(tab==='resources'){ add('rtype','rf_rtype'); add('occupied','rf_occ'); add('alliance','rf_ally'); add('lv_min','rf_lvmin'); add('lv_max','rf_lvmax'); add('cx','rf_cx'); add('cy','rf_cy'); add('radius','rf_rad'); add('sort','rf_sort'); add('limit','rf_limit'); }
  else if(tab==='subcities'){ add('quality','sf_q'); add('culture','sf_cu'); add('occupied','sf_occ'); add('alliance','sf_ally'); add('lv_min','sf_lvmin'); add('lv_max','sf_lvmax'); add('cx','sf_cx'); add('cy','sf_cy'); add('radius','sf_rad'); add('sort','sf_sort'); add('limit','sf_limit'); }
  else { add('state','vf_state'); add('alliance','vf_ally'); add('cx','vf_cx'); add('cy','vf_cy'); add('sort','vf_sort'); add('limit','vf_limit'); }
  return qs.length?('?'+qs.join('&')):'';
}
function snColumns(tab){
  var lvl=function(r){return '<span class=snlvl>'+(r.level||0)+'</span>';};
  if(tab==='resources') return [
    {h:'Name', c:function(r){return esc3(r.name);}, s:function(r){return (r.name||'').toLowerCase();}},
    {h:'Level', c:lvl, s:function(r){return r.level||0;}},
    {h:'Available', c:function(r){return snAbbr(r.available);}, s:function(r){return r.available||0;}},
    {h:'Occupied', c:snOcc, s:function(r){return r.free?0:1;}},
    {h:'X', c:function(r){return r.x;}, s:function(r){return r.x;}},
    {h:'Y', c:function(r){return r.y;}, s:function(r){return r.y;}},
    {h:'Dist', c:snDist, s:function(r){return r.dist==null?1e12:r.dist;}},
    {h:'Copy', c:snCopyCell, s:null},
    {h:'Discovered', c:function(r){return snAgo(r.age);}, s:function(r){return r.age;}} ];
  if(tab==='subcities') return [
    {h:'Name', c:function(r){return esc3(r.name||'');}, s:function(r){return (r.name||'').toLowerCase();}},
    {h:'Quality', c:function(r){return '<span class="snq snq-'+esc3(r.color)+'">'+esc3(r.color)+'</span>';}, s:function(r){return r.quality||0;}},
    {h:'Culture', c:function(r){return r.culture_label?'<span class=sntag>'+esc3(r.culture_label)+'</span>':'';}, s:function(r){return (r.culture_label||'').toLowerCase();}},
    {h:'Level', c:lvl, s:function(r){return r.level||0;}},
    {h:'Occupied', c:snOcc, s:function(r){return r.free?0:1;}},
    {h:'X', c:function(r){return r.x;}, s:function(r){return r.x;}},
    {h:'Y', c:function(r){return r.y;}, s:function(r){return r.y;}},
    {h:'Dist', c:snDist, s:function(r){return r.dist==null?1e12:r.dist;}},
    {h:'Copy', c:snCopyCell, s:null},
    {h:'Discovered', c:function(r){return snAgo(r.age);}, s:function(r){return r.age;}} ];
  // svs
  return [
    {h:'#', c:function(r,i){return i+1;}, s:null},
    {h:'Name', c:function(r){return esc3(r.name||'');}, s:function(r){return (r.name||'').toLowerCase();}},
    {h:'Alliance', c:function(r){return r.tag?'<span class=sntag>'+esc3(r.tag)+'</span>':'';}, s:function(r){return (r.tag||'').toLowerCase();}},
    {h:'Server', c:function(r){return 's'+r.sv;}, s:function(r){return r.sv||0;}},
    {h:'Bubble', c:function(r){return r.bubble?'<span class=snbusy>yes</span>':'no';}, s:function(r){return r.bubble?1:0;}},
    {h:'Power', c:function(r){return r.power?snAbbr(r.power):'—';}, s:function(r){return r.power||0;}},
    {h:'X', c:function(r){return r.x;}, s:function(r){return r.x;}},
    {h:'Y', c:function(r){return r.y;}, s:function(r){return r.y;}},
    {h:'Dist', c:snDist, s:function(r){return r.dist==null?1e12:r.dist;}},
    {h:'Copy', c:snCopyCell, s:null},
    {h:'Discovered', c:function(r){return snAgo(r.age);}, s:function(r){return r.age;}} ];
}
function snRenderTable(){
  var box=document.getElementById('snPanel'), cols=SCN.cols, rows=SCN.rows;
  if(SCN.sortIdx>=0 && cols[SCN.sortIdx] && cols[SCN.sortIdx].s){
    var f=cols[SCN.sortIdx].s, d=SCN.dir;
    rows=rows.slice().sort(function(a,b){ var x=f(a),y=f(b); return (x<y?-1:x>y?1:0)*d; });
  }
  var head=cols.map(function(cl,i){ var ar=(SCN.sortIdx===i)?(SCN.dir>0?' &#9650;':' &#9660;'):''; return '<th'+(cl.s?' class=snsort onclick="snSort('+i+')"':'')+'>'+cl.h+ar+'</th>'; }).join('');
  var body=rows.map(function(r,i){ return '<tr>'+cols.map(function(cl){ return '<td>'+cl.c(r,i)+'</td>'; }).join('')+'</tr>'; }).join('');
  box.innerHTML='<table class=sntbl><thead><tr>'+head+'</tr></thead><tbody>'+body+'</tbody></table>';
}
function snSort(i){ if(SCN.sortIdx===i){ SCN.dir=-SCN.dir; } else { SCN.sortIdx=i; SCN.dir=1; } snRenderTable(); }
function snApply(){ snLoad(); }
function snClear(){ snTab(SCN.tab); }   // re-render filtros por defecto + recarga
function snTab(name){ SCN.tab=name; SCN.sortIdx=-1; SCN.dir=1;
  // exclusivo: abrir un tab del Scanner cierra el panel de Shortcuts
  var sb=document.getElementById('scBody'); if(sb) sb.style.display='none';
  var sa=document.getElementById('scArrow'); if(sa) sa.textContent='▾';
  document.getElementById('snWrap').style.display='';
  document.querySelectorAll('.sntabs .sntab').forEach(function(b){ b.classList.toggle('on', b.getAttribute('data-tab')===name); });
  document.getElementById('snFilters').innerHTML=snFilterBar(name);
  snLoad();
}
async function snLoad(){ var box=document.getElementById('snPanel'), m=document.getElementById('snMeta');
  if(!SCN.tab){ box.innerHTML='<div class=snhint>Pick a tab to load its data.</div>'; return; }
  if(!SC.port){ box.innerHTML='<div class=snhint>No healthy node available.</div>'; m.textContent=''; return; }
  box.innerHTML='<div class=snhint>loading…</div>'; m.textContent='';
  try{
    var d=await snGet('/api/'+SCN.tab+snQuery(SCN.tab));
    SCN.rows=d.rows||[]; SCN.cols=snColumns(SCN.tab);
    if(SCN.tab==='resources'){ var s=d.summary||{};
      m.textContent=snNum(d.count)+' tiles  ·  '+Object.keys(s).map(function(k){return k+' '+snNum(s[k].free)+'/'+snNum(s[k].total);}).join('  ·  ');
    } else if(SCN.tab==='subcities'){ m.textContent=snNum(d.count)+' subcities';
    } else { var bs=d.by_server||{}; m.textContent='Our server: '+d.our_server+'  ·  '+snNum(d.count)+' foreign  ·  '+(Object.keys(bs).map(function(k){return k+' ('+bs[k]+')';}).join('  ·  ')||'none on map'); }
    if(!SCN.rows.length){ box.innerHTML='<div class=snhint>No rows.</div>'; return; }
    snRenderTable();
  }catch(e){ box.innerHTML='<div class=snhint>⚠ '+e.message+'</div>'; m.textContent=''; }
}
(function(){
  var tog=document.getElementById('scToggle'), body=document.getElementById('scBody'), arr=document.getElementById('scArrow');
  if(!tog)return;
  tog.onclick=function(){ var open=body.style.display==='none'; body.style.display=open?'':'none'; arr.textContent=open?'▴':'▾';
    if(open){ // exclusivo: abrir Shortcuts cierra el panel del Scanner y desactiva sus tabs
      var w=document.getElementById('snWrap'); if(w) w.style.display='none';
      document.querySelectorAll('.sntabs .sntab').forEach(function(b){ b.classList.remove('on'); }); SCN.tab=null;
      scLoad(); } };
  var mb=document.getElementById('scMenu'), ma=document.getElementById('scMenuArr');
  mb.onclick=function(){ SC.menuOpen=!SC.menuOpen; mb.classList.toggle('on',SC.menuOpen); ma.textContent=SC.menuOpen?'▴':'▾'; if(SC.menuOpen) scRenderMenu(); else scRenderShortcuts(); };
  document.getElementById('scApply').onclick=function(){ SC.N=parseInt(document.getElementById('scN').value||'40')||40; SC.sort=document.getElementById('scSort').value; };
})();
</script></body></html>"""


LOGIN_PAGE = """<!doctype html><html><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1"><title>Alliance Mesh HUB — Sign in</title>
<style>
:root{--bg:#0b1220;--card:#141d2e;--bd:#243149;--fg:#e6ebf5;--mut:#8a97ad;--accent:#3b6ef5;--accent2:#4f7cff}
*{box-sizing:border-box}
body{margin:0;min-height:100vh;display:flex;align-items:center;justify-content:center;background:var(--bg);
  font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;color:var(--fg)}
.card{width:320px;background:var(--card);border:1px solid var(--bd);border-radius:14px;padding:26px 26px 28px;
  box-shadow:0 24px 70px rgba(0,0,0,.45)}
label{display:block;font-size:13px;color:var(--mut);margin:16px 0 6px}
label:first-of-type{margin-top:0}
input{width:100%;padding:10px 12px;border-radius:8px;border:1px solid var(--bd);background:#0e1626;color:var(--fg);
  font-size:14px;outline:none;transition:border-color .12s,box-shadow .12s}
input:focus{border-color:var(--accent2);box-shadow:0 0 0 3px rgba(79,124,255,.22)}
button{width:100%;margin-top:22px;padding:11px;border:0;border-radius:8px;background:var(--accent);color:#fff;
  font-size:15px;font-weight:700;cursor:pointer;transition:background .12s}
button:hover{background:var(--accent2)}
.err{color:#ff6b6b;font-size:13px;margin-top:12px;min-height:16px;text-align:center}
</style></head><body>
<form class="card" id="f" onsubmit="return go(event)">
  <label for="u">Username</label><input id="u" autocomplete="username" autofocus>
  <label for="p">Password</label><input id="p" type="password" autocomplete="current-password">
  <button type="submit">Sign in</button>
  <div class="err" id="e"></div>
</form>
<script>
async function go(ev){ev.preventDefault();var e=document.getElementById('e');e.textContent='';
  try{var r=await fetch('/login',{method:'POST',headers:{'Content-Type':'application/json'},
    body:JSON.stringify({user:document.getElementById('u').value,pass:document.getElementById('p').value})});
    if(r.ok){location.href='/';return false;}
    e.textContent='Invalid username or password';
  }catch(err){e.textContent='Connection error';}
  return false;}
</script></body></html>"""


class Handler(BaseHTTPRequestHandler):
    protocol_version = "HTTP/1.1"

    def log_message(self, *a):   # silenciar el log por-request (ruido); dejamos los prints nuestros
        pass

    def _authed(self):
        """True si el login está desactivado (sin envs) o la cookie de sesión es válida.
        Login por FORMULARIO (no Basic Auth): el token se emite en /login y se guarda en la
        cookie mesh_session. Los nodos (/hb, /objs, /health) nunca pasan por aquí."""
        if not (HUB_USER and HUB_PASS):
            return True
        try:
            c = SimpleCookie(self.headers.get("Cookie", ""))
            tok = c["mesh_session"].value if "mesh_session" in c else ""
            return bool(tok) and _HUB_TOKEN and secrets.compare_digest(tok, _HUB_TOKEN)
        except Exception:
            return False

    def _handle_login(self, payload):
        """POST /login {user,pass} -> valida y emite la cookie de sesión (token determinista) o 401."""
        if not (HUB_USER and HUB_PASS):
            return self._send(200, json.dumps({"ok": True}))   # login desactivado
        u = str((payload or {}).get("user", "")); p = str((payload or {}).get("pass", ""))
        if secrets.compare_digest(u, HUB_USER) and secrets.compare_digest(p, HUB_PASS):
            body = b'{"ok":true}'
            self.send_response(200)
            self.send_header("Set-Cookie",
                             "mesh_session=%s; HttpOnly; Path=/; SameSite=Lax; Max-Age=604800" % _HUB_TOKEN)
            self.send_header("Content-Type", "application/json; charset=utf-8")
            self.send_header("Content-Length", str(len(body)))
            self.send_header("Cache-Control", "no-store")
            self.end_headers()
            try:
                self.wfile.write(body)
            except (BrokenPipeError, ConnectionResetError):
                pass
            return
        return self._send(401, json.dumps({"ok": False, "error": "bad_credentials"}))

    def _logout(self):
        # token determinista -> logout = limpiar la cookie de ESTE navegador (no invalida el token global)
        self.send_response(302)
        self.send_header("Location", "/")
        self.send_header("Set-Cookie", "mesh_session=; HttpOnly; Path=/; Max-Age=0")
        self.send_header("Content-Length", "0")
        self.end_headers()

    def _proxy(self, raw_body=None):
        """Fase 6 — reenvía /px/<port>/<api...?query> al nodo (127.0.0.1:<port>) con la sesión de
        nodo, y devuelve su respuesta. Gated por el login del HUB + whitelist de endpoints Shortcuts.
        raw_body != None -> POST. Reutiliza TODA la lógica ya validada del nodo (familias, attacks,
        bubble, whisper enrutado) sobre su vista global de la malla."""
        if not self._authed():
            return self._send(401, json.dumps({"error": "auth_required"}))
        rest = self.path[len("/px/"):]                 # "<port>/api/...?..."
        slash = rest.find("/")
        if slash < 0:
            return self._send(400, json.dumps({"error": "bad_proxy_path"}))
        try:
            port = int(rest[:slash])
        except ValueError:
            return self._send(400, json.dumps({"error": "bad_port"}))
        sub = rest[slash:]                             # "/api/...?..."
        if sub.split("?", 1)[0] not in PROXY_WHITELIST:
            return self._send(403, json.dumps({"error": "endpoint_not_allowed"}))
        with _LOCK:
            known = {int(n.get("port") or 0) for n in NODES.values() if n.get("port")}
        if port not in known:
            return self._send(400, json.dumps({"error": "unknown_node_port"}))
        try:
            headers = {}
            if _NODE_TOKEN:
                headers["Cookie"] = "botsession=%s" % _NODE_TOKEN
            if raw_body is not None:
                headers["Content-Type"] = "application/json"
                req = urllib.request.Request("http://127.0.0.1:%d%s" % (port, sub),
                                             data=raw_body, headers=headers, method="POST")
            else:
                req = urllib.request.Request("http://127.0.0.1:%d%s" % (port, sub),
                                             headers=headers, method="GET")
            with urllib.request.urlopen(req, timeout=90) as r:
                data = r.read(); ct = r.headers.get("Content-Type", "application/json")
            return self._send(200, data, ct)
        except Exception as e:
            return self._send(502, json.dumps({"error": "proxy_failed", "detail": str(e)[:120]}))

    def _send(self, code, body, ctype="application/json; charset=utf-8"):
        if isinstance(body, str):
            body = body.encode("utf-8")
        self.send_response(code)
        self.send_header("Content-Type", ctype)
        self.send_header("Content-Length", str(len(body)))
        self.send_header("Cache-Control", "no-store")
        self.end_headers()
        try:
            self.wfile.write(body)
        except (BrokenPipeError, ConnectionResetError):
            pass

    def do_GET(self):
        path = self.path.split("?", 1)[0]
        if path == "/logout":
            return self._logout()
        if path.startswith("/px/"):
            return self._proxy()
        if path == "/sc_sel":
            if not self._authed():
                return self._send(401, json.dumps({"error": "auth_required"}))
            with _SC_SEL_LOCK:
                return self._send(200, json.dumps({"sel": sorted(SC_SEL), "whisper_to": SC_WHISPER}))
        if path == "/settings":
            if not self._authed():
                return self._send(401, json.dumps({"error": "auth_required"}))
            with _LOCK:
                nodes = sorted([{"name": n.get("name", nid), "port": int(n.get("port") or 0)}
                                for nid, n in NODES.items() if n.get("port")], key=lambda x: x["name"].lower())
                rexp = _rex_port_locked()
            return self._send(200, json.dumps({"settings": HUB_SETTINGS, "nodes": nodes, "rex_port": rexp}))
        if path == "/" or path == "/index.html":
            # sin sesión -> página de LOGIN (no el dashboard); con sesión -> dashboard.
            if not self._authed():
                return self._send(200, LOGIN_PAGE, "text/html; charset=utf-8")
            return self._send(200, DASHBOARD, "text/html; charset=utf-8")
        # datos del dashboard: requieren sesión (los nodos usan /hb /objs /health, libres)
        if path in ("/fleet", "/balance") and not self._authed():
            return self._send(401, json.dumps({"error": "auth_required"}))
        if path == "/fleet":
            return self._send(200, json.dumps(_fleet_snapshot()))
        if path == "/health":
            return self._send(200, json.dumps({"ok": True, "uptime": round(time.time() - _STARTED, 0)}))
        if path == "/balance":
            # Fase 5: conmutar modo de reparto en caliente. ?mode=weighted|count (sin arg = consultar).
            global BALANCE_MODE
            qs = parse_qs(urlparse(self.path).query)
            m = (qs.get("mode") or [None])[0]
            if m in ("weighted", "count"):
                with _LOCK:
                    BALANCE_MODE = m
                    _BAL.update(assign=None, key=None, ts=0.0)   # fuerza recálculo limpio al cambiar de modo
                print(f"[hub] BALANCE_MODE -> {m}", flush=True)
            return self._send(200, json.dumps({"ok": True, "mode": BALANCE_MODE, "buckets": MESH_BUCKETS}))
        if path == "/objs":
            # MESH Fase 3: pull delta. ?since=<ts>&node=<id> -> monstruos con ts>since que NO
            # aportó ese nodo (los suyos ya los tiene en local).
            qs = parse_qs(urlparse(self.path).query)
            try:
                since = float((qs.get("since") or ["0"])[0])
            except ValueError:
                since = 0.0
            node = (qs.get("node") or [None])[0]
            rows = _objs_since(since, exclude_node=node)
            return self._send(200, json.dumps({"now": time.time(), "rows": rows, "n": len(rows)}))
        return self._send(404, json.dumps({"error": "not found"}))

    def do_POST(self):
        path = self.path.split("?", 1)[0]
        if path.startswith("/px/"):
            try:
                length = int(self.headers.get("Content-Length") or 0)
            except ValueError:
                length = 0
            raw = self.rfile.read(length) if 0 < length <= MAX_BODY else b""
            return self._proxy(raw)
        if path not in ("/hb", "/objs", "/login", "/sc_sel", "/settings", "/hard_reset"):
            return self._send(404, json.dumps({"error": "not found"}))
        cap = MAX_BODY_OBJS if path == "/objs" else MAX_BODY
        try:
            length = int(self.headers.get("Content-Length") or 0)
        except ValueError:
            return self._send(400, json.dumps({"error": "bad content-length"}))
        if length <= 0 or length > cap:
            return self._send(400, json.dumps({"error": "empty or oversized body"}))
        try:
            raw = self.rfile.read(length)
            payload = json.loads(raw.decode("utf-8"))
            if not isinstance(payload, dict):
                raise ValueError("payload no es un objeto JSON")
        except Exception as e:
            return self._send(400, json.dumps({"error": f"bad json: {e}"}))
        if path == "/login":
            return self._handle_login(payload)
        if path == "/sc_sel":
            if not self._authed():
                return self._send(401, json.dumps({"error": "auth_required"}))
            global SC_WHISPER
            with _SC_SEL_LOCK:
                if "toggle" in payload:
                    k = str(payload.get("toggle") or "")
                    if k:
                        SC_SEL.discard(k) if k in SC_SEL else SC_SEL.add(k)
                elif isinstance(payload.get("sel"), list):
                    SC_SEL.clear(); SC_SEL.update(str(x) for x in payload["sel"])
                if "whisper_to" in payload:
                    SC_WHISPER = str(payload.get("whisper_to") or "")
                _save_sc_sel()
                return self._send(200, json.dumps({"ok": True, "sel": sorted(SC_SEL), "whisper_to": SC_WHISPER}))
        if path == "/settings":
            if not self._authed():
                return self._send(401, json.dumps({"error": "auth_required"}))
            if "bubble_whisper_from" in payload:
                try: HUB_SETTINGS["bubble_whisper_from"] = int(payload.get("bubble_whisper_from") or 0)
                except Exception: pass
            if "bubble_alert_uids" in payload:
                HUB_SETTINGS["bubble_alert_uids"] = [int(x) for x in (payload.get("bubble_alert_uids") or []) if str(x).lstrip('-').isdigit()]
            _save_hub_settings()
            return self._send(200, json.dumps({"ok": True, "settings": HUB_SETTINGS}))
        if path == "/hard_reset":
            if not self._authed():
                return self._send(401, json.dumps({"error": "auth_required"}))
            nid = str(payload.get("node") or "").strip()
            force = bool(payload.get("force"))
            if not nid:
                return self._send(400, json.dumps({"ok": False, "error": "node_required"}))
            now = time.time()
            with _LOCK:
                n = NODES.get(nid)
                dev = (n or {}).get("device", "") or ""
                acc = (n or {}).get("account") or {}
                last_hb = (n or {}).get("last_hb") or now
            if not n:
                return self._send(404, json.dumps({"ok": False, "error": "unknown_node"}))
            if not dev:
                return self._send(400, json.dumps({"ok": False, "error": "no_device",
                    "msg": "Node has no device to reset."}))
            # hard_reset.sh ramifica por tipo: FÍSICO (adb reboot) vs EMULADOR (emu kill + full_restart.sh cold-boot).
            # GUARD de burbuja: el reset tarda unos minutos -> rechazar si la burbuja cae dentro de RESET_BUBBLE_MIN (salvo force)
            shl = acc.get("peace_shield_left")
            if shl is not None and not force:
                eta = int(shl) - (now - last_hb)
                if eta < RESET_BUBBLE_MIN:
                    return self._send(409, json.dumps({"ok": False, "error": "bubble_low", "eta": int(max(0, eta)),
                        "msg": "%s's bubble drops in ~%dmin (< %dmin). A hard reset (~5min downtime) would let it fall. Renew the bubble first, or confirm with force." % (
                            (n.get("name") or nid), int(max(0, eta) / 60), int(RESET_BUBBLE_MIN / 60))}))
            # RESERVA ATÓMICA del guard: check-then-set bajo lock. El server es threading -> sin esto un doble clic
            # (dos POST casi simultáneos) cuela 2 runs porque ambos pasan el check antes de que ninguno marque INFLIGHT.
            with HARD_RESET_LOCK:
                if _hard_reset_active(nid, now):
                    return self._send(409, json.dumps({"ok": False, "error": "already_running"}))
                HARD_RESET_INFLIGHT[nid] = now   # reservar ANTES de spawnear (cierra la ventana de carrera)
            # lanzar hard_reset.sh en background (Mac-side; sobrevive al stop del nodo)
            try:
                os.makedirs(HARD_RESET_LOGDIR, exist_ok=True)
                logf = os.path.join(HARD_RESET_LOGDIR, "hard_reset_%s.log" % nid)
                lf = open(logf, "w")
                subprocess.Popen(["bash", HARD_RESET_SH, nid], stdout=lf, stderr=subprocess.STDOUT,
                                 cwd=MESH_SCRIPTS, start_new_session=True)
            except Exception as e:
                HARD_RESET_INFLIGHT.pop(nid, None)   # rollback de la reserva si el spawn falló
                return self._send(500, json.dumps({"ok": False, "error": "spawn_failed: %s" % e}))
            print(f"[hub] HARD RESET started: {nid} ({dev}) force={force} -> {logf}", flush=True)
            return self._send(200, json.dumps({"ok": True, "started": nid, "device": dev, "log": logf,
                "msg": "Hard reset lanzado (~5min): reboot + frida + keyguard + Evony + reattach."}))
        try:
            if path == "/hb":
                resp = _ingest_hb(payload, self.client_address[0])
            else:   # /objs — MESH Fase 3: push de monstruos escaneados por un nodo
                nid = payload.get("node_id")
                if not isinstance(nid, str) or not nid.strip():
                    raise ValueError("node_id ausente")
                applied = _objs_ingest(nid.strip(), payload.get("rows") or [])
                resp = {"ok": True, "applied": applied}
        except ValueError as e:
            return self._send(422, json.dumps({"error": str(e)}))
        except Exception as e:
            print(f"[hub] ingest err: {e}", flush=True)
            return self._send(500, json.dumps({"error": "internal"}))
        return self._send(200, json.dumps(resp))


def main():
    threading.Thread(target=_liveness_loop, daemon=True, name="liveness").start()
    srv = ThreadingHTTPServer((HUB_BIND, HUB_PORT), Handler)
    print(f"[hub] Evony Mesh hub en http://{HUB_BIND}:{HUB_PORT}  "
          f"(sano <{HEALTHY_MAX}s, caído ≥{DOWN_AFTER}s)", flush=True)
    try:
        srv.serve_forever()
    except KeyboardInterrupt:
        print("\n[hub] parando…", flush=True)
        srv.shutdown()


if __name__ == "__main__":
    sys.exit(main())
