# Evony-hibrid-app — escáner + bot en una sola cuenta

App experimental que fusiona el escáner de mapa y el bot en **un solo cliente de juego
y una sola cuenta**. Proyecto aislado: no comparte código, puertos ni dispositivos con
producción.

**Estado: FUNCIONANDO** (2026-08-13). Backend en `http://127.0.0.1:8783`.

## Qué NO se toca

| Recurso | Quién lo usa |
|---|---|
| `evony-scout-pixel-v5/` :8773 + Pixel 192.168.0.30/.31 | escáner de producción |
| `bot_lite/{dasea,lume,pixel}/` :8780 :8781 :8782 | bots de producción |
| AVD `evony_bot_1` = `emulator-6004` | bot dasea |

## Arquitectura

```
  Evony (emulator-6008, cuenta "Lume 🔥", server 1939)
        │
        │  UN SOLO agente Frida: agent_bot.js
        │    · BOT           -> canal recv("ctl")   [código de producción, intacto]
        │    · MÓD. ESCÁNER  -> canal recv("scan")  [injertado al final del fichero]
        ▼
  bot_web.py  :8783
        │  scanner_store.py  <- almacén del mapa (lo que antes servía el :8773)
        ▼
  UI web (sin login: local, sin auth.json)
```

**La clave de la integración:** `fetch_monsters()` ya no pega por HTTP al escáner
externo, lee de `scanner_store.map_rows()`, que devuelve el MISMO formato de filas
(`x, y, name, level, group, id, power, seen_age`). Todo lo demás del bot —presets,
auto-farm, rally, auto-join, heal, buffs, wheel, mails— sigue funcionando sin cambios.

**Dos canales de control separados.** Dos `recv()` del mismo tipo se roban los mensajes
entre sí, así que el bot escucha `"ctl"` y el escáner `"scan"`. El bot no hookea
`DecodeCallback`, de modo que el hook del escáner es exclusivo.

## Lecciones aprendidas (a golpes)

- **No sondear la cuenta desde el escáner.** Un `Il2Cpp.gc.choose(PlayerData)` cada 3 s
  durante la carga en frío **mató el juego**. `gc.choose` recorre el heap entero. El bot
  ya lee la cuenta cada 15 s y publica `globalThis.__accountReady`; el escáner sólo mira
  ese booleano.
- **Nada de `gc.choose` dentro del bucle de barrido.** Lo tuvimos en la compuerta y el
  barrido se quedaba mudo (`sent=0`) sin dar un solo error.
- **Una sola fuente de configs.** El escáner volcaba sus propias 1.920 configs mientras
  el bot hacía su `dump_moncfg`: trabajo duplicado y pesado en el peor momento. Ahora el
  backend pasa las del bot al almacén.
- **Orden de arranque:** juego → cuenta dentro → backend. Al revés, "Loading" eterno.
- **`OnSendTroopReply` no se hookea jamás:** crashea el juego (SIGABRT). Para confirmar
  una marcha, leer el estado del general (`GetGeneralState`: 2=idle, 3=marchando).
- **`Msg.general` no expone `__id` por nombre:** el id se lee por offset 16, y
  `GetGeneralState` recibe el **id**, no el objeto.
- **t=2 son los monstruos** (verificado con el histograma del mapa; el t=5 de las notas
  viejas ya no vale).

## Banco de pruebas

- **AVD:** `evony_hibrid`, clon de `evony_bot_1` (imagen `google_apis_playstore/arm64`,
  6 GB RAM, 4 cores, 1080×2400). Creado a mano (`avdmanager` peta con el Java 8 del
  sistema): copiar `config.ini` cambiando `avd.id`/`avd.name`, copiar `userdata.img` y
  escribir el `.ini` externo.
- **Puerto adb:** 6008 (6000/6002/6004/6006 ocupados).
- **Root:** heredado — la imagen del sistema ya está parcheada con rootAVD/Magisk, así
  que cualquier AVD nuevo nace rooteado. Sólo hay que instalar la app Magisk y conceder
  `su` a `com.android.shell` (diálogo *Superuser Request* → GRANT, auto-deniega a los 5 s).
