Docs Faire écrire un Bot par l’IA

Un LLM comme conseiller stratégique

Confiez à un LLM les questions « que faut-il économiser, où affecter les ouvriers, faut-il attaquer ou temporiser cette minute », et laissez la couche de règles se contenter d'exécuter et d'opposer son veto. Le cerveau de référence fonctionne déjà ainsi ; cette page en explique le modèle et les pièges.

Passé un certain stade, vous remarquerez que les règles d’économie de votre Bot s’empilent les unes sur les autres : une règle pour le nombre de bûcherons, une pour 5 ouvriers par mine, une pour réduire le bois de moitié quand il y en a trop, une pour envoyer plus de monde à l’or quand l’or manque et que le bois abonde… Chacune est juste prise isolément, mais ensemble elles produisent des situations comme « la mine manque d’ouvriers alors que tous les paysans coupent du bois », dont aucune règle n’est responsable.

Ce genre de jugement, « regarder l’ensemble et fixer des priorités », ne se prête pas à des if / else, mais c’est exactement ce que les LLM font bien. Le cerveau de référence (brains/xwar3/strategy/brain/coach.py) utilise le découpage en couches ci-dessous.

Couches

LLM (conseiller)        Une fois toutes les 20 secondes de jeu, asynchrone, ne bloque jamais un tick
  Entrée : un instantané d'une page de la partie (ressources, nourriture, répartition des paysans, mines, unités, technologies, héros, renseignements sur l'ennemi, événements récents)
  Sortie : JSON strict — un diagnostic en une phrase + répartition des ouvriers + quoi produire en priorité + posture de cette minute + choses à éviter
        │
        ▼  liste blanche + bornage min/max + veto
Couche de règles (Bot, à chaque tick)   Traduit les conseils en « biais » sur les capacités existantes : répartition des ouvriers, priorités de construction / d'entraînement, posture d'attaque
        │
        ▼
Couche d'exécution (SDK / couche réflexe)   Donne les ordres, lit les reçus, fait la micro

Contrat de sortie

Faites en sorte que le modèle ne produise qu’un JSON aux champs fixes, sans ajout ni omission :

{
  "diagnosis": "Une phrase : le plus gros problème de la partie, qui doit s'appuyer sur les données d'entrée",
  "workers":   { "gold": 10, "lumber": 6 },
  "priority":  ["hpea", "hhou", "hbar"],
  "posture":   "creep",
  "avoid":     ["Ne recherchez pas Iron Plating en premier si le bois manque"]
}
ChampUsage par la couche de règlesBornage du cerveau de référence
workersNombre cible d’ouvriers à l’or et au boisOr 2 ~ 25, bois 1 ~ 20 ; la somme ne peut pas dépasser le nombre total de paysans
priorityOrdre de priorité pour l’entraînement / la construction / la recherche4 au maximum ; seuls les codes à quatre caractères présents dans la table des « codes autorisés » sont acceptés
posturePosture de cette minuteUniquement l’une des valeurs attack defend creep expand recover hold
avoidChoses à ne pas faire cette minute2 au maximum
diagnosisSert uniquement aux journaux et à l’affichage dans la console—

Prévoyez un prompt par race, qui ne couvre que les arbitrages propres à cette race (la construction coopérative et la Milice des Humains, les Terriers des Orcs, la Mine d’or hantée des Morts-vivants, la Mine d’or enchevêtrée des Elfes de la nuit…). Placez les règles générales dans une partie commune, ne les recopiez pas quatre fois.

Quatre contraintes strictes

Le cerveau de référence a appris chacune d’elles à ses dépens :

  1. Le conseiller ne donne jamais d’ordre direct aux unités. Il ne voit pas ce qui se passe à l’échelle de 150 ms, et il hallucine. Il ne modifie que les objectifs et les priorités ; qui va où et qui attaque qui reste décidé par la couche de règles et la couche réflexe — le commandement ne peut avoir qu’un seul maître.
  2. Asynchrone. Un appel au conseiller prend environ 1 seconde et tourne dans un thread d’arrière-plan ; le résultat le plus récent s’applique, et il ne bloque jamais un tick. Si le modèle n’est pas démarré, dépasse le délai ou répond n’importe quoi, faites comme si cette couche n’existait pas : le comportement revient aux règles pures. Un conseil trop ancien (plus de 3 intervalles) n’est pas utilisé non plus.
  3. Liste blanche + bornage. Chaque champ doit correspondre à une capacité existante, et les valeurs numériques sont bornées dans une plage raisonnable. Tout contenu non reconnu est compté puis rejeté, pas ignoré en silence.
  4. Tout compter. Combien de requêtes, combien de succès, combien de délais dépassés, combien de bornages, combien de fois chaque champ a été retenu : publiez tout cela, avec la dernière entrée envoyée au modèle. Sinon, « cette couche sert-elle vraiment à quelque chose ? » reste une question sans réponse.

