Docs IA escrevendo Bots

LLM como coach de estratégia

Entregue a um LLM as decisões "para o que economizar, onde colocar trabalhadores, atacar ou segurar neste minuto" e deixe a camada de regras apenas executar e vetar. O cérebro de referência já funciona assim; esta página explica o padrão e as armadilhas.

Quando seu Bot passa de certo tamanho, você percebe que as regras de economia vão sendo empilhadas umas sobre as outras: uma regra para quantos lenhadores, uma para 5 trabalhadores por mina, uma para cortar a madeira pela metade quando sobra, uma para mandar mais gente ao ouro quando falta ouro e sobra madeira… Cada regra está certa isoladamente, mas juntas produzem situações como “a mina está sem trabalhadores enquanto todos os camponeses cortam árvores” — situações pelas quais nenhuma regra é responsável.

Julgamentos do tipo “olhar o quadro geral e definir prioridades” nunca se encaixaram bem em if / else, mas são exatamente o que LLMs fazem bem. O cérebro de referência (brains/xwar3/strategy/brain/coach.py) usa as camadas abaixo.

Camadas

LLM (conselheiro)       Uma vez a cada 20 segundos de jogo, assíncrono, nunca bloqueia um tick
  Entrada: um snapshot de uma página da partida (recursos, comida, distribuição de camponeses, minas, tipos de unidade, tecnologia, heróis, informações do inimigo, eventos recentes)
  Saída: JSON estrito — um diagnóstico de uma frase + divisão de trabalhadores + o que produzir primeiro + a postura deste minuto + o que evitar
        │
        ▼  lista de permissões + limites mín./máx. + veto
Camada de regras (Bot, a cada tick)  Traduz o conselho em "vieses" sobre capacidades existentes: divisão de trabalhadores, prioridade de construção / treino, postura de ataque
        │
        ▼
Camada de execução (SDK / camada reflexa)  Dá ordens, lê recibos, faz micro

Contrato de saída

Faça o modelo produzir JSON com um conjunto fixo de campos — sem acrescentar nem remover nenhum:

{
  "diagnosis": "Uma frase: o maior problema da partida, que precisa ter base nos dados de entrada",
  "workers":   { "gold": 10, "lumber": 6 },
  "priority":  ["hpea", "hhou", "hbar"],
  "posture":   "creep",
  "avoid":     ["Não pesquise Placas de Ferro primeiro quando faltar madeira"]
}
CampoComo a camada de regras usaLimites do cérebro de referência
workersNúmero alvo de trabalhadores no ouro e na madeiraOuro 2 ~ 25, madeira 1 ~ 20; a soma não pode passar do total de camponeses
priorityOrdem de prioridade de treino / construção / pesquisaNo máximo 4; só aceita códigos de quatro caracteres que aparecem na tabela de “códigos permitidos”
postureA postura deste minutoPrecisa ser um de attack defend creep expand recover hold
avoidO que não fazer neste minutoNo máximo 2
diagnosisUsado só para logs e para exibição no console—

Use um prompt por raça, cobrindo apenas as escolhas específicas daquela raça (a construção cooperativa e a Milícia dos Humanos, as Tocas dos Orcs, a Mina de Ouro Assombrada dos Mortos-vivos, a Mina de Ouro Enredada dos Elfos Noturnos…). Coloque as regras comuns em uma parte compartilhada — não copie tudo quatro vezes.

Quatro restrições rígidas

