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

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

HERE  = os.path.dirname(os.path.abspath(__file__))
AGENT = os.path.join(HERE, "agent.js")
PORT  = 8770

LOCK   = threading.Lock()
CFG    = {}                 # id -> {name, level, type, power}
OBJS   = {}                 # (wx,wy) -> {t,id,lv,wx,wy,ts}
PLAYERS = {}                # uid -> {uid,name,lv,clv,wx,wy,gid,tag,shield,power,ts}
MARCHES = {}                # troop_id (int) -> {mty,ow,sx,sy,tx,ty,ts,te,tg,ts_recv}
MARCH_BY_TGT = {}           # (tx,ty) -> {troop_id: True}
STATE  = {"sweep": "?", "cfg_n": 0, "sweep_num": 0, "last_pass_secs": None}
FIRST_PASS_TS = None       # ts en que termino la 1a vuelta; antes de eso = sin resaltado
LAST_PASS_TS  = None       # ts de la ultima vuelta completa (para medir su duracion)
FRESH_WINDOW  = 120        # 2 min: tras esto el "nuevo" deja de estar resaltado
TTL_SECONDS   = 600        # ciclo iScout (~10 min): limpieza de memoria de objetos viejos
STALE_SECONDS = 90         # una vuelta del barrido es ~24s. Si un objeto no se re-ve
                           # en ~3-4 vueltas se considera muerto -> se quita de la lista
PRUNE_EVERY   = 20         # cada N seg el hilo de limpieza purga muertos
RESPAWN_GAP_S = 250        # si un tile no se ve >= esto y reaparece => spawn NUEVO
                           # (el boss murio, tile vacio entre pasadas, ahora otro).
                           # Ajustado a la cadencia de v1: 1 escaner a mapa
                           # completo ~110s/vuelta -> ~2 vueltas (en v2 dual son
                           # ~30s/media-vuelta y el gap es 80).

# ---------------- Frida backend ----------------
def on_message(msg, data):
    if msg.get("type") == "error":
        print("[agent error]", msg.get("stack") or msg.get("description"), flush=True)
        return
    if msg.get("type") != "send":
        return
    p = msg["payload"]
    if not isinstance(p, dict):
        return
    if p.get("kind") == "config":
        with LOCK:
            for k, v in p["cfg"].items():
                CFG[str(k)] = v
            STATE["cfg_n"] = len(CFG)
        print(f"[cfg] {p['n']} configs", flush=True)
    elif p.get("kind") == "batch":
        now = time.time()
        with LOCK:
            for it in p["items"]:
                k = (it["wx"], it["wy"])
                old = OBJS.get(k)
                if old is None:
                    # tile nunca visto -> descubrimiento nuevo
                    it["ts"] = now
                    it["first_ts"] = now
                    OBJS[k] = it
                else:
                    # spawn NUEVO en tile recurrente si: cambio tipo/nivel, o el
                    # tile estuvo sin verse >= RESPAWN_GAP_S (vacio entre pasadas
                    # = el boss anterior murio y aparecio otro).
                    respawn = (old.get("id") != it["id"]
                               or old.get("lv") != it["lv"]
                               or (now - old.get("ts", now)) >= RESPAWN_GAP_S)
                    if respawn:
                        it["ts"] = now
                        it["first_ts"] = now      # re-marca como recien descubierto
                        OBJS[k] = it
                    else:
                        # mismo boss: refresca datos y "ultimo visto", conserva first_ts
                        old.update(it)
                        old["ts"] = now
                        old.setdefault("first_ts", old.get("ts", now))
    elif p.get("kind") == "players":
        now = time.time()
        with LOCK:
            for it in p["items"]:
                it["ts"] = now
                PLAYERS[it["uid"]] = it
    elif p.get("kind") == "marches":
        now = time.time()
        with LOCK:
            for it in p["items"]:
                trp = int(it.get("trp", 0))
                if trp == 0: continue
                it["ts_recv"] = now
                old = MARCHES.get(trp)
                if old is not None:
                    okey = (int(old.get("tx", 0)), int(old.get("ty", 0)))
                    sub = MARCH_BY_TGT.get(okey)
                    if sub is not None:
                        sub.pop(trp, None)
                        if not sub: MARCH_BY_TGT.pop(okey, None)
                MARCHES[trp] = it
                key = (int(it["tx"]), int(it["ty"]))
                MARCH_BY_TGT.setdefault(key, {})[trp] = True
    elif p.get("kind") == "sweep_done":
        STATE["sweep"] = "done"
    elif p.get("kind") == "sweep_pass":
        global FIRST_PASS_TS, LAST_PASS_TS
        now = time.time()
        if FIRST_PASS_TS is None:
            FIRST_PASS_TS = now           # 1a vuelta completada
            print("[sweep] 1a vuelta completa; nuevos a partir de ahora se resaltan", flush=True)
        if LAST_PASS_TS is not None:
            STATE["last_pass_secs"] = round(now - LAST_PASS_TS, 1)
        LAST_PASS_TS = now
        STATE["sweep_num"] = p["pass"]
        STATE["sweep"] = f"pasada {p['pass']}"

def frida_thread():
    with open(AGENT) as f:
        src = f.read()
    attempt = 0
    while True:
        attempt += 1
        try:
            dev = frida.get_device("emulator-5554")
            pid = next((pp.pid for pp in dev.enumerate_processes() if pp.name == "Evony"), None)
            if pid is None:
                print("Evony no corre en el emulador (reintento en 5s)", flush=True)
                time.sleep(5); continue
            session = dev.attach(pid)
            script = session.create_script(src)
            script.on("message", on_message)
            script.load()
            global LAST_PASS_TS
            LAST_PASS_TS = time.time()        # base para medir la 1a vuelta
            STATE["sweep"] = "running"
            STATE["sweep_num"] = 0
            STATE["last_pass_secs"] = None
            print(f"[frida] backend activo (intento {attempt})", flush=True)
            while True:
                time.sleep(1)
        except frida.TransportError as e:
            print(f"[frida] TransportError ({e}); reintentando en 4s...", flush=True)
            STATE["sweep"] = "reconectando"
            time.sleep(4)
        except Exception as e:
            print(f"[frida] error: {e}; reintentando en 5s...", flush=True)
            STATE["sweep"] = "reconectando"
            time.sleep(5)