- **Evony 5.27.1** clonada por `adb pull` desde `emulator-6004` (los APK de
  `~/Desktop/Evony/evony_apk/` son de mayo y están obsoletos).
- **frida-server 17.9.10**, misma versión que el frida-python del Mac.

## Uso

```bash
./run.sh
```

Levanta frida-server, pone Evony delante, espera a que la cuenta cargue y arranca el
backend. Luego abre `http://127.0.0.1:8783`.

Compilar el agente tras tocar `agent_bot.ts`:

```bash
./build_agent.sh
```

⚠️ `/tmp/il2cpp_work` es EFÍMERO: se borra al reiniciar el Mac. Si falta, reinstalar
frida-il2cpp-bridge@0.13.1 + frida-compile@19.0.5 **juntos** desde la caché de npm.

## API propia de la app

| Endpoint | Qué hace |
|---|---|
| `GET /api/scanner` | estado del barrido: progreso de vuelta, objetos, ratio de replies, y **salud del cliente** (frames, gap medio/máximo, congelaciones) |
| `POST /api/scanner` | `{"cmd":"pause"\|"resume"\|"status"\|"cadence"\|"priority"}` |

El resto de la API es la del bot de producción, sin cambios.

## ⚠️ Barrido y marchas SE PISAN — silencio al enviar (2026-08-13)

**La pregunta que originó el proyecto tiene respuesta, y es que NO conviven sin
coordinación.** Medido en producción, no en teoría:

| Barrido | Envíos del bot | Marchas que salieron |
|---|---|---|
| Activo (350 ms) | 9 en 3 min (4 JOIN + 5 FIRE) | **0** — 6/6 slots libres todo el rato |
| **Pausado** | 5 | **4**, incluidas 2 de auto-join (mtype 19/20) |
| Activo + silencio al enviar | continuo | **6 marchas, 0 slots libres, 3 rallies a la vez** |

**Mecanismo:** el barrido manda ~3 `request_worldmap`/s por el MISMO
`SendProtobufMessage` que usan las marchas. El servidor limita la tasa y lo que descarta
son los `send_troop`. El síntoma engaña: el bot canta `FIRE ✅` / `JOIN ✅` porque eso es
client-side y no prueba nada. El delator es el **ratio de replies**, que cayó a 0,75
(con el arreglo vuelve a 1,6).

**Consecuencia en cadena que confundía el diagnóstico:** si la marcha no sale, el general
nunca consta ocupado, así que el bot lo reutiliza en el siguiente ciclo contra el
siguiente objetivo → se ven 3 marchas idénticas del mismo general a targets distintos,
todas `pending` para siempre porque nunca hubo batalla. Parecen dos bugs y es uno.

**Solución implementada:** el bot y el escáner viven en el mismo proceso, así que el bot
llama a `globalThis.__holdScan()` al recibir cualquier orden que mande tropas (`fire`,
`join_rally`, `blitz_rally`, `cancel_rally`, `heal_soldiers`, `wheel_spin`, `use_*`) y el
barrido calla 3 s. Es una asignación: sin round-trip ni latencia. El panel cuenta los
silencios (`sweep holds`).

## Medición de convivencia

El agente mide en el hilo principal de Unity el hueco entre frames: si barrer y marchar
se pisan, aparece ahí. Primera lectura con el barrido en marcha:

```
92 requests / 99 replies (ratio 1.08) · 6.442 objetos
449 frames · gap medio 112 ms · máximo 258 ms · 0 congelaciones
```

`spike.py` + `agent_spike.ts` quedan en el repo: ejecutan el protocolo de 3 fases
(A=solo barrido, B=solo marchas, C=ambas) y dan un veredicto comparativo con números.
Sin ejecutar todavía.

## Panel de escáner en la UI

Primera tarjeta de la web (`🗺️ Map scanner`), con ciclo propio de 4 s:
objetos en mapa · servidor · configs · barra de progreso de la vuelta · requests y ratio
de replies · **salud del cliente** (gap medio/pico entre frames y congelaciones >2 s, en
verde/ámbar/rojo) · botón pausar-reanudar y ajuste de cadencia.

Verificado en vivo: pausar detiene el envío y al reanudar **el barrido continúa por donde
iba** (no reinicia el índice, a diferencia del agente de producción).

