# El LLM como asesor

> Deja en manos del LLM qué acumular, dónde poner a los trabajadores y si este minuto toca atacar o aguantar; la capa de reglas solo ejecuta y veta. El cerebro de referencia ya funciona así; esta página explica el patrón y sus trampas.

Fuente: https://war3ai.com/es/docs/llm-coach/

Cuando tu Bot alcanza cierto tamaño, descubres que las reglas de la capa económica se han ido pegando una encima de otra: una para el número de leñadores, otra para 5 por mina, otra para reducirlos a la mitad cuando sobra madera, otra para mandar más cuando hay mucho oro y poca madera… Cada una por separado es correcta, pero juntas producen situaciones como «a la mina le faltan trabajadores y todos los campesinos están talando», de las que **ninguna regla se hace responsable**.

Este tipo de juicio —ver el conjunto y fijar prioridades— no encaja bien en un `if / else`, pero es justo lo que mejor hacen los LLM. El cerebro de referencia (`brains/xwar3/strategy/brain/coach.py`) usa exactamente la división en capas que ves abajo.

## Capas

```text
LLM (asesor)            una vez cada 20 segundos de juego, asíncrono, nunca bloquea un tick
  Entrada: una página con el estado de la partida (recursos, comida, reparto de campesinos, minas, unidades, tecnologías, héroes, información del enemigo, sucesos recientes)
  Salida: JSON estricto —— un diagnóstico de una frase + reparto de trabajadores + qué producir primero + postura para este minuto + qué no hacer
        │
        ▼  lista blanca + acotación a mínimos / máximos + veto
Capa de reglas (Bot, cada tick)      traduce la sugerencia en «sesgos» sobre capacidades que ya existen: reparto de trabajadores, prioridades de construcción / entrenamiento, postura de ataque
        │
        ▼
Capa de ejecución (SDK / capa de reflejos)  da órdenes, lee recibos, hace el micro
```

## Contrato de salida

Haz que el modelo emita solo JSON con campos fijos, sin añadir ni quitar ninguno:

```json
{
  "diagnosis": "Una frase: el mayor problema de la partida; tiene que poder justificarse con los datos de entrada",
  "workers":   { "gold": 10, "lumber": 6 },
  "priority":  ["hpea", "hhou", "hbar"],
  "posture":   "creep",
  "avoid":     ["Si falta madera, no investigues Iron Plating primero"]
}
```

| Campo | Cómo lo usa la capa de reglas | Acotación en el cerebro de referencia |
|---|---|---|
| `workers` | Número objetivo de trabajadores en oro y en madera | Oro 2 ~ 25, madera 1 ~ 20; la suma no puede superar el total de campesinos |
| `priority` | Orden de prioridad para entrenar / construir / investigar | Como máximo 4; solo se aceptan códigos de cuatro caracteres que aparezcan en la tabla de «códigos permitidos» |
| `posture` | La postura para este minuto | Solo uno de `attack` `defend` `creep` `expand` `recover` `hold` |
| `avoid` | Qué no hacer este minuto | Como máximo 2 |
| `diagnosis` | Solo para los logs y para mostrarlo en la consola | — |

Usa un prompt por raza que contenga solo las decisiones propias de esa raza (la construcción asistida y la milicia de los Humanos, la madriguera de los Orcos, la mina de oro encantada de los No-muertos, la mina de oro enredada de los Elfos de la noche…). Las reglas comunes van en una parte compartida: no las copies cuatro veces.

## Cuatro restricciones estrictas

Las cuatro le costaron caro al cerebro de referencia:

1. **El asesor nunca da órdenes directas a las unidades.** No ve lo que pasa en la escena a escala de 150 ms y además alucina. Solo cambia objetivos y prioridades; quién va adónde y a quién ataca lo siguen decidiendo la capa de reglas y la capa de reflejos. El mando solo puede tener un dueño.
2. **Asíncrono.** Cada consulta tarda alrededor de 1 segundo, corre en un hilo en segundo plano y se aplica el resultado más reciente: **nunca bloquea un tick**. Si el modelo no ha arrancado, se agota el tiempo o responde cualquier cosa, se actúa como si esta capa no existiera y el comportamiento vuelve a ser solo de reglas. Las sugerencias demasiado viejas (más de 3 intervalos) tampoco se usan.
3. **Lista blanca + acotación.** Cada campo debe poder mapearse a una capacidad existente, y los valores se acotan a rangos razonables. Lo que no se reconoce **se cuenta y se descarta**, en lugar de ignorarse en silencio.
4. **Cuéntalo todo.** Cuántas consultas, cuántos éxitos, cuántos timeouts, cuántas veces se acotó un valor, cuántas veces se adoptó cada campo; publícalo junto con la última entrada enviada al modelo. Si no, la pregunta «¿sirve de algo esta capa?» no tiene respuesta.