# ---------------- API helpers ----------------
def enrich(o):
    c = CFG.get(str(o["id"])) or {}
    now = time.time()
    ft = o.get("first_ts", o.get("ts", now))
    # resaltado SOLO si: ya termino la 1a vuelta, el objeto se descubrio DESPUES
    # de esa 1a vuelta, y lleva <=5 min desde su primera deteccion.
    fresh = (FIRST_PASS_TS is not None
             and ft > FIRST_PASS_TS
             and (now - ft) <= FRESH_WINDOW)
    # ataque/rally REAL via hook ServerMapLayer.addTargetObj -> MsgDown.map_target_info
    # Solo marches de ATAQUE a monstruo: 2=monster solo, 19/20/21=boss_war_*,
    # 43=scout_monster. Otros mty (reap_resource=7, cargo=49, etc.) son ruido.
    atk = ""; atk_uid = 0; atk_guild = 0; atk_eta = 0; atk_mty = 0
    atk_name = ""; atk_tag = ""; atk_count = 0
    ATTACK_MTYS = {2, 19, 20, 21, 43}
    ALLY_MTYS   = {19, 20, 21}
    key = (int(o["wx"]), int(o["wy"]))
    subs = MARCH_BY_TGT.get(key)
    if subs:
        rel = []
        for trp in list(subs.keys()):
            m = MARCHES.get(trp)
            if not m: continue
            if int(m.get("mty", 0)) not in ATTACK_MTYS: continue
            if int(m.get("te", 0)) <= now: continue
            rel.append(m)
        if rel:
            ally = [m for m in rel if int(m.get("mty", 0)) in ALLY_MTYS]
            if ally:
                by = {}
                for m in ally: by.setdefault(int(m["mty"]), []).append(m)
                if   21 in by: rep = min(by[21], key=lambda m: int(m["te"]))
                elif 20 in by: rep = min(by[20], key=lambda m: int(m["te"]))
                else:          rep = min(by[19], key=lambda m: int(m["te"]))
                atk = "rally"; atk_count = len(ally)
            else:
                rep = max(rel, key=lambda m: int(m["te"]))
                atk = "rally"; atk_count = 1
            atk_uid = int(rep.get("ow", 0))
            atk_mty = int(rep.get("mty", 0))
            atk_eta = max(0, int(rep.get("te", 0) - now))
            pl = PLAYERS.get(str(atk_uid)) or PLAYERS.get(atk_uid)
            if pl:
                atk_guild = int(pl.get("gid", 0) or 0)
                atk_name  = pl.get("name", "") or ""
                atk_tag   = pl.get("tag", "") or ""
    # legado: occupy_id solo para mapinfo_type 3/4/5/7 (farm/garrison/ruins/subcity)
    oid = int(o.get("oid", 0) or 0)
    ogd = int(o.get("ogd", 0) or 0)
    st  = int(o.get("st",  0) or 0)
    hp  = int(o.get("hp",  0) or 0)
    hpt = int(o.get("hpt", 0) or 0)
    if not atk_uid and oid > 0 and o["t"] in (3, 4, 5, 7):
        atk = "owned"; atk_uid = oid; atk_guild = ogd
        pl = PLAYERS.get(str(oid)) or PLAYERS.get(oid)
        if pl:
            atk_name = pl.get("name", "") or ""
            atk_tag  = pl.get("tag", "") or ""
    return {
        "name":  c.get("name", f"id{o['id']}"),
        "level": o["lv"],
        "type":  o["t"],
        "power": c.get("power", 0),
        "x": o["wx"], "y": o["wy"], "id": o["id"],
        "group": group_of(c.get("name", "")),
        "age": int(now - ft),
        "seen_age": int(now - o.get("ts", now)),
        "fresh": fresh,
        "atk": atk, "atk_uid": atk_uid, "atk_guild": atk_guild,
        "atk_eta": atk_eta, "atk_mty": atk_mty,
        "atk_name": atk_name, "atk_tag": atk_tag, "atk_count": atk_count,
        "atk_hp": hp, "atk_hpt": hpt, "atk_st": st,
    }

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

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

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

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

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

def families():
    """[{name, variants, live}] — variants=tiers en config, live=cuantos en el mapa ahora."""
    cat = catalog()
    with LOCK:
        objs = list(OBJS.values())
    live = {}
    for o in objs:
        c = CFG.get(str(o["id"])) or {}
        nm = c.get("name", "")
        if not nm:
            continue
        for disp, kws, grp in FAMILIES:
            if fam_match(nm, kws):
                live[disp] = live.get(disp, 0) + 1
    out = []
    for disp, kws, grp in FAMILIES:
        v = sum(1 for x in cat if fam_match(x["name"], kws))
        if v:
            out.append({"name": disp, "variants": v, "live": live.get(disp, 0), "group": grp})
    return out

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

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

def query(params):
    ids = set()
    for s in params.get("ids", "").split(","):
        s = s.strip()
        if s.isdigit():
            ids.add(int(s))
    fams = [f for f in params.get("fams", "").split("||") if f]
    if fams:
        ids |= fam_ids(set(fams))
    try: cx = float(params.get("cx", "")) if params.get("cx") else None
    except: cx = None
    try: cy = float(params.get("cy", "")) if params.get("cy") else None
    except: cy = None
    try: rad = float(params.get("radius", "")) if params.get("radius") else None
    except: rad = None
    only_type = params.get("type", "")        # "" o "2" etc
    sort = params.get("sort", "newest")
    # query() devuelve la lista completa; el paginado se hace en /api/data.
    # max_seen: objetos vistos por ultima vez hace <= N seg.
    # Default = STALE_SECONDS: solo lo que sigue existiendo en el mapa ahora mismo
    # (lo que ya no se re-detecta en ~3-4 vueltas desaparece de la lista).
    try: max_seen = float(params["max_seen"]) if params.get("max_seen") else STALE_SECONDS
    except: max_seen = STALE_SECONDS

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

    if sort == "level":
        out.sort(key=lambda r: (-r["level"], r["name"]))
    elif sort == "power":
        out.sort(key=lambda r: -r["power"])
    elif sort == "dist" and cx is not None:
        out.sort(key=lambda r: (r["dist"] if r["dist"] is not None else 9e9))
    elif sort == "newest":
        out.sort(key=lambda r: r["age"])
    return out

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