## Ataques activos

El reply de worldmap trae tres listas y el módulo escáner cosecha las tres: objetos
(`mapinfo`), **marchas en vuelo** (`map_target_info`) y **jugadores** (`user_summary`).
Con eso `fetch_attacks()` sale del almacén local y alimenta la vista **🏴 Rally Steals**
y el motor de robo.

Filtros aplicados (el escáner de producción hace lo mismo):

| Filtro | Por qué |
|---|---|
| **Relocations** (atacante == dueño del castillo destino) | no es un ataque, es alguien volviendo a su ciudad — **45 de 56 marchas** en la primera medición |
| Marchas propias | se detecta el uid propio por las coordenadas de la ciudad, porque `ACCOUNT` no expone `uid` |
| Refuerzos de la propia alianza **a castillos** | son *helps*, no ataques |
| Aterrizadas (+12 s de gracia) y sin refresco en 120 s | rally cancelado o ya resuelto |

⚠️ **El filtro de la propia alianza NO debe aplicarse a los ataques a monstruos.** Se
implementó así al principio y dejaba Rally Steals permanentemente vacío: los 6 ataques a
monstruos que había eran todos de LAN y se descartaban. Un rally de tu propia alianza a un
boss **sí** es robable; a quién robar lo decide el usuario con "Target alliances".

Los objetivos que no son monstruos se etiquetan por tipo (`castle`, `farm`, `ruins`…) y
en los castillos se muestra el **nombre del dueño** en vez del id. El bot sólo roba
objetivos cuyo grupo está en {Boss, Event, Normal, Shadow, Other}, así que esas etiquetas
los excluyen solas.

| Endpoint | Qué da |
|---|---|
| `GET /api/attacks` | ataques activos ya filtrados |
| `GET /api/attacks?raw=1` | TODAS las marchas sin filtrar — para comprobar que el filtrado no se come nada |

## Scan dirigido (tiles desconocidos)

Cuando llega una marcha hacia un tile que el barrido aún no ha visitado, no sabemos qué
monstruo hay y la fila se cae de Rally Steals por no tener grupo (visto con el
`Ymir @867,627` que DaSea sí tenía). El backend detecta esos destinos y pide su
coordenada al agente (`cmd:"priority"`), que la encola en la **misma cola del barrido**:
así respeta cadencia y silencio-al-enviar. Mandarlas a pelo reproduciría la saturación
que nos dejó sin marchas. Tope de 3 por lote y 120 s antes de reintentar el mismo tile.

## El diálogo "Superuser Request" salía sin parar

`ensure_agent()` comprobaba frida-server con `su -c netstat`, y como se llama en cada
attach/reattach/watchdog, Magisk pedía permiso continuamente **encima del juego**. Ahora
la comprobación es `ps -A | grep frida-server`, que **no necesita root**; `su` sólo se usa
si de verdad hay que arrancar frida-server.

Para que no pida permiso nunca más (opcional, requiere un toque manual): lanzar
`adb -s emulator-6008 shell "su -c id"` y pulsar **GRANT** en la ventana del emulador.
⚠️ Magisk deja GRANT **deshabilitado en gris los primeros ~3 s** (anti-tapjacking), así
que un `input tap` automático demasiado pronto no hace nada; y `uiautomator` no ve ese
diálogo (es ventana de sistema), por lo que no se puede localizar el botón por código.
Alternativa: app Magisk → Superuser → Automatic Response → Grant.

## Continuidad del barrido (persiste entre freezes/reinicios)

El índice del sweep vivía solo en memoria del agente: al reengancharse tras un freeze o
reinicio volvía a 0 y re-empezaba por el núcleo una y otra vez, sin llegar a la periferia
profunda. Ahora se persiste en disco y se retoma:

- El agente reporta `sweep.idx` en cada `scan_metrics`; el backend lo guarda en
  `scan_progress.json` (escritura atómica, throttle 15 s).
- Al armarse, el módulo escáner emite `scan_boot`; el backend responde con `resume_idx`
  = índice guardado. El agente hace `sIdx = idx` (clamp al tamaño de la vuelta) y retoma.
