Reconstruire un langage de gabarits de tooltips sans documentation, un sort à la fois
Inflige [#1] dégâts, {[+2]?et [$1$1#1] dégâts supplémentaires:sans bonus}. Aucune spec ne décrit ce format, et le client qui sait l'interpréter est fermé.La seule façon d'avancer, c'était d'observer des tooltips rendus dans le jeu, deviner la règle qui explique chacun, et vérifier cette règle contre le cas précis qui l'a fait naître avant de la généraliser.
Un cas réel, puis généraliser
Ça donne une longue traîne de commits qui se ressemblent. La boucle est toujours la même :
- 1
Repérer
Je tombe sur un sort mal rendu. - 2
Isoler
J'isole le token en cause. - 3
Traiter étroit
J'écris le handler le plus étroit possible. - 4
Vérifier
Je vérifie le rendu. - 5
Élargir
J'élargis la règle seulement si un deuxième cas la confirme.
Écrire un moteur de gabarits générique avant de comprendre la grammaire aurait été deviner une spec à l'aveugle. Le vocabulaire qui en ressort couvre quatre familles :
- Paramètre
[#N]lit le Nème paramètre de l'effet courant (base + incrément × niveau).- Navigation
[$C$D#P]navigue vers un enfant à un chemin donné, puis lit son paramètre P.- Ternaire
{[COND]?vrai:faux}un ternaire, avec plusieurs opérateurs de condition.- Math
|expr|une formule arithmétique évaluée inline.
Les opérateurs de condition
+N positif, -N négatif, ~N tuple présent, !N nul ou absent, V>N / V<N comparaison littérale, et un seuil implicite de 1 quand la comparaison n'a pas de valeur à gauche : [>2] veut dire « le paramètre 2 vaut plus de 1 ».
Trois bugs illustrent le genre de piège qu'on ne voit qu'en marchant dedans.
Le repli à un niveau
Un token [$A$B#P] décrit un chemin à deux sauts : entrer dans l'enfant A, puis dans son propre enfant B, lire le paramètre P de ce petit-enfant. Ça marche pour l'immense majorité des sorts. Le problème vient d'une partie des effets enveloppés dans un noeud « groupe », un conteneur transparent qui existe pour des raisons de moteur interne, sans effet visible côté joueur. Quand un titre référence ce genre de structure, le chemin vise une feuille sans petit-enfant : le groupe l'enveloppe directement comme enfant immédiat.
La navigation échoue donc silencieusement, et le token reste affiché tel quel. La correction n'est pas de réécrire la résolution de chemin en profondeur, mais d'ajouter un repli localisé, à l'endroit précis où le token est consommé : si la navigation complète échoue et que le pool de départ ne contient qu'un seul noeud enveloppant, on retente le même chemin un cran plus bas.
// Repli : le chemin visait un petit-enfant, mais le pool de départ
// n'a qu'un seul noeud enveloppant (groupe transparent) qui contient
// déjà la cible comme enfant direct. On retente un niveau plus bas.
if (!target && pool.length === 1 && pool[0].children?.length) {
target = navigateEffectPath(pool[0].children, indices);
}Le piège
Corriger la fonction générale de résolution de chemin pour absorber tous les groupes transparents d'un coup aurait cassé les chemins qui visent réellement un petit-enfant ailleurs dans l'arbre. Le repli ciblé, activé en dernier recours à chaque site d'appel qui en a besoin, évite de régresser sur les cas qui marchaient déjà.
Au prix d'une petite dette : le même repli existe dupliqué à deux endroits du code qui construisent des descriptions d'état, plutôt qu'une seule fois dans la résolution générale. Assumé pour l'instant, plutôt qu'une factorisation prématurée qui devinerait à quoi doit ressembler la version générale.
Des maths qui fuient dans le texte affiché
Le deuxième bug était plus bête et plus visible : les tokens |expr|, censés être évalués comme une formule arithmétique, ne l'étaient tout simplement pas. Le texte affiché au joueur contenait littéralement des fragments comme « Cumulable jusqu'à |20*2| » au lieu de « Cumulable jusqu'à 40 », sans que personne ne s'en rende compte tant ce token est rare et le résultat cassé vaguement lisible.
La correction tient en deux points. L'ordre d'abord : substituer les tokens de paramètre avant d'évaluer les |expr|, sinon on évalue une expression qui contient encore des crochets. La sécurité ensuite : plutôt qu'un eval ou un new Function, on valide que la chaîne ne contient que des caractères arithmétiques sûrs, et seulement alors on l'évalue avec un petit parseur récursif-descendant écrit à la main.
result = result.replace(/\|([^|]+)\|/g, (match, expr: string) => {
if (!/^[\d.\s+\-*/()]+$/.test(expr)) return match; // pas une formule, on laisse tel quel
const value = evalArithmeticExpr(expr);
return value === null ? match : String(Math.floor(Math.abs(value)));
});Détail découvert après coup1 : rien de tout ça n'est écrit nulle part. On l'apprend en voyant un tooltip afficher un fragment de marqueur brut à la place d'un mot.
- Cette passe doit aussi tourner avant la conversion d'autres tokens qui utilisent, eux aussi, la barre verticale comme séparateur, sinon la barre d'un marqueur et celle d'une formule se marient par erreur et avalent tout ce qu'il y a entre les deux. ↩
Le troisième bug ne plantait pas
Les deux premiers bugs avaient un point commun rassurant : ils étaient visibles. Un token non résolu ou une formule non évaluée, ça se voit à l'oeil sur le tooltip, ça se cherche par recherche plein texte, et le correctif se vérifie en relisant le même écran. Le troisième cas ne donnait aucun de ces signaux : le tooltip s'affichait, avec des chiffres plausibles, juste faux.
Le dump stocke effets, sorts et états dans un seul et même espace numérique d'identifiants. Un effet référence son parent par un simple id, sans préciser explicitement de quel type de conteneur il s'agit. La première version de l'indexation regroupait « tous les effets qui pointent vers cet id » comme s'il s'agissait forcément des enfants d'un même effet parent. Sauf qu'un id peut désigner à la fois un effet, un sort ou un état, chacun avec sa propre liste d'enfants légitimes : rien n'empêche qu'un effet porte le même numéro qu'un sort ou qu'un état sans aucun rapport. Résultat : l'indexation greffait les effets top-niveau d'un conteneur sans rapport sur un effet qui ne faisait que partager son numéro. Le tooltip affichait alors une ligne de dégâts empruntée à une tout autre entité, un chiffre crédible, à la bonne échelle, juste emprunté au mauvais endroit.
La correction restreint le regroupement au seul cas structurellement valide : un effet n'est l'enfant d'un autre effet que si son type de parent déclaré est explicitement un « groupe » au sens du moteur. Tout le reste, même quand l'id correspond, est un point d'entrée d'un conteneur différent, pas un enfant.
// Seuls les effets rattachés à un GROUP sont de vrais enfants d'un autre EFFET.
// Tout autre type de parent (SPELL, STATE, ITEM_EQUIP, AREA, ...) signifie que
// l'effet est un point d'entrée top-niveau de ce conteneur, PAS un enfant.
// L'id de parent est partagé entre les espaces effet/sort/état.
if ((e.parentType ?? '').trim() !== 'GROUP') continue; Ce bug-là change la nature du risque. Les deux précédents cassaient une règle trop étroite ou une passe manquante ; celui-ci vient d'une règle qui avait l'air générale (« regrouper par id de parent ») et qui se trompait silencieusement dès qu'un deuxième cas la contredisait, sans jamais planter ni afficher un token brut. C'est la version la plus nette de la thèse de cet article : une règle qui a l'air de généraliser correctement sur le premier cas peut produire, sur le suivant, un résultat qui a l'air correct sans l'être.
L'évaluateur ternaire T/F/U
Le morceau dont je suis le plus content : un évaluateur statique à trois valeurs pour le DSL de critère qui décide si un effet se déclenche. Certains sorts ont des sous-arbres gardés par un critère qui ne peut jamais être vrai hors combat (un décompte d'ennemis, un état posé par un lanceur inexistant hors combat). Le prouver statiquement permet à l'outil de statistiques de sauter ces branches proprement.
Le critère est une petite expression booléenne (and, or, not, comparaisons, appels de fonction). La plupart des identifiants qu'on y trouve ne sont résolvables qu'à l'exécution, donc un évaluateur binaire serait obligé de deviner. Chaque sous-expression réduit plutôt à Vrai, Faux ou Inconnu, avec une algèbre conservatrice sur les cas mixtes :
| and | T | F | U |
|---|---|---|---|
| T | T | F | U |
| F | F | F | F |
| U | U | F | U |
and (logique ternaire T/F/U)| or | T | F | U |
|---|---|---|---|
| T | T | T | T |
| F | T | F | U |
| U | T | U | U |
or (logique ternaire T/F/U)Un and avec un côté Faux vaut Faux quel que soit l'autre côté ; un or avec un côté Vrai vaut Vrai quel que soit l'autre côté ; tout le reste retombe sur Inconnu plutôt que de risquer une fausse certitude.
Cet évaluateur existe en double : le pipeline de construction du dump et le client qui affiche les tooltips sont deux projets séparés, sans package partagé, et chacun a sa propre copie du même algèbre à trois valeurs, maintenue à la main en parité. Une poignée de fonctions ont une valeur connue hors combat, chacune justifiée en commentaire plutôt que traitée comme une évidence :
const KNOWN_FUNCTIONS = {
// Hors combat, aucun ennemi sur le terrain.
GetEnnemyCountInRange: () => ({ kind: 'num', n: 0 }),
// « Appliqué par tel lanceur » suppose un lanceur runtime. Un effet
// accordé par de l'équipement n'a pas de lanceur : structurellement F.
HasStateFromUser: () => F,
};Une mauvaise entrée élague en silence
Une mauvaise entrée dans ce registre élague silencieusement des effets réels : ce n'est pas une erreur visible à l'oeil, c'est une régression qui attend qu'un joueur remarque qu'une stat a disparu. Chaque ajout est donc pesé un par un, et chaque correctif s'accompagne d'un test qui fige le cas réel qui l'a motivé.
Deux gardes-fous : cas nommés et absence de perte
Reconstruire un format à coups de correctifs ciblés ne tient que si chaque correctif reste acquis. Deux disciplines s'en chargent, à deux échelles différentes.
Cas nommés (tests)
Une suite Vitest côté client : un it() par cas de tooltip réel rencontré en production, nommé d'après ce cas précis plutôt que d'après le mécanisme testé. Chaque bug de cet article a son test, qui échouerait si le correctif régressait.
Absence de perte (diff)
Avant tout changement qui touche au texte : rendre l'intégralité des sorts connus, une fois avant, une fois après, et differ ligne à ligne. La preuve cherchée n'est pas « ça a l'air mieux », c'est zéro ligne disparue, zéro ligne apparue hors de la cible.
Seul le client porte la suite de tests, et c'est assumé : c'est là que les régressions sont le plus coûteuses, puisqu'elles sont visibles par un joueur. Le pipeline de construction, lui, est un script qu'on relance et dont on relit la sortie. Les deux disciplines se complètent : le test nommé fige un cas contre l'oubli, l'absence de perte prouve qu'un changement qui vise un cas n'en a pas silencieusement cassé cinquante autres qu'on n'a pas pensé à lister.
Élaguer tôt casse, filtrer tard répare
La première version du pipeline élaguait à la construction : un effet dont le critère est littéralement la chaîne « Faux » était retiré du dump, sous-arbre compris. Ça a cassé un vrai tooltip en production, car son critère n'était pas juste « Faux » mais une conjonction terminée par « ... et Faux » : l'effet ne se déclenche jamais en jeu, correct, mais sa description textuelle restait censée s'afficher dans l'onglet des effets du sort. En le supprimant du dump, j'avais supprimé le texte avec.
Le deuxième accident vient d'une autre règle d'élagage, celle qui retire les effets marqués « ne pas afficher dans la description ». Raisonnable en apparence, sauf que certains titres parents référencent justement les paramètres de ces effets masqués via un token de chemin. Retirer l'enfant masqué laissait un token orphelin dans le titre du parent, et l'affichage de la ligne s'effondrait entièrement.
Les deux régressions pointaient dans la même direction : décider à la construction ce qui est sûr à retirer demande de prévoir tous les usages futurs d'un noeud, y compris ceux qu'on ne voit pas encore parce qu'un autre noeud le référence à distance. La décision retenue a été de ne plus jamais élaguer dans le dump : l'arbre complet part tel quel, critères et effets masqués compris. Le filtrage se fait au runtime, côté consommateur, et chaque consommateur garde sa propre philosophie. Le calcul de statistiques saute les branches via le même évaluateur T/F/U, parce que pour des chiffres, se taire sur l'incertain est le choix sûr. Le rendu de texte, lui, affiche tout ce qu'il peut, parce que pour une description lisible, mieux vaut trop de texte qu'une ligne manquante.
Deux autres exemples suivent la même logique côté texte. Certains effets ont un gabarit générique par-action et un override par-effet plus spécifique qui l'emporte : quand cet override, écrit pour un autre contexte, embarque un token de nom d'état qui n'a rien à résoudre ici, coller le gabarit générique en queue de chaîne inverse l'ordre des mots. La correction substitue le gabarit en place du token défaillant, pas en queue, pour que les mots qui suivent gardent leur position. Et quand un titre par-effet résume déjà l'état posé, les lignes brutes des effets deviennent redondantes à l'affichage en ligne : plutôt que les supprimer (encore la même erreur), on les déplace vers la carte au survol, et seulement si elles y sont démontrées présentes mot pour mot une fois normalisées.
La bonne question
Au lieu de choisir entre garder et supprimer une information, la bonne question est de savoir quel consommateur doit la recevoir.
Ce que j'en retiens
À retenir
Reconstruire un format propriétaire sans spec ressemble à du débogage à l'envers : au lieu de partir d'une règle connue et de trouver où elle casse, on part d'un rendu cassé et on remonte vers une règle inconnue. Généraliser trop tôt, avant d'avoir vu au moins deux cas réels, produit une règle qui a l'air propre et qui se trompe sur le troisième cas. Et la seule chose plus dangereuse qu'un token mal interprété, c'est une décision d'élagage prise trop tôt dans le pipeline : elle ne se contente pas d'afficher un mauvais texte, elle supprime l'information qui aurait permis de le corriger plus tard.