def query_players(params):
    now = time.time()
    def num(k):
        v = params.get(k, "")
        try: return float(v) if v not in ("", None) else None
        except: return None
    lv_min = num("lv_min"); lv_max = num("lv_max")
    pw_min = num("pw_min"); pw_max = num("pw_max")   # en M (millones)
    shield = params.get("shield", "")                # "", "yes", "no"
    tag = params.get("tag", "").strip().lower()
    cx, cy, rad = num("cx"), num("cy"), num("radius")
    sort = params.get("sort", "power")
    limit = int(params.get("limit", "300"))
    max_seen = num("max_seen") or TTL_SECONDS
    with LOCK:
        ps = list(PLAYERS.values())
    out = []
    for p in ps:
        if now - p.get("ts", 0) > max_seen:
            continue
        lv = p.get("lv", 0); pw = p.get("power", 0)
        pwM = pw / 1e6
        has_shield = bool(p.get("shield", 0))
        if lv_min is not None and lv < lv_min: continue
        if lv_max is not None and lv > lv_max: continue
        if pw_min is not None and pwM < pw_min: continue
        if pw_max is not None and pwM > pw_max: continue
        if shield == "yes" and not has_shield: continue
        if shield == "no" and has_shield: continue
        if tag and tag not in (p.get("tag", "") or "").lower(): continue
        d = None
        if cx is not None and cy is not None and rad is not None:
            d = math.hypot(p["wx"] - cx, p["wy"] - cy)
            if d > rad: continue
            d = round(d)
        out.append({
            "name": p.get("name", ""), "level": lv, "castle": p.get("clv", 0),
            "power": pw, "tag": p.get("tag", ""),
            "shield": int(p.get("shield", 0) or 0),     # tier code
            "x": p["wx"], "y": p["wy"], "uid": p["uid"], "dist": d,
            "age": int(now - p.get("ts", now)),
        })
    if sort == "power":   out.sort(key=lambda r: -r["power"])
    elif sort == "level": out.sort(key=lambda r: (-r["level"], -r["power"]))
    elif sort == "dist":  out.sort(key=lambda r: (r["dist"] if r["dist"] is not None else 9e9))
    elif sort == "newest":out.sort(key=lambda r: r["age"])
    return out[:limit]