O cérebro de referência aprendeu cada uma delas do jeito difícil:

  1. O conselheiro nunca dá ordens diretas a unidades. Ele não enxerga o que acontece na escala de 150 ms e alucina. Ele só muda metas e prioridades; quem vai para onde e quem ataca o quê continua sendo decidido pela camada de regras e pela camada reflexa — o comando só pode ter um dono.
  2. Assíncrono. Uma consulta ao conselheiro leva cerca de 1 segundo e roda em uma thread em segundo plano; vale o resultado mais recente, e ela nunca bloqueia um tick. Se o modelo não subiu, estourou o tempo ou respondeu lixo, aja como se essa camada não existisse e volte às regras puras. Conselhos antigos demais (mais de 3 intervalos) também não são usados.
  3. Lista de permissões + limites. Todo campo precisa corresponder a uma capacidade existente, e os valores numéricos são limitados a uma faixa razoável. Conteúdo não reconhecido é contado e depois descartado, não ignorado em silêncio.
  4. Conte tudo. Quantas vezes você perguntou, quantas deram certo, quantas estouraram o tempo, quantas foram limitadas, quantas vezes cada campo foi adotado — publique tudo isso junto com a última entrada enviada ao modelo. Caso contrário, “essa camada serve para alguma coisa?” vira uma pergunta sem resposta.

O que degrada com segurança é o que mais degrada em silêncio

O conselheiro foi projetado para que “falha = agir como se essa camada não existisse”; por isso, quando o serviço do modelo não está no ar, o Bot se comporta exatamente como as regras puras e nada parece errado visto de fora. O cérebro de referência já passou um dia inteiro com o conselheiro sem conseguir se conectar em todas as 6 instâncias, sem que ninguém percebesse. Sempre publique “horário da última chamada bem-sucedida” e “motivo da última falha” — é exatamente para isso que existe a página “coach de estratégia” no console Farsight.

Implementando no seu próprio Bot

Abaixo está um esqueleto mínimo que funciona com qualquer API compatível com OpenAI (LM Studio, Ollama ou uma API na nuvem) e usa só a biblioteca padrão:

import collections, json, threading, urllib.request
from openwar3 import Bot

BASE = "http://127.0.0.1:1234/v1"            # LM Studio / Ollama / qualquer serviço compatível com OpenAI
MODEL = "your-model"
POSTURES = {"attack", "defend", "creep", "expand", "recover", "hold"}
SYSTEM = """Você é um coach de macro de Warcraft III. Cuida só de economia e estratégia, não de micro.
Produza apenas JSON, com campos fixos: {"diagnosis": uma frase, "workers": {"gold": inteiro, "lumber": inteiro},
"priority": [códigos de quatro caracteres, no máximo 4, só os de allowed], "posture": um dos seis, "avoid": [no máximo 2]}
Baseie tudo nos dados da partida que você recebeu; não invente nada que não esteja nos dados."""


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 jogo: decisões de macro acontecem na escala de minutos, não precisa perguntar a 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:             # lê o snapshot na thread principal; a thread em segundo plano nunca toca em 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                       # conta e descarta — nunca em silêncio
                posture = "hold"
            self.plan = {"gold": min(25, max(2, int(p["workers"]["gold"]))),      # limita
                         "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 / resposta lixo: age como se a camada não existisse
            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 {}   # não usa conselhos antigos demais
        # ↓ camada de regras: com plan vazio, segue as regras padrão; com plan, só ajusta divisão, prioridades e postura — as ordens em si continuam decididas pelas regras
        ...

Como escolher o modelo

SituaçãoRecomendação
Local, precisa ser rápidoModelos MoE (que ativam só uma pequena parte dos parâmetros por chamada) são muito mais rápidos que modelos densos do mesmo tamanho. O cérebro de referência usa Qwen3.6-35B-A3B (LM Studio, Q4): mediana de 1.09 s, a mais lenta 1.45 s, e 5/5 saídas passam direto por json.loads
Modelos locais que “pensam”Você precisa desligar a seção de raciocínio, senão todos os tokens vão para o raciocínio e nenhum JSON sai. O LM Studio ignora /no_think; o cérebro de referência passou a usar /v1/completions, monta o ChatML por conta própria e preenche antecipadamente um <think></think> vazio seguido de um {
Modelos na nuvemA latência costuma ser maior, mas essas camadas já são assíncronas por design; decisões de macro se medem em minutos, então alguns segundos de latência são aceitáveis

O mesmo modelo também pode dar voz às suas unidades: veja Balões de fala e modelos locais. Se você quer que o modelo dê ordens diretamente a cada tick (em vez de atuar como conselheiro), aguarde o gateway JSON da Arena.