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"]
}
| Champ | Usage par la couche de règles | Bornage du cerveau de référence |
|---|---|---|
workers | Nombre cible d’ouvriers à l’or et au bois | Or 2 ~ 25, bois 1 ~ 20 ; la somme ne peut pas dépasser le nombre total de paysans |
priority | Ordre de priorité pour l’entraînement / la construction / la recherche | 4 au maximum ; seuls les codes à quatre caractères présents dans la table des « codes autorisés » sont acceptés |
posture | Posture de cette minute | Uniquement l’une des valeurs attack defend creep expand recover hold |
avoid | Choses à ne pas faire cette minute | 2 au maximum |
diagnosis | Sert 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 :
- 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.
- 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.
- 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.
- 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
| Situation | Recommandation |
|---|---|
| En local, besoin de vitesse | Les 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 cloud | La 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.