Simuler avant de frapper : comment mon bot choisit ses combats
Luchamon Bot ne devine pas quel adversaire affronter — il interroge le moteur de combat du jeu lui-même, encore et encore, et joue l'option la moins pire. Plongée dans la sélection pilotée par simulation, les probabilités de victoire en Monte-Carlo, et l'économie du « ne jamais gâcher une énergie ».
Luchamon Bot joue à un auto-battler entièrement on-chain sur Base. Les règles qui comptent ici sont simples : chacun de mes Luchamons reçoit sept énergies par semaine, et chaque combat démarre pareil — le jeu me propose huit adversaires au hasard et j'en choisis un. Que je gagne ou que je perde, l'énergie est dépensée. Et l'énergie non utilisée s'évapore au reset hebdomadaire.
Du point de vue d'un bot, tout le jeu se réduit donc à une seule question, posée des milliers de fois par semaine : parmi ces huit, lequel j'affronte ? Cet article raconte comment j'y réponds — et pourquoi la réponse s'est avérée plus étrange que « celui que j'ai le plus de chances de battre ».
La mauvaise réponse, tentante
Ma première version était une heuristique. Luchamon a une table des types — un matchup vert donne un petit bonus de dégâts, un rouge joue contre toi — alors je me suis appuyé dessus : préférer le vert, éviter le rouge, viser un adversaire coté un peu en dessous de moi, pénaliser légèrement les lignes de stats suspectes.
Ça marchait. Et ça ignorait presque tout ce qui décide d'un combat.
L'issue d'un combat dépend du niveau, de quatre stats, de trois skills et d'un passif — et de la façon dont tout ça interagit. Le type est une entrée parmi d'autres, et pas la dominante. Un adversaire « vert tranquille » avec un combo de skills brutal te termine ; un « rouge » aux skills pourris est gratuit. Une heuristique bâtie surtout sur la table des types, c'est deviner avec des étapes en plus. Je voulais arrêter de deviner.
Demander au jeu, pas le modéliser
Voici le déclic qui a redessiné le projet : je n'ai pas à modéliser le combat. Le jeu contient déjà un modèle parfait de lui-même — sa résolution de combat est déterministe, et elle est lisible on-chain sans envoyer de transaction ni rien dépenser. Je lui donne un matchup, il me dit qui gagne. Pas une approximation du moteur. Le moteur.
Ça a tué d'un coup l'alternative que je redoutais : réécrire la logique de combat en TypeScript. Porter la logique de jeu de quelqu'un d'autre est un piège — c'est correct exactement jusqu'à leur prochaine mise à jour, après quoi c'est silencieusement faux et tu ne le découvres qu'en perdant. Lire la vraie chose, c'est ne jamais pouvoir diverger du jeu. Quoi qu'il fasse, je le fais.
Il y a une seule entourloupe, et c'est la plus intéressante.
Une entourloupe : les dés ne sont pas encore lancés
Un combat Luchamon n'est pas entièrement déterminé par les deux builds. Il dépend aussi d'une graine aléatoire — tirée au moment du combat, par l'aléa vérifiable de la chaîne, et inconnue quand je choisis mon adversaire. Donc demander « qui gagne ? » ne renvoie pas un oui ou un non. Ça renvoie un oui-ou-non pour un lancer de dés précis.
La solution est évidente une fois dite à voix haute : ne pas demander une fois, demander plusieurs fois. Rejouer le même matchup contre un éventail de graines et compter. Si un adversaire me fait gagner 39 combats sur 40 lancers simulés, c'est un matchup à ~98 %. C'est de la simple estimation Monte-Carlo, et ça transforme un oracle à pile ou face en probabilité :
interface WinProbResult {
oppId: bigint;
wins: number;
samples: number; // simulations réussies (erreurs RPC exclues)
errors: number;
prob: number; // wins / samples, dans [0, 1]
}
Ce champ samples a l'air d'être de la comptabilité. C'est en fait la ligne la plus importante du type, et elle m'a coûté un bug avant que je la respecte.
« Inconnu » n'est pas « défaite sûre »
Chaque lancer simulé est une lecture réseau, et les lectures réseau échouent. Quand l'une échoue, je n'ai pas le droit de la compter — donc samples est le nombre de lancers qui sont réellement revenus, pas le nombre que j'ai demandé. Ce qui veut dire que prob = wins / samples peut paraître confiant sur un dénominateur minuscule : deux lectures réussies, deux victoires, « 100 % ».
Pire, le cas zéro. Si toutes les lectures d'un adversaire ont échoué, wins / samples vaut 0 / 0 — que je reporte comme prob = 0. Lu naïvement, c'est « défaite garantie, ne jamais le prendre ». En réalité ça veut dire « je n'en sais rien ». Ce sont des conclusions opposées, et les confondre fait jeter au bot des combats gagnables parce que le RPC a hoqueté.
Donc la règle, partout en aval : une probabilité n'a le droit de voter que si elle s'appuie sur assez d'échantillons réussis, et samples === 0 est une absence de donnée, pas un verdict. Une incertitude honnête vaut mieux qu'un chiffre confiant bâti sur du vide.
La rendre assez bon marché pour rester honnête
Il y a une tension dans le Monte-Carlo : la confiance vient des échantillons, et ici chaque échantillon a un coût. L'astuce qui a récupéré l'essentiel de la précision, c'est l'échantillonnage apparié (paired sampling).
En vrai, le taux de victoire de chaque adversaire dans l'absolu ne m'intéresse pas — ce qui m'intéresse, c'est lequel est meilleur. Alors plutôt que de tirer de nouveaux dés pour chacun des huit candidats, je tire un seul jeu de dés et je rejoue les huit contre les mêmes graines. Le bruit de graine à graine est maintenant partagé entre les candidats, donc quand j'en compare deux, ce bruit commun s'annule. La variance de la différence — la chose sur laquelle je classe — s'effondre, et j'obtiens la même confiance de classement avec bien moins de lancers.
Le même générateur ensemencé me donne une seconde propriété gratuitement : la reproductibilité. Je l'ensemence par Luchamon et deux passes de l'analyse renvoient la même recommandation au lieu de dériver dans le bruit Monte-Carlo. Une recommandation qui vacille à chaque fois que tu la demandes, tu finis par ne plus lui faire confiance.
Le twist : jouer le moins pire
Maintenant la partie que je n'avais pas vue venir. Une fois les probabilités de victoire en main, la politique évidente est « affronte celui que tu as le plus de chances de battre, et si aucun n'est bon, passe ton tour ». J'ai livré exactement ça. C'était faux.
Deux faits sur le jeu cassent l'intuition :
- Le perdant gagne quand même du $LUCHA. Un combat perdu n'est pas un combat gâché économiquement — il paie encore.
- L'énergie, c'est use-it-or-lose-it. Ce que je ne dépense pas avant le reset hebdo est perdu.
Mets les deux ensemble et s'abstenir est le seul vrai mauvais résultat. Une défaite me coûte ~15 ELO et rapporte quand même des tokens ; passer son tour ne rapporte rien et l'énergie s'évapore. Le bot a donc arrêté de passer son tour. Il joue désormais toujours le meilleur adversaire disponible — même quand « meilleur disponible » veut dire un pile ou face, même quand ça veut dire le moins pire de huit mauvais choix.
La seule chose qui reste à faire pour un seuil, c'est exprimer un appétit, pas une permission :
type SelectionOutcome =
| { kind: "sim"; opp: OpponentProfile; prob: number; samples: number }
| { kind: "fallback"; opp: OpponentProfile }
| { kind: "skip"; reason: string };
Deux objectifs se greffent par-dessus. safe prend la victoire la plus sûre. aggressive grimpe au classement : parmi les adversaires au-dessus d'une barre de confort, il prend le plus fort, troquant un peu de probabilité de victoire contre de l'ELO. Le seuil gouverne cette éligibilité — ce n'est plus une porte qui fait croiser les bras au bot. La seule chose qui produit encore un skip, c'est un plancher dur, et ce plancher vaut zéro par défaut : ne jamais s'abstenir, sauf si je le relève explicitement pour protéger l'ELO sur un pool désespéré.
Replis, fidélité, et ce que je surveillerais
La simulation est le défaut, pas toute l'histoire. Quand les lectures échouent en masse, ou qu'aucun adversaire ne passe la barre du minimum d'échantillons, la sélection se rabat sur l'ancienne heuristique green-first — une moins bonne réponse vaut mieux qu'un crash, et fallback est un résultat de première classe plutôt qu'une erreur. Cette vieille heuristique a gagné une seconde vie comme filet de sécurité.
Un caveat honnête que je garde signalé dans le code : la simulation lit le build courant de chaque adversaire. La source strictement correcte est le build figé pour la semaine, qui peut différer pour un adversaire qui vient de se re-spec. Pour les adversaires stables et bas niveau, ils sont identiques, et la vérif empirique a tenu — un matchup que la simulation donnait à 100 % a été gagné pour de vrai — donc j'ai laissé la bascule vers le snapshot figé comme un réglage de fidélité futur, à revisiter le jour où je verrai la simulation et la réalité diverger. Mesurer cette dérive, plutôt que la supposer inexistante, c'est la partie que je dirais à quiconque construit ça de prendre au sérieux.
L'addition
Tout ça a un coût que j'ai soigneusement contourné jusqu'ici : chaque décision, c'est des dizaines à des milliers de lectures on-chain, et une analyse complète sur les 49 Luchamons grimpe à plusieurs centaines de milliers. Demander au jeu qui gagne est gratuit à l'appel et ruineux en cumulé. Rendre ça abordable — sans payer un RPC plus costaud ni faire tourner un nœud complet — est une histoire à part entière, et c'est celle que je raconterai ensuite : comment un fork local de la chaîne a transformé une calibration de quarante minutes en moins d'une minute.
— Pato