> **Lo que puede degradarse sin riesgo es lo que más fácilmente se degrada sin que nadie lo note**
>
> El asesor está diseñado para que «fallo = como si esta capa no existiera», así que cuando el servicio del modelo no arranca, el Bot se comporta exactamente igual que con reglas puras y desde fuera no se nota nada. Al cerebro de referencia le pasó que, durante un día entero, los asesores de sus 6 instancias no pudieron conectarse y nadie se dio cuenta. Publica siempre «la hora del último éxito» y «el motivo del último fallo»: la página «Asesor de estrategia» de la [consola Farsight](https://war3ai.com/es/docs/console/) sirve justo para eso.

## Impleméntalo en tu propio Bot

Aquí tienes un esqueleto mínimo que funciona con cualquier API compatible con OpenAI (LM Studio, Ollama o una API en la nube) y solo usa la biblioteca estándar:

```python title="coached_bot.py"
import collections, json, threading, urllib.request
from openwar3 import Bot

BASE = "http://127.0.0.1:1234/v1"            # LM Studio / Ollama / cualquier servicio compatible con OpenAI
MODEL = "your-model"
POSTURES = {"attack", "defend", "creep", "expand", "recover", "hold"}
SYSTEM = """Eres un coach de economía de Warcraft III: solo te ocupas de la economía y la estrategia, no del micro.
Emite solo JSON con campos fijos: {"diagnosis": una frase, "workers": {"gold": entero, "lumber": entero},
"priority": [códigos de cuatro caracteres, máximo 4, solo de allowed], "posture": uno de los seis, "avoid": [máximo 2]}
Habla solo a partir de los datos de la partida que se te dan; no inventes nada que no esté en los datos."""

def ask(state: dict) -> dict:
    body = {"model": MODEL, "temperature": 0.3, "max_tokens": 260,
            "messages": [{"role": "system", "content": SYSTEM},
                         {"role": "user", "content": json.dumps(state, ensure_ascii=False)}]}
    req = urllib.request.Request(f"{BASE}/chat/completions", json.dumps(body).encode(),
                                 {"Content-Type": "application/json"})
    with urllib.request.urlopen(req, timeout=8) as r:
        text = json.load(r)["choices"][0]["message"]["content"]
    return json.loads(text[text.index("{"): text.rindex("}") + 1])

class CoachedBot(Bot):
    EVERY = 20.0                              # segundos de juego: las decisiones económicas se miden en minutos, no hace falta preguntar cada tick
    allowed = {"hpea", "hfoo", "hrif", "hkni", "hhou", "hbar", "hbla", "Rhme", "Rhar"}

    def on_start(self, g):
        self.plan, self.plan_at, self.asked_at, self.busy = {}, -1e9, -1e9, False
        self.stats = collections.Counter()

    def summary(self, g) -> dict:             # la instantánea se lee en el hilo principal; el hilo de fondo no toca g
        res = g.resources() or {}
        return {"clock": round(g.clock() or 0), "gold": res.get("gold"), "lumber": res.get("lumber"),
                "food": [res.get("food_used"), res.get("food_cap")],
                "workers": len(g.my_workers()), "idle_workers": len(g.idle_workers()),
                "army": collections.Counter(u.type for u in g.my_army()),
                "enemy_seen": collections.Counter(u.type for u, _t, _age in g.last_seen(max_age=90)),
                "night": g.is_night(), "allowed": sorted(self.allowed)}

    def consult(self, state, now):
        try:
            p = ask(state)
            self.stats["ok"] += 1
            posture = p.get("posture")
            if posture not in POSTURES:
                self.stats["bad_posture"] += 1                       # se cuenta y se descarta, no en silencio
                posture = "hold"
            self.plan = {"gold": min(25, max(2, int(p["workers"]["gold"]))),      # acotación
                         "lumber": min(20, max(1, int(p["workers"]["lumber"]))),
                         "priority": [c for c in p.get("priority", []) if c in self.allowed][:4],
                         "posture": posture}
            self.plan_at = now
        except Exception as e:                                       # timeout / respuesta sin sentido: como si esta capa no existiera
            self.stats[f"error:{type(e).__name__}"] += 1
        finally:
            self.busy = False

    def on_tick(self, g):
        now = g.clock() or 0.0
        if not self.busy and now - self.asked_at >= self.EVERY:
            self.busy, self.asked_at = True, now
            threading.Thread(target=self.consult, args=(self.summary(g), now), daemon=True).start()
        plan = self.plan if now - self.plan_at <= 3 * self.EVERY else {}   # las sugerencias demasiado viejas no se usan
        # ↓ capa de reglas: con plan vacío se siguen las reglas por defecto; con plan solo se ajustan reparto, prioridades y postura; las órdenes concretas las siguen dando las reglas
        ...
```

## Cómo elegir el modelo

| Situación | Recomendación |
|---|---|
| Local, con prisa | Los modelos MoE (en cada paso solo activan una pequeña parte de los parámetros) son mucho más rápidos que un modelo denso del mismo tamaño. El cerebro de referencia usa Qwen3.6-35B-A3B (LM Studio, Q4): mediana de **1.09 s**, la más lenta 1.45 s, y 5/5 respuestas se pueden pasar directamente a `json.loads` |
| Local, modelo que «piensa» | **Tienes que desactivar la fase de pensamiento**; si no, todos los tokens se van en pensar y no sale ni un solo JSON. LM Studio ignora `/no_think`; el cerebro de referencia usa `/v1/completions`, arma el ChatML a mano y prerrellena un `<think></think>` vacío y una `{` |
| Modelo en la nube | La latencia suele ser mayor, pero esta arquitectura ya es asíncrona; las decisiones económicas se miden en minutos, así que unos segundos de latencia son aceptables |

> **Nota**
>
> El mismo modelo también puede poner voz a las unidades: consulta [Bocadillos y modelos locales](https://war3ai.com/es/docs/speech/). Si quieres que el modelo dé órdenes directamente tick a tick (en lugar de hacer de asesor), espera a la pasarela JSON de la [Arena](https://war3ai.com/es/arena/).