- Sobrevive a: re-attach del watchdog, reinicio del backend y cold-boot del emulador (el
  fichero está en el disco del Mac).

Verificado: guardado idx=157 → forzar re-attach → log `barrido retomado en idx=157`, el
sweep continúa desde ahí, no desde 0.

⚠️ `scanner_store.py` DEBE importar `json` (lo usa `save_progress`); sin él la escritura
lanzaba NameError tragado por el `except` y el fichero no aparecía nunca.

## Retoques de UI portados de producción (bot_lite/pixel)

Copiados exactos de la versión que ya corre en los bots de producción:
- **Botón Attack por target en el Preview**: nueva columna con `<button class=atkbtn>Attack</button>`
  por fila → `attackOne(x,y)` → `POST /api/apply {only:{x,y}}` → `apply_and_fire(only_xy=…)`
  dispara SOLO ese objetivo (por coords, el plan se recalcula). El botón general se conserva.
- **Cancel rally resaltado**: `class=sec` → `class=cancelbtn` (rojo semi-transparente, como el
  Attack individual). Blitz sigue en dorado (`gemsbtn`).
- **MARCH CAP oculto en auto-join**: `capChip()` devuelve '' si `mode==='autojoin'` (marchan con 1×T1).
- **Cancel rally + Blitz debajo de la barra de progreso**: la barra (`pmarch`) y los botones
  (`rallyBtnsHtml`) se envuelven en `<div class=pmwrap>` con la barra PRIMERO. `.pmwrap{display:contents}`
  en móvil; en desktop (`@media min-width:641px`) `pmwrap` pasa a columna absoluta arriba-derecha
  (`position:absolute;flex-direction:column`) → los botones quedan debajo de la barra. Copiado de producción.

## Auto-join envía pero no se materializa = SESIÓN DEGRADADA (reiniciar el JUEGO)

Síntoma: el bot loguea `JOIN ✅` (client-side, no confirma nada) pero `active_marches=0`,
`guild_wars[...].joined=False` y ninguna marcha de rally aparece — pese a haber rallies
unibles (mst en el futuro), slots libres y generales libres. Pista secundaria: el ciclo
se queda pegado a UN solo general (los demás caen en el cooldown `_JOIN_GEN` de 180 s tras
sus joins fallidos) y luego deja de intentar 60-180 s.