Ce qui se dégrade sans danger est aussi ce qui se dégrade le plus discrètement

Le conseiller est conçu pour que « échec = faire comme si cette couche n’existait pas » ; quand le service du modèle ne tourne pas, le Bot se comporte donc exactement comme avec des règles pures, et rien n’y paraît de l’extérieur. Le cerveau de référence a ainsi passé une journée entière avec le conseiller injoignable sur les 6 instances, sans que personne ne s’en aperçoive. Publiez toujours « l’heure du dernier succès » et « la raison du dernier échec » — c’est précisément le rôle de la page « Conseiller stratégique » de la console Farsight.

Implémentation dans votre propre Bot

Voici un squelette minimal qui fonctionne avec n’importe quelle API compatible OpenAI (LM Studio, Ollama ou une API cloud) et n’utilise que la bibliothèque standard :

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

BASE = "http://127.0.0.1:1234/v1"            # LM Studio / Ollama / tout service compatible OpenAI
MODEL = "your-model"
POSTURES = {"attack", "defend", "creep", "expand", "recover", "hold"}
SYSTEM = """Tu es un coach de macro pour Warcraft III. Tu ne t'occupes que de l'économie et de la stratégie, pas de la micro.
Réponds uniquement en JSON, avec des champs fixes : {"diagnosis": une phrase, "workers": {"gold": entier, "lumber": entier},
"priority": [codes à quatre caractères, 4 au maximum, uniquement parmi allowed], "posture": une valeur parmi six, "avoid": [2 au maximum]}
Appuie-toi uniquement sur les données de partie fournies ; n'invente rien qui n'y figure pas."""


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                              # secondes de jeu : la macro se décide à l'échelle de la minute, inutile de demander à chaque 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:             # lire l'instantané dans le thread principal ; le thread d'arrière-plan ne touche jamais 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                       # compter puis rejeter, jamais en silence
                posture = "hold"
            self.plan = {"gold": min(25, max(2, int(p["workers"]["gold"]))),      # bornage
                         "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:                                       # délai dépassé / réponse incohérente : faire comme si cette couche n'existait pas
            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 {}   # ne pas utiliser un conseil trop ancien
        # ↓ couche de règles : sans plan, suivre les règles par défaut ; avec un plan, n'ajuster que la répartition, les priorités et la posture — les ordres concrets restent décidés par les règles
        ...

Choisir un modèle

SituationRecommandation
En local, besoin de vitesseLes modèles MoE (qui n’activent qu’une petite partie de leurs paramètres à chaque appel) sont bien plus rapides que les modèles denses de même taille. Le cerveau de référence utilise Qwen3.6-35B-A3B (LM Studio, Q4) : médiane 1.09 s, pire cas 1.45 s, et 5/5 sorties passent directement dans json.loads
En local, modèle qui « réfléchit »Désactivez impérativement la section de réflexion, sinon tous les tokens partent dans la réflexion et aucun JSON ne sort. LM Studio ignore /no_think ; le cerveau de référence est passé à /v1/completions, construit lui-même le ChatML et pré-remplit un <think></think> vide suivi d’un {
Modèle cloudLa latence est généralement plus élevée, mais ce découpage est asynchrone par conception ; les décisions de macro se comptent en minutes, quelques secondes de latence sont acceptables

Le même modèle peut aussi donner la parole à vos unités : voir Bulles de dialogue et modèles locaux. Si vous voulez que le modèle donne directement les ordres à chaque tick (au lieu de jouer les conseillers), attendez la passerelle JSON de l’Arène.