# ---------------- HTTP ----------------
HTML = """<!doctype html><html><head><meta charset=utf-8>
<title>Evony Scout</title><style>
body{font-family:-apple-system,system-ui,sans-serif;margin:0;background:#0f1115;color:#e6e6e6}
header{padding:14px 18px;background:#161a22;border-bottom:1px solid #262b36;display:flex;gap:18px;align-items:center;flex-wrap:wrap}
h1{font-size:16px;margin:0;color:#7dd3fc}
.box{padding:12px 18px;display:flex;gap:22px;flex-wrap:wrap;align-items:flex-end;background:#12151c;border-bottom:1px solid #262b36}
label{font-size:11px;color:#9aa4b2;display:block;margin-bottom:4px}
input,select{background:#0f1115;border:1px solid #2c3340;color:#e6e6e6;padding:6px 8px;border-radius:6px;font-size:13px}
input[type=number]{width:80px}
button{background:#2563eb;color:#fff;border:0;padding:8px 16px;border-radius:6px;cursor:pointer;font-size:13px}
button.sec{background:#374151}
table{width:100%;border-collapse:collapse;font-size:13px}
th,td{text-align:left;padding:8px 14px;border-bottom:1px solid #1f2530}
th{color:#9aa4b2;font-weight:600;position:sticky;top:0;background:#12151c}
tr:hover{background:#171b24}
.lvl{color:#fbbf24;font-weight:600}
.cp{cursor:pointer;color:#60a5fa;font-size:12px}
.stat{font-size:12px;color:#9aa4b2}
.tag{background:#1e293b;color:#93c5fd;padding:1px 6px;border-radius:4px;font-size:11px}
.grp{padding:1px 8px;border-radius:4px;font-size:11px;font-weight:600}
.g-boss{background:#7f1d1d;color:#fecaca}
.g-event{background:#1e3a8a;color:#bfdbfe}
.g-normal{background:#1f2937;color:#9aa4b2}
.atk{padding:1px 8px;border-radius:4px;font-size:11px;font-weight:600;cursor:help}
.atk-rally{background:#7f1d1d;color:#fecaca}
.atk-damaged{background:#78350f;color:#fde68a}
.atk-owned{background:#1f2937;color:#93c5fd;font-weight:500}
.atktoggle{display:flex;gap:12px;align-items:center;padding:10px 18px;background:#12151c;border-top:1px solid #262b36}
.atkLbl{font-size:13px;color:#fca5a5;font-weight:500}
.ios-switch{position:relative;display:inline-block;width:42px;height:24px;flex:0 0 auto}
.ios-switch input{opacity:0;width:0;height:0}
.ios-switch .slider{position:absolute;cursor:pointer;inset:0;background:#374151;border-radius:24px;transition:.2s}
.ios-switch .slider:before{position:absolute;content:"";width:18px;height:18px;left:3px;top:3px;background:#fff;border-radius:50%;transition:.2s;box-shadow:0 1px 2px rgba(0,0,0,0.3)}
.ios-switch input:checked + .slider{background:#dc2626}
.ios-switch input:checked + .slider:before{transform:translateX(18px)}
#atkpanel{background:#0d0f15;border-bottom:2px solid #7f1d1d;border-top:1px solid #262b36;padding:0}
.atkhdr{display:flex;align-items:center;gap:14px;padding:10px 18px;background:#12151c;border-bottom:1px solid #262b36}
.atktitle{font-size:14px;color:#fca5a5;font-weight:600}
.atktbl{width:100%;border-collapse:collapse;font-size:12px}
.atktbl th{background:#161a22;color:#9aa4b2;font-weight:600;padding:6px 12px;text-align:left;border-bottom:1px solid #1f2530;font-size:11px}
.atktbl td{padding:6px 12px;border-bottom:1px solid #1a1f29}
.atktbl tr:hover td{background:#161a22}
.ph{padding:1px 7px;border-radius:4px;font-size:10px;font-weight:700;letter-spacing:.3px}
.ph-wait{background:#78350f;color:#fde68a}
.ph-way{background:#9a3412;color:#fed7aa}
.ph-combat{background:#7f1d1d;color:#fecaca}
.ph-march,.ph-scout{background:#1f2937;color:#93c5fd}
.eta-val{font-family:ui-monospace,SFMono-Regular,monospace;font-weight:700;font-size:13px}
.eta-soon{color:#fca5a5}
tr.eta-imminent td{animation:atkblink 0.6s ease-in-out infinite}
tr.eta-imminent .eta-val{color:#fff;background:#7f1d1d;padding:2px 6px;border-radius:4px}
/* multi-select chips */
.ms{position:relative;min-width:340px}
.msbox{display:flex;flex-wrap:wrap;gap:5px;align-items:center;background:#0f1115;border:1px solid #2c3340;border-radius:6px;padding:5px 6px;min-height:34px;cursor:text}
.chip{background:#312e81;color:#c7d2fe;border-radius:5px;padding:2px 6px;font-size:12px;display:flex;gap:5px;align-items:center}
.chip b{cursor:pointer;color:#a5b4fc}
.msbox input{border:0;background:transparent;flex:1;min-width:80px;padding:3px;color:#e6e6e6;outline:none}
.dd{position:absolute;z-index:9;left:0;right:0;top:100%;margin-top:3px;background:#12151c;border:1px solid #2c3340;border-radius:6px;max-height:280px;overflow:auto;display:none}
.dd.open{display:block}
.opt{padding:7px 10px;font-size:13px;cursor:pointer}
.opt:hover,.opt.act{background:#1e293b}
.opt small{color:#7c8595}
.fams{display:flex;flex-wrap:wrap;gap:6px;padding:10px 18px;background:#12151c;border-bottom:1px solid #262b36}
.fam{background:#1f2937;color:#cbd5e1;border:1px solid #2c3340;border-radius:14px;padding:5px 12px;font-size:12px;cursor:pointer;user-select:none}
.fam:hover{background:#283449}
.fam.on{background:#2563eb;color:#fff;border-color:#2563eb}
.fam.z{opacity:.4}
.famgrp{padding:8px 18px 0;background:#12151c}
.famgrp h3{margin:0 0 6px;font-size:11px;color:#7dd3fc;text-transform:uppercase;letter-spacing:.5px}
.famrow{display:flex;flex-wrap:wrap;gap:6px}
tr.fresh td{background:rgba(255,235,59,0.10)}
tr.atk-active td{background:rgba(220,38,38,0.20); animation:atkblink 1.6s ease-in-out infinite}
@keyframes atkblink{0%,100%{background:rgba(220,38,38,0.20)} 50%{background:rgba(220,38,38,0.06)}}
tr.cfgloading td{padding:40px 18px;text-align:center;color:#fbbf24;font-size:14px;background:rgba(120,53,15,0.15);font-weight:500;line-height:1.6}
tr.cfgloading td .hint{font-size:12px;color:#9aa4b2;font-weight:400}
.sortbar{display:flex;gap:8px;align-items:center;padding:10px 18px;background:#12151c;border-bottom:1px solid #262b36}
.sortbar label{margin:0;font-size:12px;color:#9aa4b2}
.pager{display:flex;gap:10px;align-items:center;justify-content:center;padding:10px 18px;background:#12151c;border-top:1px solid #262b36;font-size:12px;color:#9aa4b2}
.pager button{background:#374151;color:#cbd5e1;border:0;padding:5px 12px;border-radius:5px;cursor:pointer;font-size:12px}
.pager button:disabled{opacity:0.35;cursor:not-allowed}
.pager .info{padding:0 8px}
.limbox{margin-left:auto;display:flex;gap:8px;align-items:center}
.tabs{display:flex;gap:6px;margin-left:auto}
.tab{background:#1f2937;color:#cbd5e1;border:1px solid #2c3340;border-radius:8px 8px 0 0;padding:7px 16px;font-size:13px;cursor:pointer}
.tab.on{background:#2563eb;color:#fff;border-color:#2563eb}
.sh{color:#34d399;font-weight:600}
.nosh{color:#7c8595}
</style></head><body>
<header><h1>Evony Scout</h1><span class=stat id=stat>loading...</span>
<label style="display:inline">auto<input type=checkbox id=auto checked></label>
<div class=tabs><span class="tab on" id=tab_mon onclick="switchTab('mon')">Monsters</span><span class=tab id=tab_pl onclick="switchTab('pl')">Players</span></div></header>

<div id=view_mon>
<div class=famgrp><h3>Boss Monsters</h3><div class=famrow id=fams_boss></div></div>
<div class=famgrp style="padding-bottom:10px"><h3>Event Monsters</h3><div class=famrow id=fams_event></div></div>
<div class=box>
 <div style="flex:1;min-width:340px"><label>Rally bosses (empty = all)</label>
   <div class=ms id=ms>
     <div class=msbox id=msbox><input id=msin placeholder="search boss... (Cerberus, Sphinx, Bayard, Lava Turtle...)" autocomplete=off></div>
     <div class=dd id=dd></div>
   </div></div>
 <div><label>Type</label>
   <select id=type><option value="">All</option><option value="2" selected>Monsters (rally)</option><option value="3">Type 3</option><option value="1">Own castle</option><option value="7">Players</option></select></div>
 <div><label>Center X</label><input type=number id=cx value=603></div>
 <div><label>Center Y</label><input type=number id=cy value=603></div>
 <div><label>Radius (tiles, empty=whole map)</label><input type=number id=rad placeholder="whole map"></div>
 <div><label>Max age (min, empty=active only)</label><input type=number id=maxage placeholder="active" style="width:90px"></div>
 <div><button onclick=load()>Apply</button>
   <button class=sec onclick="SEL.clear();FAM.clear();renderChips();loadFamilies();load()">Clear</button></div>
</div>
<div class=atktoggle>
  <span class=atkLbl>⚔ Show active attacks</span>
  <label class=ios-switch>
    <input type=checkbox id=showatk>
    <span class=slider></span>
  </label>
</div>
<div class=sortbar><label>Sort</label><select id=sort onchange=load()>
   <option value=newest selected>Recent</option><option value=level>Level</option>
   <option value=power>Power</option><option value=dist>Distance</option></select>
 <span class=limbox><label>Limit</label><select id=lim onchange=load()>
   <option>20</option><option>50</option><option>100</option>
   <option>200</option><option>400</option><option selected>500</option>
   <option value="0">No limit</option></select></span></div>
<div id=atkpanel style="display:none">
  <div class=atkhdr>
    <span class=atktitle>⚔ Active attacks</span>
    <span id=atkcount class=stat>—</span>
    <select id=atkfilter onchange=renderAttacks()>
      <option value=all>all</option>
      <option value=alliance>alliance only</option>
      <option value=solo>monarch only</option>
      <option value=boss>Boss/Event only</option>
    </select>
    <span class=stat style="margin-left:auto;font-size:11px">⏱ live countdown</span>
  </div>
  <table class=atktbl>
   <thead><tr><th>Phase</th><th>Attacker</th><th>Target</th><th>Coords</th><th>Origin</th><th>ETA</th></tr></thead>
   <tbody id=atkrows></tbody>
  </table>
</div>
<table><thead><tr><th>Name</th><th>Level</th><th>Power</th><th>Family</th><th>Attack</th><th>X</th><th>Y</th><th>Dist</th><th>Copy</th><th>Discovered</th></tr></thead>
<tbody id=rows></tbody></table>
<div class=pager id=pager style="display:none">
  <button id=pg_first onclick="gotoPage(1)">«</button>
  <button id=pg_prev  onclick="gotoPage(curPage-1)">← prev</button>
  <span class=info id=pginfo>—</span>
  <button id=pg_next  onclick="gotoPage(curPage+1)">next →</button>
  <button id=pg_last  onclick="gotoPage(99999)">»</button>
</div>
</div><!-- /view_mon -->

<div id=view_players style="display:none">
<div class=box>
 <div><label>Level min</label><input type=number id=p_lvmin placeholder=1 style="width:70px"></div>
 <div><label>Level max</label><input type=number id=p_lvmax placeholder=45 style="width:70px"></div>
 <div><label>Power min (M)</label><input type=number id=p_pwmin placeholder=0 style="width:80px"></div>
 <div><label>Power max (M)</label><input type=number id=p_pwmax placeholder="∞" style="width:80px"></div>
 <div><label>Shield</label>
   <select id=p_shield><option value="">All</option><option value="yes">With shield</option><option value="no">No shield</option></select></div>
 <div><label>Alliance (tag)</label><input id=p_tag placeholder="e.g. ABC" style="width:110px"></div>
 <div><label>Center X</label><input type=number id=p_cx value=603></div>
 <div><label>Center Y</label><input type=number id=p_cy value=603></div>
 <div><label>Radius (empty=all)</label><input type=number id=p_rad value=300></div>
 <div><label>Sort</label><select id=p_sort>
   <option value=power>Power</option><option value=level>Level</option>
   <option value=dist>Distance</option><option value=newest>Recent</option></select></div>
 <div><label>Limit</label><input type=number id=p_lim value=500></div>
 <div><label>Max age (min, empty=cycle)</label><input type=number id=p_maxage placeholder=10 style="width:70px"></div>
 <div><button onclick=loadPlayers()>Apply</button>
   <button class=sec onclick="['p_lvmin','p_lvmax','p_pwmin','p_pwmax','p_tag'].forEach(i=>document.getElementById(i).value='');document.getElementById('p_shield').value='';loadPlayers()">Clear</button></div>
</div>
<table><thead><tr><th>Name</th><th>Alliance</th><th>Level</th><th>Castle</th><th>Power</th><th>Bubble</th><th>X</th><th>Y</th><th>Dist</th><th>Copy</th></tr></thead>
<tbody id=prows></tbody></table>
</div><!-- /view_players -->
<script>
function fmtPow(p){p=+p||0;if(p>=1e6)return (p/1e6).toFixed(1)+'M';if(p>=1e3)return (p/1e3).toFixed(0)+'K';return p}
function fmtAgo(s){s=Math.max(0,Math.round(+s||0));
 if(s<60)return s+' sec ago';
 if(s<3600){const m=Math.floor(s/60);return m+' min ago';}
 const h=Math.floor(s/3600),m=Math.floor((s%3600)/60);return h+'h '+m+'m ago';}
function fmtDur(s){if(s==null)return '—';s=+s;
 if(s<60)return s.toFixed(1)+'s';
 const m=Math.floor(s/60),ss=Math.round(s%60);return m+'m '+ss+'s';}
function fmtEta(s){s=Math.max(0,Math.round(+s||0));if(s<=0)return '';
 if(s<60)return s+'s';
 if(s<3600){const m=Math.floor(s/60),ss=s%60;return m+'min '+ss+'s';}
 const h=Math.floor(s/3600),m=Math.floor((s%3600)/60),ss=s%60;
 return h+'h '+m+'min '+ss+'s';}
function fmtAtk(x){if(!x.atk)return '<span class=stat>—</span>';
 const nm=(x.atk_name||'').replace(/"/g,'&quot;');
 const tag=(x.atk_tag||'').replace(/"/g,'&quot;');
 const who = nm ? ((tag?('['+tag+'] '):'')+nm) : ('u'+x.atk_uid);
 const eta = fmtEta(x.atk_eta);
 const n=+x.atk_count||1;
 const tip=`uid=${x.atk_uid} ali=${x.atk_guild} mty=${x.atk_mty} eta=${x.atk_eta}s n=${n} hp=${x.atk_hp} st=${x.atk_st}`;
 if(x.atk==='rally'){
  if(n>1){
    const extra = n-1;
    return `<span class="atk atk-rally" title="${tip}">⚔⚔ ${who} +${extra}${eta?(' · '+eta):''}</span>`;
  }
  return `<span class="atk atk-rally" title="${tip}">⚔ ${who}${eta?(' · '+eta):''}</span>`;
 }
 if(x.atk==='owned') return `<span class="atk atk-owned" title="${tip}">${who}</span>`;
 return `<span class="atk atk-damaged" title="${tip}">DAMAGED</span>`;}
let CAT=[];                       // catalogo [{id,name,level,label,power}]
const SEL=new Map();              // id -> label seleccionado
const FAM=new Set();              // familias rapidas activas
let curPage=1;
function gotoPage(p){curPage=Math.max(1,p|0); load(true);}
let SHOWATK = localStorage.getItem('showatk')==='1';
let ATKS = []; let ATKS_TS = 0;
async function loadAttacks(){
 if(!SHOWATK) return;
 const r=await fetch('/api/attacks?_='+Date.now(),{cache:'no-store'});
 const j=await r.json();
 ATKS = j.attacks||[]; ATKS_TS = Date.now()/1000;
 renderAttacks();
}
function renderAttacks(){
 const panel=document.getElementById('atkpanel');
 if(!SHOWATK){panel.style.display='none'; return;}
 panel.style.display='';
 const filt=document.getElementById('atkfilter').value;
 const delta = Date.now()/1000 - ATKS_TS;
 let list = ATKS.slice();
 if(filt==='alliance') list = list.filter(a=>a.kind==='alliance');
 else if(filt==='solo') list = list.filter(a=>a.kind==='solo');
 else if(filt==='boss') list = list.filter(a=>a.tgroup==='Boss'||a.tgroup==='Event');
 for(const a of list) a._eta = Math.max(0, Math.round(a.eta - delta));
 list.sort((a,b)=>a._eta-b._eta);
 document.getElementById('atkcount').textContent = `${list.length} shown · ${ATKS.length} total`;
 const tb=document.getElementById('atkrows'); tb.innerHTML='';
 for(const a of list){
  const tr=document.createElement('tr');
  const eta=a._eta;
  if(eta<30) tr.className='eta-imminent';
  const ph=a.phase;
  const phLab = ph==='wait'?'WAIT':ph==='way'?'MARCH':ph==='combat'?'COMBAT':ph==='scout'?'SCOUT':'SOLO';
  const who = a.name ? ((a.tag?('['+a.tag+'] '):'')+a.name) : ('u'+a.uid);
  const whoExtra = a.kind==='alliance' && a.count>1 ? ` +${a.count-1}` : '';
  const tgt = a.tname ? `${a.tname}${a.tlevel?(' Lv'+a.tlevel):''}` : `tile(${a.tx},${a.ty})`;
  const etaCls = eta<300?'eta-val eta-soon':'eta-val';
  tr.innerHTML = `
    <td><span class="ph ph-${ph}">${phLab}</span></td>
    <td>${who}${whoExtra}</td>
    <td>${tgt}</td>
    <td><span class=cp onclick="navigator.clipboard.writeText('${a.tx},${a.ty}')">${a.tx},${a.ty} &#8682;</span></td>
    <td><span class=stat style="font-size:11px">(${a.sx},${a.sy})</span></td>
    <td><span class="${etaCls}">${fmtEta(eta)||'0s'}</span></td>`;
  tb.appendChild(tr);
 }
}
function toggleAtk(on){
 SHOWATK = !!on;
 localStorage.setItem('showatk', SHOWATK?'1':'0');
 if(SHOWATK){loadAttacks();} else {document.getElementById('atkpanel').style.display='none';}
}
setInterval(()=>{ if(SHOWATK) renderAttacks(); }, 1000);
async function loadFamilies(){
 try{const r=await fetch('/api/families?_='+Date.now(),{cache:'no-store'});const j=await r.json();
  const cb=document.getElementById('fams_boss'),ce=document.getElementById('fams_event');
  cb.innerHTML='';ce.innerHTML='';
  for(const f of j){const b=document.createElement('span');
   b.className='fam'+(FAM.has(f.name)?' on':'')+(f.live?'':' z');
   b.textContent=f.name+' ('+f.live+')';
   b.title=f.variants+' tipos en config · '+f.live+' en el mapa ahora';
   b.onclick=()=>{FAM.has(f.name)?FAM.delete(f.name):FAM.add(f.name);b.classList.toggle('on');load();};
   (f.group==='boss'?cb:ce).appendChild(b);}
 }catch(e){}
}
const msbox=document.getElementById('msbox'),msin=document.getElementById('msin'),dd=document.getElementById('dd');

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

async function load(keepPage){
 if(!keepPage) curPage=1;
 const ids=[...SEL.keys()].join(',');
 const fams=[...FAM].join('||');
 const ma=document.getElementById('maxage').value;
 const q=new URLSearchParams({ids,fams,type:document.getElementById('type').value,
  cx:document.getElementById('cx').value,cy:document.getElementById('cy').value,
  radius:document.getElementById('rad').value,sort:document.getElementById('sort').value,
  limit:document.getElementById('lim').value,
  page:String(curPage)});
 if(ma) q.set('max_seen', String(Math.round(parseFloat(ma)*60)));
 const r=await fetch('/api/data?'+q+'&_='+Date.now(),{cache:'no-store'});const j=await r.json();
 const tb=document.getElementById('rows');tb.innerHTML='';
 // gate: si Evony aun no cargo su config, mostrar banner en lugar de filas rotas.
 if(!j.cfg_n){
  tb.innerHTML = `<tr class=cfgloading><td colspan="10">⌛ Waiting for Evony to load the game config…<br>
    <span class=hint>Enter the <b>world map</b> (server 2163). This resolves on its own once Evony has the config — no need to restart anything.</span></td></tr>`;
  document.getElementById('pager').style.display='none';
  document.getElementById('stat').textContent =
   `⌛ waiting for config | total map: ${j.all} | sweep no. ${j.sweep_num||0} · last pass: ${fmtDur(j.last_pass_secs)} | cfg: 0`;
  return;
 }
 for(const x of j.rows){
  const tr=document.createElement('tr');
  const cls=[];
  if(x.fresh) cls.push('fresh');
  if(x.atk==='rally') cls.push('atk-active');
  if(cls.length) tr.className=cls.join(' ');
  const g=x.group||'Normal';
  tr.innerHTML=`<td>${x.name}</td><td class=lvl>${x.level}</td><td>${fmtPow(x.power)}</td>
   <td><span class="grp g-${g.toLowerCase()}">${g}</span></td>
   <td>${fmtAtk(x)}</td><td>${x.x}</td><td>${x.y}</td>
   <td>${x.dist??'-'}</td><td><span class=cp onclick="navigator.clipboard.writeText('${x.x},${x.y}')">${x.x},${x.y} &#8682;</span></td>
   <td class=stat>${fmtAgo(x.age)}</td>`;
  tb.appendChild(tr);
 }
 const pg=document.getElementById('pager');
 if(j.limit<=0 || j.pages<=1){pg.style.display='none'; curPage=1;}
 else{
   pg.style.display=''; curPage=j.page;
   document.getElementById('pginfo').textContent=`page ${j.page} of ${j.pages} · ${j.total} results`;
   document.getElementById('pg_first').disabled = (j.page<=1);
   document.getElementById('pg_prev').disabled  = (j.page<=1);
   document.getElementById('pg_next').disabled  = (j.page>=j.pages);
   document.getElementById('pg_last').disabled  = (j.page>=j.pages);
 }
 document.getElementById('stat').textContent=
   `${j.total} results | total map: ${j.all} | sweep no. ${j.sweep_num||0} · last pass: ${fmtDur(j.last_pass_secs)} | cfg: ${j.cfg_n} | catalog: ${CAT.length}`;
}
let TAB='mon';
function switchTab(t){
 TAB=t;
 document.getElementById('view_mon').style.display = t==='mon'?'':'none';
 document.getElementById('view_players').style.display = t==='pl'?'':'none';
 document.getElementById('tab_mon').classList.toggle('on',t==='mon');
 document.getElementById('tab_pl').classList.toggle('on',t==='pl');
 t==='pl'?loadPlayers():load();
}
async function loadPlayers(){
 const g=id=>document.getElementById(id).value;
 const ma=g('p_maxage');
 const q=new URLSearchParams({lv_min:g('p_lvmin'),lv_max:g('p_lvmax'),
  pw_min:g('p_pwmin'),pw_max:g('p_pwmax'),shield:g('p_shield'),tag:g('p_tag'),
  cx:g('p_cx'),cy:g('p_cy'),radius:g('p_rad'),sort:g('p_sort'),limit:g('p_lim')});
 if(ma) q.set('max_seen', String(Math.round(parseFloat(ma)*60)));
 const r=await fetch('/api/players?'+q+'&_='+Date.now(),{cache:'no-store'});const j=await r.json();
 const tb=document.getElementById('prows');tb.innerHTML='';
 for(const x of j.rows){
  const tr=document.createElement('tr');
  const sh = x.shield
    ? `<span class=sh title="shield tier=${x.shield} (ETA exacto no disponible en bulk scan)">YES</span>`
    : '<span class=nosh>no</span>';
  const tg = x.tag ? `<span class=tag>${x.tag}</span>` : '<span class=stat>—</span>';
  const pw = (+x.power>0) ? fmtPow(x.power)
    : `<span class=stat title="no expuesto en el broadcast del map-scan; requiere profile fetch per-player">—</span>`;
  tr.innerHTML=`<td>${x.name||'?'}</td><td>${tg}</td><td class=lvl>${x.level}</td><td>${x.castle}</td>
   <td>${pw}</td><td>${sh}</td><td>${x.x}</td><td>${x.y}</td>
   <td>${x.dist??'-'}</td><td><span class=cp onclick="navigator.clipboard.writeText('${x.x},${x.y}')">${x.x},${x.y} &#8682;</span></td>`;
  tb.appendChild(tr);
 }
 document.getElementById('stat').textContent=
   `${j.total} players | total captured: ${j.all} | sweep: ${j.sweep}`;
}
async function tick(){
 if(TAB==='pl'){ if(document.getElementById('auto').checked) await loadPlayers(); return; }
 await loadCatalog();await loadFamilies();
 if(document.getElementById('auto').checked)await load(true);   // auto-refresh respeta la pagina
 if(SHOWATK) await loadAttacks();
}
(function(){const cb=document.getElementById('showatk');cb.checked=SHOWATK;cb.addEventListener('change',e=>toggleAtk(e.target.checked));if(SHOWATK)loadAttacks();})();
Promise.all([loadCatalog(),loadFamilies()]).then(load);setInterval(tick,4000);
</script></body></html>"""