Causa: la sesión de RED del emulador se degradó (típico tras un **kick "sesión en otro
dispositivo"** o mucho trajín de Stop/Reload). El agente lee memoria bien (SCAN funciona)
pero `send_troop` no llega al servidor. **El re-attach NO lo cura.**

FIX (verificado 2026-08-17): reiniciar el JUEGO — `am force-stop` + `am start`. Emulador y
frida siguen vivos, es rápido, y el agente reengancha solo. Tras el reinicio los joins se
materializan (mtype 19 hacia las coords del líder) y rotan entre presets. El botón
**↻ Reload Bot** de la UI hace exactamente esto.

## MOTOR DE ENVÍO MAIN-THREAD (fix de raíz de las congelaciones) — 2026-08-17

**Causa de las congelaciones (confirmada en vivo):** invocar `SendProtobufMessage` desde el
HILO DE FRIDA choca con el GC de Unity → el GC espera a ese hilo → suspende UnityMain → el
juego se congela. Síntoma exacto visto: el heartbeat del agente se para (last_hb_age crece
sin fin) mientras el proceso sigue vivo; el barrido se clava (idx fijo). El watchdog acaba
reiniciando el juego.

**Fix (portado de `~/evony-scout-pixel-v5/agent_v5.ts`):** `sendWorldmap()` ya NO envía —
sólo ENCOLA en `sSendQ` (barato, cero il2cpp). El envío REAL (`doSendWorldmap`) se ejecuta
desde el hook de `WorldMapManager.Update` = MAIN THREAD de Unity, drenando ≤2 por frame
(`S_MAX_PER_FRAME`). Al ir por el mismo hilo que el GC, no hay contención → no hay
suspensión → no hay congelación. El mismo hook mide el gap entre frames (telemetría).

Verificado tras desplegar: `sent`/`objs`/`idx` suben, `cola_envio` (sweep.sendq) se queda en
0-1 (el drenado va sobrado), 0 congelaciones. Bot (marchas/joins) intacto.

⚠️ El envío sólo ocurre cuando `WMM.Update` dispara = cuando el juego está EN EL WORLDMAP.
Fuera de él la cola no drena (capada a 64, descarta el exceso) — correcto: esos envíos no
servirían de todas formas.

**Vuelta atrás** si diera problemas a largo plazo: en `agent_bot.ts`, hacer que
`sendWorldmap` vuelva a llamar directo al cuerpo de `doSendWorldmap` (envío síncrono desde
el hilo de frida) y quitar el drenado del hook de `WMM.Update`. Recompilar.

**Resultado parcial (2026-08-17):** el motor eliminó las congelaciones DEL ESCÁNER (0
freezes de frame en 23 min, cola vacía) — pero el juego SE VOLVIÓ A CONGELAR a los ~23 min
por otra vía: deadlock il2cpp/GC donde el PROCESO queda wedged (frida lo ve pero un attach
FRESCO también da timeout; el juego con CPU avanzando, no es congelación de main thread).
Sospechoso restante: las llamadas il2cpp del BOT desde el hilo de frida (`gen_states` = 98
`GetGeneralState`, `scanAccount`) + el emulador maltratado por ~12 cold boots/kicks de hoy.

**Fix del watchdog (proceso wedged auto-recupera):** en `agent_thread`, si `attach_agent()`
falla del todo (6 timeouts = proceso wedged), el watchdog hace `_restart_evony()` (force-stop
+ relaunch) con cooldown de 90s, en vez de seguir intentando re-attach eternamente. Así el
freeze se auto-cura en ~30-60s. Antes se quedaba caído hasta intervención manual.

**Recuperación RÁPIDA (2026-08-17):** `_watchdog_action` — freeze = heartbeat caducado **>30s**
(3 latidos, antes 40) → reinicio DIRECTO del juego (`hot` = force-stop+relaunch), **sin
re-attach previo**. Antes iba a `reattach` → 6 intentos de attach de ~29s cada uno (~2,9 min
tirados, porque en un proceso wedged el attach nunca funciona) → luego reinicio. Ahora:
freeze→reinicio en ~30-40s + recarga ~40-60s = **~1-1,5 min total** (antes ~4 min). Cooldown de
120s para no re-disparar durante la recarga. Margen: 3 latidos perdidos no es un pico legítimo
de GC. `heartbeat`=10s, bucle watchdog=8s.

## Anti-tormenta del bot (en observación) — 2026-08-17

Tras poner el motor main-thread (que arregla el escáner), quedaban TORMENTAS de freeze:
el juego se recongelaba enseguida tras recuperarse. Causa: las llamadas il2cpp del BOT
desde el hilo de frida, sobre todo `gen_states` (`gc.choose(PD)` = fuerza GC + recorre
heap, + ~98 `GetGeneralState` en bucle), disparadas justo tras cada recarga.

**Primer freno (backend-only, SEGURO, reversible) — en `_maybe_gen_states`:**
1. Gracia de 30s tras `attached_at` (re)enganche → el burst pesado no corre hasta que el
   juego se estabiliza (ataca la tormenta post-recarga).
2. Intervalo 90→150s → menos martillazos al GC.
No cambia QUÉ hace el bot: durante la gracia/staleness asume "marchable" por defecto y usa
`active_marches` (fresco) para no repetir general; un join a un general ocupado lo rechaza
el servidor limpio. **Vuelta atrás:** restaurar 90s y quitar la línea de la gracia.

**Pendiente de valorar (implementación definitiva):** si las tormentas persisten, mover las
llamadas il2cpp del bot (scanAccount/gen_states) al MAIN THREAD como el escáner. `scanAccount`
(cada 15s, gc.choose) es el otro martillo pero es esencial -> no se tocó en esta ronda.

Observación: `scratchpad/watch_obs.py` (3h, distingue tormenta de atasco real).

## Pendiente
- [ ] Ejecutar el protocolo del spike para tener el veredicto formal.
- [ ] El índice del barrido no persiste entre reinicios del agente (mismo problema que
      producción: la periferia profunda rara vez se alcanza).