class H(BaseHTTPRequestHandler):
    def log_message(self, *a): pass
    def _send(self, code, body, ctype="application/json"):
        b = body.encode() if isinstance(body, str) else body
        self.send_response(code)
        self.send_header("Content-Type", ctype)
        self.send_header("Content-Length", str(len(b)))
        self.send_header("Cache-Control", "no-store, no-cache, must-revalidate")
        self.send_header("Pragma", "no-cache")
        self.end_headers()
        self.wfile.write(b)
    def do_GET(self):
        from urllib.parse import urlparse, parse_qs
        u = urlparse(self.path)
        if u.path == "/" or u.path == "/index.html":
            return self._send(200, HTML, "text/html; charset=utf-8")
        if u.path == "/api/names":
            return self._send(200, json.dumps(names_list()))
        if u.path == "/api/catalog":
            return self._send(200, json.dumps(catalog()))
        if u.path == "/api/families":
            return self._send(200, json.dumps(families()))
        if u.path == "/api/data":
            params = {k: v[0] for k, v in parse_qs(u.query).items()}
            all_rows = query(params)
            try: limit = int(params.get("limit", "500"))
            except: limit = 500
            try: page = max(1, int(params.get("page", "1")))
            except: page = 1
            total = len(all_rows)
            if limit <= 0:
                page, pages, rows = 1, 1, all_rows
            else:
                pages = max(1, (total + limit - 1) // limit)
                page  = min(page, pages)
                rows  = all_rows[(page-1)*limit : page*limit]
            with LOCK:
                alln = len(OBJS)
            return self._send(200, json.dumps({
                "rows": rows, "total": total, "all": alln,
                "page": page, "pages": pages, "limit": limit,
                "sweep": STATE["sweep"], "cfg_n": STATE["cfg_n"],
                "sweep_num": STATE.get("sweep_num", 0),
                "last_pass_secs": STATE.get("last_pass_secs")}))
        if u.path == "/api/players":
            params = {k: v[0] for k, v in parse_qs(u.query).items()}
            rows = query_players(params)
            with LOCK:
                alln = len(PLAYERS)
            return self._send(200, json.dumps({
                "rows": rows, "total": len(rows), "all": alln,
                "sweep": STATE["sweep"]}))
        if u.path == "/api/marches":
            now = time.time()
            with LOCK:
                arr = [dict(m) for m in MARCHES.values() if int(m.get("te", 0)) > now]
            arr.sort(key=lambda m: int(m.get("te", 0)))
            return self._send(200, json.dumps({"now": int(now), "n": len(arr), "marches": arr}))
        if u.path == "/api/attacks":
            ATTACK_MTYS = {2, 19, 20, 21, 43}
            ALLY_MTYS   = {19, 20, 21}
            PHASE_NAME  = {19: "wait", 20: "way", 21: "combat", 2: "march", 43: "scout"}
            now = time.time()
            out = []
            with LOCK:
                tile_pl = {}
                for pl in PLAYERS.values():
                    wx = int(pl.get("wx", 0) or 0); wy = int(pl.get("wy", 0) or 0)
                    if wx and wy: tile_pl[(wx, wy)] = pl
                T_LABEL = {1:"castle", 3:"farm", 4:"garrison", 5:"ruins",
                           6:"king_city", 7:"sub_city", 8:"snowberg",
                           9:"guild_city", 10:"guild_store", 11:"guild_farm",
                           12:"boss"}
                for (tx, ty), sub in MARCH_BY_TGT.items():
                    ms = []
                    for trp in sub.keys():
                        m = MARCHES.get(trp)
                        if not m: continue
                        if int(m.get("mty", 0)) not in ATTACK_MTYS: continue
                        if int(m.get("te", 0)) <= now: continue
                        ms.append(m)
                    if not ms: continue
                    ally_ms = [m for m in ms if int(m.get("mty", 0)) in ALLY_MTYS]
                    if ally_ms:
                        by = {}
                        for m in ally_ms: by.setdefault(int(m["mty"]), []).append(m)
                        if   21 in by: rep = min(by[21], key=lambda m: int(m["te"]))
                        elif 20 in by: rep = min(by[20], key=lambda m: int(m["te"]))
                        else:          rep = min(by[19], key=lambda m: int(m["te"]))
                        kind = "alliance"; count = len(ally_ms)
                    else:
                        rep = max(ms, key=lambda m: int(m["te"]))
                        mty = int(rep.get("mty", 0))
                        kind = "scout" if mty == 43 else "solo"
                        count = 1
                    mty = int(rep.get("mty", 0))
                    uid = int(rep.get("ow", 0))
                    pl  = PLAYERS.get(str(uid)) or PLAYERS.get(uid) or {}
                    obj = OBJS.get((tx, ty)) or {}
                    tname = ""; tlevel = 0; tgroup = ""; ttype = 0
                    if obj:
                        ttype = int(obj.get("t", 0))
                        tlevel = obj.get("lv", 0)
                        if ttype == 2:
                            c = CFG.get(str(obj.get("id"))) or {}
                            nm = c.get("name", "") or ""
                            tname  = nm or f"id{obj.get('id')}"
                            tgroup = group_of(nm)
                        elif ttype in (1, 7):
                            tpl = tile_pl.get((tx, ty))
                            if tpl:
                                nm = tpl.get("name", "") or f"u{tpl.get('uid', '?')}"
                                tg = tpl.get("tag", "") or ""
                                tname  = f"[{tg}] {nm}" if tg else nm
                                tlevel = tpl.get("clv", 0) or tlevel
                                tgroup = "Player" if ttype == 1 else "SubCity"
                            else:
                                tname  = T_LABEL.get(ttype, "?") + f" id{obj.get('id', 0)}"
                                tgroup = "Player" if ttype == 1 else "SubCity"
                        else:
                            lbl = T_LABEL.get(ttype, f"t{ttype}")
                            tname  = lbl
                            tgroup = lbl.capitalize()
                    else:
                        tpl = tile_pl.get((tx, ty))
                        if tpl:
                            nm = tpl.get("name", "") or f"u{tpl.get('uid', '?')}"
                            tg = tpl.get("tag", "") or ""
                            tname  = f"[{tg}] {nm}" if tg else nm
                            tlevel = tpl.get("clv", 0)
                            tgroup = "Player"
                            ttype  = 1
                    out.append({
                        "tx": tx, "ty": ty,
                        "tname": tname, "tlevel": tlevel, "tgroup": tgroup, "ttype": ttype,
                        "kind": kind, "phase": PHASE_NAME.get(mty, "march"), "mty": mty,
                        "count": count, "uid": uid,
                        "name":  pl.get("name", "") or "",
                        "tag":   pl.get("tag", "") or "",
                        "guild": int(pl.get("gid", 0) or 0),
                        "eta":   max(0, int(rep.get("te", 0) - now)),
                        "te":    int(rep.get("te", 0)),
                        "sx":    int(rep.get("sx", 0)),
                        "sy":    int(rep.get("sy", 0)),
                    })
            out.sort(key=lambda a: a["eta"])
            return self._send(200, json.dumps({"now": int(now), "n": len(out), "attacks": out}))
        return self._send(404, "{}")

def prune_thread():
    """Purga objetos no re-vistos en TTL_SECONDS (auto-expira muertos -> mejor que iScout)."""
    while True:
        time.sleep(PRUNE_EVERY)
        now = time.time()
        cutoff = now - TTL_SECONDS
        with LOCK:
            dead = [k for k, o in OBJS.items() if o.get("ts", 0) < cutoff]
            for k in dead: del OBJS[k]
            pdead = [k for k, p in PLAYERS.items() if p.get("ts", 0) < cutoff]
            for k in pdead: del PLAYERS[k]
            mdead = [trp for trp, m in MARCHES.items() if int(m.get("te", 0)) <= now - 30]
            for trp in mdead:
                m = MARCHES.pop(trp, None)
                if m:
                    key = (int(m.get("tx", 0)), int(m.get("ty", 0)))
                    sub = MARCH_BY_TGT.get(key)
                    if sub is not None:
                        sub.pop(trp, None)
                        if not sub: MARCH_BY_TGT.pop(key, None)
        if dead or pdead or mdead:
            print(f"[prune] -{len(dead)} objs / -{len(pdead)} players / -{len(mdead)} marchas, "
                  f"quedan {len(OBJS)}/{len(PLAYERS)}/{len(MARCHES)}", flush=True)

def main():
    t = threading.Thread(target=frida_thread, daemon=True)
    t.start()
    threading.Thread(target=prune_thread, daemon=True).start()
    srv = ThreadingHTTPServer(("127.0.0.1", PORT), H)
    print(f"[web] http://127.0.0.1:{PORT}  (Ctrl+C para parar)", flush=True)
    try:
        srv.serve_forever()
    except KeyboardInterrupt:
        print("\n[web] parado")

if __name__ == "__main__":
    main()
