Apparence
Journal des décisions
Chaque entrée : la décision, l'alternative écartée, la raison. À consulter avant de reproposer une option — la plupart des « bonnes idées » listées ici ont déjà été examinées et rejetées pour des motifs précis.
Index des décisions
Les 118 décisions actées, D-01 → D-118. Aller directement à l'entrée voulue en cherchant son titre (### D-64) — ce fichier n'est pas fait pour être lu en entier.
Conception du jeu — D-01 → D-20
- D-01 · Mode de jeu : synchrone temps réel
- D-02 · Combat : attaque libre + Provocation
- D-03 · Ressource : « Ferveur », auto-ramp, plafond 10
- D-04 · Avantage du second joueur : +1 Ferveur temporaire, tour 1 uniquement
- D-05 · Deck de 30, 3 copies max (2 épique, 1 légendaire)
- D-06 · Trois types de cartes : Créature / Sort / Permanent
- D-07 · Mot-clé « Fougue » plutôt que « Charge »
- D-08 · « Piège / Secret » reporté en extension
- D-09 · Héros = un vrai personnage WoW du joueur
- D-10 · Terrain non ordonné en v1
- D-11 · Set de lancement de 120 cartes
- D-12 · Sous-types à deux axes : rang × rôle
- D-13 · Le rang détermine le coût, pas la rareté
- D-14 · Deux légendaires par set
- D-15 · Évolution des membres entre extensions
- D-16 · Numérotation façon Magic
- D-17 · Classes/factions reportées, mais préparées
- D-18 · Ordre de développement : schéma → générateur → moteur
- D-19 · Multi-prototypes : l'actif durable est la donnée
- D-20 · Cartes « Enluminées » (shiny) — cosmétique, non achetables
P0 — décisions de schéma — D-21 → D-28
- D-21 · Identifiants techniques en anglais, libellés en français
- D-22 · Versioning : un fichier par version
- D-23 · Une capacité porte une SÉQUENCE d'effets
- D-24 · Le rang sort de
subtypes - D-25 · L'extension n'est référencée que par son id
- D-26 ·
GAIN_MANArenomméeGAIN_FERVOR, avec un mode - D-27 · La durée
AURAest imposée par le schéma - D-28 · Un dossier
tools/en Node, hors de l'actif durable
Lacunes de règles révélées par P0 — D-29 → D-34
- D-29 · Le héros est passif : il n'attaque jamais (v1)
- D-30 · Un seul choix de cible par capacité
- D-31 · Les seuils de Ferveur lisent
ferveurMax - D-32 · Aucun dégât de zone dans le pool neutre
- D-33 · Invocation sur terrain plein : les surnuméraires sont perdus
- D-34 · Étalon de valeur d'effet :
1,5 × coût + 1
Publication — D-35 → D-35
- D-35 · Le site de documentation est le même projet que P1, dans le même dépôt
P2 — règles de résolution (tranche 0) — D-36 → D-50
- D-36 · Main de 10 cartes maximum ; la surpioche brûle
- D-37 · Les déclenchements simultanés se résolvent par ordre d'entrée en jeu
- D-38 · Les morts sont constatées après chaque effet ; les Râles se résolvent entre les séquences
- D-39 · Durabilité et charges : ce qui les décrémente
- D-40 · L'Armure absorbe tout, et « perdre des PV » n'existe pas
- D-41 · Aucune cible légale : la carte reste jouable, l'effet est perdu
- D-42 · Mise en place, victoire, égalité, abandon
- D-43 · L'état de partie parle anglais
- D-44 · Furtif : invisible pour l'adversaire seulement, rompu par sa propre attaque
- D-45 · Létal : dégâts de combat, entre créatures, un point suffit
- D-46 · Bouclier : une instance entière, sans compter comme des dégâts
- D-47 · « Blessée » = des dégâts marqués ; l'aura qui disparaît peut tuer
- D-48 ·
GAIN_FERVOR MAX: un cristal vide ; plafond absolu 10 partout - D-49 · Le sujet et le moment de chaque déclencheur ; précisions de combat
- D-50 · La sémantique des primitives, arrêtée une fois pour toutes
P2 — le format exécutable (tranche 1) — D-51 → D-52
- D-51 · Le hasard : mulberry32, seedé, consommation normée
- D-52 · Le format des scénarios : état épars, assertions partielles, sous-séquence d'événements
P2 — le kit (tranche 2) — D-53 → D-54
- D-53 · Les scénarios de mise en place
- D-54 · Menues sémantiques normées par le kit
Architecture d'exécution (tranche 3 → P3) — D-55 → D-58
- D-55 · TypeScript de bout en bout ; les choix d'infrastructure de P3 sont différés
- D-56 · Le contrat d'hermétisme du moteur
- D-57 · Espace de travail npm et dépôt unique — la scission est différée, pas exclue
- D-58 · Statut du client, et son contrat de remplaçabilité
P2 — le moteur (tranche 3) — D-62 → D-63
- D-62 · Les sémantiques que le kit ne fixe pas, arrêtées par le moteur de référence
- D-63 · Le juge du kit est unique et partagé ; la frontière D-56 porte sur le paquet livré
Corpus — la phase de brouillon (2026-08-04) — D-59 → D-61
- D-59 · Le versionnement suit le statut du set, pas le calendrier
- D-60 · Aucun jeton ne porte le nom d'un rang
- D-61 · Quatre légendaires par set, deux par deck
P3 — cadrage du duel en ligne (tranche 0, 2026-08-05) — D-64 → D-74
- D-64 · Le cœur du serveur est pur lui aussi ; NestJS + Socket.IO n'est qu'un adaptateur
- D-65 · L'auteur d'une intention est vérifié par le serveur, jamais par le moteur
- D-66 · La vue filtrée est un masquage qui produit un état de partie valide
- D-67 · Une partie est un journal ; l'état n'est jamais persisté
- D-68 · Le temps entre par un seul point, et il sort en données
- D-69 · La légalité d'un deck est une fonction pure, à côté du moteur mais hors de sa boucle
- D-70 · Le mulligan : deux appels à
setupMatch, aucune API nouvelle - D-71 · Le protocole : intentions et événements, numérotés et idempotents
- D-72 · Le salon : un code opaque, deux sièges, rien d'autre
- D-73 ·
server/, espace de travail npm, catalogue lu sur disque au démarrage - D-74 · Le client de duel est en Vue ; Three.js est une couche à prouver, pas une fondation
P3 — tranche 1, le cœur pur (2026-08-06) — D-75 → D-77
- D-75 · La vue filtrée masque aussi le futur : son propre deck et l'état du générateur
- D-76 · Toute poussée d'état embarque la vue filtrée ; l'état complet reste interdit
- D-77 · Trois noms stables sous guilde-lgc.fr ; l'hébergement migre derrière eux
P3 — tranche 2, l'adaptateur (2026-08-06) — D-78 → D-83
- D-78 · Le compteur de séquence appartient au serveur :
lastSeqvoyage dans chaque poussée - D-79 · Entrer dans une partie n'est pas authentifié ; y revenir l'est — le jeton de siège
- D-80 · Les banques de temps sont abandonnées : 75 s par tour, et rien d'autre
- D-81 · Les échéances se relisent dans la session ; l'outbox n'en est que la notification
- D-82 · La reprise est à la demande, et le journal s'écrit avant l'acquittement
- D-83 · Le serveur refuse aussi ce que le moteur laisse passer : les types d'intention inconnus
P3 — tranche 3, le client de duel (2026-08-06) — D-84 → D-87
- D-84 · Three.js est écarté pour le moment ; la maquette n'est pas faite, elle est reportée
- D-85 · Le fil (
wire.ts) remonte dans@lgc/server: un protocole, une déclaration - D-86 ·
@lgc/card-render: le second consommateur existe, D-58.3 s'applique - D-87 · Combien de cibles une intention demande est une question de donnée, tranchée par le moteur
P3 — la refonte visuelle du client (2026-08-07) — D-88 → D-92
- D-88 · Le plateau est une scène de dimensions fixes, mise à l'échelle par
zoom - D-89 · Le clic et le glisser sont le même état ; le geste ne décide jamais de la légalité
- D-90 · Deux dépôts distincts : on POSE sur son terrain, on VISE avec une flèche
- D-91 · Quelles cibles un choix accepte est une question de moteur, comme le combien
- D-92 · Trois registres de récit : la bande, les toasts, le journal
P4 — les animations du plateau (2026-08-08) — D-93 → D-96
- D-93 · Les animations sont un récit joué sur les événements ; l'état ne les attend jamais
- D-94 · Une couche d'effets au-dessus de la scène : rien de ce qui bouge n'est cliquable
- D-95 · Les positions viennent d'un registre d'ancres relevé après chaque rendu
- D-96 ·
prefers-reduced-motionest l'unique interrupteur, et c'est lui que le banc e2e active
Outillage — la génération des visuels (2026-08-09) — D-97
- D-97 · Les illustrations se génèrent en ligne de commande, depuis Notion, vers
data/art/
P4 — le corpus de cartes (2026-08-09) — D-98 → D-99
- D-98 · Le rang Adepte porte les rerolls, et devient la famille tribale basse du set
- D-99 · Le set de lancement est complet à 120 cartes, et trois cartes-témoins de P0 changent d'identité
- D-100 · Les captures de personnages arrivent par convention de nom, appariées sur le nom du personnage
Outillage — la direction artistique (2026-08-10) — D-101 → D-104
- D-101 · La direction artistique est pré-remplie, en texte libre et sans validation
- D-102 · Notion est débranché : l'atelier se lit par un export CSV
- D-103 · L'emblème de guilde entre par la colonne Référence — un nom, jamais un chemin
- D-104 · La colonne « Classe » entre au classeur, et n'est câblée nulle part
P4 — la refonte classe (2026-08-10) — D-105 → D-106
- D-105 · La classe donne la saveur, en primitives neutres ; l'axe Adepte s'étoffe
- D-106 · Le Furieux passe du forum au Discord — second renommage d'id consenti
P4 — l'ambiance sonore (2026-08-10 → 12) — D-107 → D-109
- D-107 · Le son dérive de la partition visuelle, et le mixage vit dans le client
- D-108 · Un son absent est synthétisé, jamais silencieux — et le son a son propre interrupteur
- D-109 · Les sons abstraits sont forgés, pas trouvés — et le foley reste à enregistrer
P4 — le set rouvert (2026-08-12) — D-110
- D-110 · Le set de lancement passe de 120 à 127 cartes
P4 — les decks fournis (2026-08-13) — D-111
- D-111 · Un deck fourni par plan de jeu, et chaque carte servie AU MOINS une fois
P4 — retours de partie (2026-08-13) — D-112
- D-112 · Une créature de la main sur terrain plein est injouable
P4 — l'éditeur de decks (2026-08-13) — D-113
- D-113 · Un éditeur de decks dans la doc, et le code « LGC1. » comme pont vers le client
P4 — retours du testeur (2026-08-14) — D-114
- D-114 ·
countsur un sélecteur : n cibles DISTINCTES, et le schéma fait foi - D-115 · La surface du DSL est de la DONNÉE, et « Ninja loot » fonctionne enfin
- D-116 · Une carte piochée vaut 2 points d'effet, pas 1,4
- D-117 · Un seul format de liste de deck, et un parseur partagé
- D-118 · Un Cri de guerre ne part que de la main
Conception du jeu
Les vingt décisions qui cadrent le jeu lui-même — prises avant tout schéma et toute ligne de code.
D-01 · Mode de jeu : synchrone temps réel
Écarté : asynchrone tour par tour, ou les deux. Raison : l'asynchrone interdit toute mécanique réactive et casse le rythme d'un duel. Le coût est un service stateful (WebSocket), assumé.
D-02 · Combat : attaque libre + Provocation
Écarté : modèle Magic (déclaration d'attaque puis assignation de bloqueurs). Raison : le blocage double la complexité du moteur et de l'UI pour une profondeur dont un jeu de guilde n'a pas besoin au lancement.
D-03 · Ressource : « Ferveur », auto-ramp, plafond 10
Écarté : cartes-terrain façon Magic (diluent le deck, créent le mana screw) ; le terme « Cristal » (générique) ; « Élan », « Cohésion », « Renom ». Raison : la triade Sceaux / Ferveur / PV ne présente aucun chevauchement sémantique. Le plafond (10) est le bouton de rythme à ajuster en premier si les parties traînent.
D-04 · Avantage du second joueur : +1 Ferveur temporaire, tour 1 uniquement
Écarté : +1 Ferveur permanent (le J2 gagnerait ~65 % des parties — un tour d'avance sur la courbe est l'avantage le plus décisif du genre) ; une carte « Pièce » (collision de vocabulaire avec les Sceaux). Implémentation : alimente ferveurActuelle, ne touche pas à ferveurMax.
D-05 · Deck de 30, 3 copies max (2 épique, 1 légendaire)
Écarté : 20 cartes / 2 copies (envisagé pour un pool de 35 cartes, rendu caduc par le passage à 120). Vigilance : 3-of sur 30 cartes = très forte consistance. Les combos à deux cartes sont bien plus fiables qu'en Hearthstone.
D-06 · Trois types de cartes : Créature / Sort / Permanent
Écarté : faire des permanents un sous-type de sort (moins lisible en code comme pour le joueur) ; armes et équipements (doublent les règles de combat) ; terrains ; cartes-héros alternatives.
D-07 · Mot-clé « Fougue » plutôt que « Charge »
Écarté : Charge (attaquer le héros dès l'arrivée). Raison : transforme n'importe quel buff en dégâts directs incontrables et rend les combos létales inanticipables. Fougue (attaquer une créature seulement) en garde 90 % de la sensation. Si un effet Charge est souhaité un jour, une seule légendaire.
D-08 · « Piège / Secret » reporté en extension
Raison : exige un système de priorité au milieu de la résolution adverse, et une information cachée dont l'existence est publique — risque réel de fuite via l'API, et opacité pour un débutant. Le système trigger + condition permet de l'ajouter plus tard sans refonte.
D-09 · Héros = un vrai personnage WoW du joueur
Contraintes fermes :
- Aucune donnée de puissance en jeu (ilvl, hauts faits, score M+, progression raid) — cosmétique uniquement, sinon les raiders écrasent les casuals et l'objectif social est mort.
- La race est cosmétique (un bonus racial forcerait le choix et viderait de son sens le « c'est mon perso »).
- Snapshot à la liaison : transferts, renommages et suppressions cassent les liens Battle.net.
- Duel amical = toute classe libre ; tournoi = perso réel. Évite le mur du reroll.
D-10 · Terrain non ordonné en v1
Conséquence à respecter : aucune carte « adjacent » ou « à gauche/droite » dans le set de lancement. Le champ position existe, nullable. Ajouter le positionnement plus tard est indolore côté données, coûteux côté UI.
D-11 · Set de lancement de 120 cartes
Écarté : 35 cartes (deckbuilding illusoire, collection complétée en trois semaines, Sceaux sans usage). Méthode de production : designer par cycles (un effet décliné sur plusieurs coûts — 10 à 12 cycles couvrent la moitié du set) et par archétypes (4 × ~20 cartes + 40 flexibles). Règle de coupe : si le set est trop gros, couper en haut de courbe. L'erreur systématique des sets maison est le sommet trop lourd.
D-12 · Sous-types à deux axes : rang × rôle
Rangs (hiérarchie réelle de la guilde, vouée à évoluer) : Disciple · Adepte · Membre · Champion · Conseiller · Haut Conseiller. (Adepte ajouté par D-98.)Rôles : Tank · Soigneur · DPS · Artisan. Non-membres : Boss · Monstre · Mascotte · PNJ.
Écarté : les tags PUG et CASU — les seuls de la liste initiale lisibles comme des piques. Les créatures non-membres couvrent déjà le besoin de remplissage sans viser personne.
Implémentation : les rangs sont une table de configuration, jamais une enum. Deux champs distincts — rankId (référence vivante, pour les synergies) et rankLabel (texte figé à l'impression). Si un rang est renommé, les cartes publiées gardent leur libellé : c'est de l'histoire de guilde, pas une donnée à rafraîchir.
D-13 · Le rang détermine le coût, pas la rareté
| Rang | Coût |
|---|---|
| Disciple | 1-3 |
| Adepte | 1-3 |
| Membre | 3-5 |
| Champion | 4-6 |
| Conseiller | 5-8 |
| Haut Conseiller | 7-10 |
Écarté : faire corréler aussi le rang avec la rareté. Coût et rareté sont deux axes indépendants ; les lier tous deux au rang interdirait à jamais une légendaire à 2 Ferveur ou une commune à 8. La rareté reste libre — un Disciple peut être légendaire.
Bénéfice : les synergies tribales de rang sont automatiquement cohérentes avec la courbe.
D-14 · Deux légendaires par set
Amendé par D-61 (2026-08-04) : quatre par set (2 créatures + 2 sorts), plafonnées à deux par deck. La rareté de l'honneur est déplacée du set vers le deck.
Chaque extension en apporte deux nouvelles. Peu de légendaires les rend désirables, et cela étale l'honneur sur plusieurs années au lieu d'avoir à justifier un classement d'entrée.
D-15 · Évolution des membres entre extensions
Le rang imprimé est un instantané daté. « Kaelis, Disciple » (set 1) reste un Disciple ; le set 3 peut ajouter « Kaelis, Conseiller ».
Trois règles :
- La carte évoluée ne domine jamais strictement l'ancienne — coût supérieur, rôle différent dans la courbe. Sinon c'est du power creep et l'ancienne meurt.
- Les deux versions coexistent légalement dans un deck (et peuvent synergiser entre elles).
- Champ
evolutionOfdans le modèle → affichage « historique de ce membre » sur le site.
D-16 · Numérotation façon Magic
Nom d'extension · code à 3 lettres · symbole · numéro de collection (042/120) · cadre par set. Le numéro de collection est le déclencheur de collectionnite le plus efficace : on ne possède plus « des cartes », on possède « 87 / 120 ». Couleurs de rareté : qualité d'objet WoW (gris / vert / bleu / violet / orange) — zéro apprentissage.
D-17 · Classes/factions reportées, mais préparées
Règle future : deck = cartes neutres + cartes de la classe du héros. Champ factions[] (tableau, pas chaîne) dès maintenant.
Le piège majeur : ne pas brûler les identités mécaniques dans le pool neutre. Si la résurrection, la contre-magie, le vol de créature et le burn de zone sont déjà neutres, il ne reste rien pour le Prêtre ou le Mage. Le pool neutre fait des choses génériques ; tout le reste part dans 03-backlog-classes.md.
D-18 · Ordre de développement : schéma → générateur → moteur
Raison : le générateur de cartes est la brique la moins risquée, la plus rapide à livrer, la seule qui produit de l'attente avant que le jeu existe, et il débloque le prototypage de mécaniques sur table (cartes imprimées, zéro moteur).
D-19 · Multi-prototypes : l'actif durable est la donnée
Schéma, corpus et kit de conformité vivent dans data/, en JSON pur, indépendants de toute stack. Un prototype est fonctionnel quand il passe le kit. Discipline : un prototype = une variable. Changer la stack et les mécaniques en même temps n'apprend rien. Séparer prototype de mécanique (cartes imprimées, vraie table) et prototype technique (moteur + client minimal).
D-20 · Cartes « Enluminées » (shiny) — cosmétique, non achetables
Nom retenu : Enluminée. Registre du manuscrit médiéval, cohérent avec Le Grand Conseil. Écarté : Dorée (vocabulaire Hearthstone, mais §10 réservait déjà « doré » à autre chose) ; Brillante (compris de tous mais plat) ; Scellée / Sceau d'or (collision sémantique avec la monnaie Sceaux, que D-03 s'attache justement à éviter).
Quatre règles fermes :
- Rigoureusement cosmétique — aucune stat, aucun effet, aucun bonus, y compris hors partie. Application directe de D-09 : ni la chance ni l'ancienneté ne doivent peser sur une partie. Écarté : un micro-bonus méta (gain de Sceaux, badge chiffré) — il transformerait l'enluminure en objectif de farm.
- Propriété de l'exemplaire, pas de la carte — même
id,versionetcollectorNumber. Écarté : une entrée de collection séparée, qui doublerait le corpus à 240 entrées et obligerait à appliquer chaque nerf deux fois. - Compte comme une copie normale pour les limites de deck (D-05).
- Ne s'achète pas — drop en pack (~5 %, pity 10 packs) et récompense d'événement uniquement. Écarté : le craft en Sceaux, pourtant tentant comme puits (une enluminée doit dire « j'ai eu de la chance » ou « j'étais là », jamais « j'ai payé ») ; et l'attribution automatique de sa propre carte-membre (décorer 100 % des membres ne signale rien).
Conséquence d'architecture : le moteur ne voit jamais foil — absent de l'état de partie, du kit de conformité et du seed RNG. Simple option de rendu. Un replay rejoué sans les données d'enluminure doit produire la partie identique.
Deux pièges :
- Le taux de drop est un bouton à sens unique : on peut le monter, jamais le baisser sans dévaluer les enluminées déjà distribuées.
- Pas d'échange entre membres : un marché interne rendrait les Sceaux spéculatifs et réintroduirait le pay-to-win exclu par §10.
Puits de Sceaux inchangé : les alt-arts IA restent le sink infini. L'alt-art se choisit, l'enluminée se gagne — les deux cohabitent sans se cannibaliser.
P0 — décisions de schéma
Les sept entrées suivantes ont été prises en écrivant data/schema/. Elles précisent ou corrigent §8.1 de la spec, qui donnait un modèle indicatif et non un schéma exécutable.
D-21 · Identifiants techniques en anglais, libellés en français
Clés JSON et valeurs d'enum en anglais (cost, atk, hp, keywords: ["TAUNT"], subtypes: ["CRAFTER"]). Les libellés français vivent dans data/schema/taxonomy.json et dans les champs de texte (name, flavor, rulesText, text de capacité).
Écarté : les enums en français, et a fortiori les clés en français. §8.1 utilisait déjà TAUNT : le mélange serait devenu permanent. Séparer identifiant technique et libellé rend en outre un futur i18n indolore, et permet de renommer un rôle sans toucher au corpus.
Conséquence : aucune chaîne française en dur dans un prototype. Le générateur (P1) lit ses libellés dans taxonomy.json.
D-22 · Versioning : un fichier par version
Convention de nommage : <id>.v<version>.json — crea_gorak_forgeron.v1.json. L'id est la lignée, stable ; la version est le tirage. Champs supersedes et changeNote, ce dernier obligatoire dès la version 2.
Écarté : une version courante écrasée avec un dossier archive/ (deux chemins de chargement) ; un tableau versions[] dans un fichier unique (fichiers qui gonflent, schéma alourdi).
Raison : le diff git est explicite, et un moteur peut charger une version arbitraire pour rejouer un replay d'époque — c'est exactement ce que la règle d'architecture 5 achète.
D-23 · Une capacité porte une SÉQUENCE d'effets
{ trigger, condition, effects: [ {action, target, params, condition}, … ] }, exécutés dans l'ordre.
Écarté : le modèle de §8.1 (une action par capacité). « Infliger 4 dégâts et piocher une carte » aurait exigé deux capacités ON_PLAY empilées, avec un ordre de résolution implicite et une condition dupliquée.
Bénéfice imprévu : la condition au niveau de l'effet résout « 1 dégât ; 3 dégâts si vous avez 8+ Ferveur » (pouvoir de l'Évocateur) par deux effets à conditions inversées, sans action DEAL_DAMAGE_CONDITIONAL ni code spécifique.
D-24 · Le rang sort de subtypes
§6.1 plaçait le rang à la fois dans subtypes: ["DPS", "CHAMPION"] et dans rankId/rankLabel. Le rang ne vit désormais que dans rankId (référence vivante) + rankLabel (libellé figé). subtypes ne porte que le rôle (membres) ou le type non-membre (BOSS, MONSTER, PET, NPC).
Raison : une même information en deux endroits finit toujours par désynchroniser — et ici l'un des deux (subtypes) était une enum figée, l'autre (rankId) une table de configuration vouée à changer. Le DSL de ciblage expose les deux axes séparément : filter.hasSubtype et filter.hasRank.
D-25 · L'extension n'est référencée que par son id
Une carte stocke set: "set_01" et rien d'autre. Le nom, le code à 3 lettres, le symbole, le cadre et la composition cible vivent dans data/schema/sets.json.
Écarté : dupliquer setCode sur chaque carte comme le suggérait §7.5.
Raison : le nom de l'extension de lancement n'est pas tranché. Le décider — ou le changer — ne doit pas réécrire 120 fichiers.
D-26 · GAIN_MANA renommée GAIN_FERVOR, avec un mode
L'action porte { amount, mode } où mode vaut CURRENT (alimente ferveurActuelle sans toucher au plafond) ou MAX (relève ferveurMax).
Raison : « mana » n'existe pas dans le vocabulaire du jeu (D-03). Surtout, la distinction CURRENT/MAX est exactement celle que D-04 impose pour l'avantage du second joueur : elle devait être dans le modèle, pas dans une exception du moteur.
D-27 · La durée AURA est imposée par le schéma
duration vaut PERMANENT, END_OF_TURN ou AURA. Le schéma exige AURA dès que le déclencheur est CONTINUOUS, et l'interdit partout ailleurs.
Raison : c'est la règle d'architecture 4 rendue mécaniquement invérifiable à contourner. Un moteur qui appliquerait un buff d'aura comme une mutation de stats stockées laisserait des créatures buffées après la disparition de la source — le bug qui tue les moteurs de TCG maison. La donnée ne peut plus exprimer ce cas : deux fixtures négatives (invalid_aura_hors_continuous, invalid_continuous_sans_aura) le vérifient à chaque exécution.
D-28 · Un dossier tools/ en Node, hors de l'actif durable
data/ reste 100 % JSON pur. tools/ contient validate.mjs (ajv) et lint.mjs (règles arithmétiques et globales). Ce dossier est jetable et remplaçable ; data/ ne l'est pas.
Raison : JSON Schema ne sait pas faire d'arithmétique. Le budget de stats (ATK + PV = 2 × coût + 1), la fourchette de coût par rang (données dans un autre fichier), l'unicité des numéros de collection, la limite de deux légendaires et l'intégrité des références entre cartes ne sont vérifiables qu'en code. Sans ce linter, chacune de ces règles est une discipline humaine qui cède au bout de 40 cartes.
Bloc design : les cartes portent un objet design (abilityBudget, effectPoints, archetype, cycle, vanilla, notes) qui sert la comptabilité d'équilibrage. Le moteur l'ignore — il n'entre ni dans l'état de partie ni dans le kit de conformité, même règle que le cosmétique (D-20).
Lacunes de règles révélées par P0
Six trous que l'écriture du schéma a mis au jour. Tranchés avant P1 plutôt que reportés en P2 : trois d'entre eux changeaient la définition d'un pouvoir héroïque, et deux la façon d'écrire les cartes. La spec est amendée en v1.1 en conséquence — ce ne sont pas des corrections contre elle mais des précisions qu'elle ne portait pas.
D-29 · Le héros est passif : il n'attaque jamais (v1)
Le héros n'a pas de caractéristique d'attaque, ne se déclare jamais attaquant, et ne peut pas recevoir de mot-clé. Il ne blesse l'adversaire que par ses créatures, ses sorts et son pouvoir héroïque.
Écarté : le héros attaquant à la façon de Hearthstone. Il aurait fallu trancher le malaise d'invocation héroïque, l'attaque par tour, les dégâts en retour, l'interaction avec Provocation, et l'affichage d'une valeur d'ATK sur un portrait — soit un second système de combat pour un gain de profondeur faible.
Conséquences immédiates :
- Les pouvoirs du Druide (« +1 ATK et 1 Armure ») et du Chasseur de démons (« +1 ATK ») ne faisaient plus rien. Redessinés en « Croissance » (une créature alliée gagne +1/+1) et « Métamorphose » (1 Ferveur : 1 dégât à une créature). Le Chasseur de démons reste le seul pouvoir à 1 Ferveur ; le Druide frôle désormais le Moine, et c'est le maillon faible de la liste — à re-différencier au prochain jet.
- Le schéma interdit
BUFF,SET_STATSetGRANT_KEYWORDsurkind: HERO(et surANY, qui pourrait le résoudre). Les seules actions autorisées sur un héros sontDEAL_DAMAGE,HEALetGAIN_ARMOR. La donnée ne peut plus exprimer une règle qui n'existe pas — fixtureinvalid_buff_sur_heros. - L'identité druidique du backlog (choix entre deux effets, accélération de Ferveur) n'est pas dépensée pour boucher le trou : elle reste réservée à l'extension. Une accélération de Ferveur répétable sur un pouvoir héroïque atteindrait le plafond de 10 immédiatement, de toute façon.
Réversible : rendre le héros actif plus tard est une extension (armes, cartes-héros), pas une migration. L'inverse ne l'aurait pas été.
D-30 · Un seul choix de cible par capacité
Dans une même capacité, tous les sélecteurs de scope CHOSEN partageant le même couple (side, kind) désignent la même entité. Le joueur choisit une fois, au début de la résolution, et ce choix vaut pour toute la séquence.
Écarté : un scope PREVIOUS_TARGET explicite. Il aurait permis d'exprimer la même chose deux fois dans la donnée — donc de l'exprimer de façon ambiguë. Une règle générale vaut mieux qu'un mot-clé optionnel qu'on oublie.
Conséquence : « +0/+2 et Provocation » (pouvoir du Moine) touche forcément la même créature. Deux couples (side, kind) distincts dans une même capacité provoquent bien deux invites successives : c'est légal, mais assez rare pour que lint.mjs le signale en avertissement.
Scénario de conformité obligatoire : une capacité à deux effets CHOSEN dont la cible meurt entre le premier et le second effet. Le second doit être perdu, pas redirigé.
D-31 · Les seuils de Ferveur lisent ferveurMax
« Si vous avez 8 Ferveur ou plus » signifie « si votre plafond est à 8 ». La condition est nommée FERVOR_MAX_AT_LEAST pour qu'aucun moteur ne puisse se tromper : le nom porte la sémantique.
Écarté : lire ferveurActuelle. Trois raisons — le seuil ne dépendrait plus que de l'ordre de jeu dans le tour (« lance ce sort avant de dépenser »), ce qui est une contrainte cachée ; le +1 temporaire du second joueur pourrait franchir un seuil un tour trop tôt, alors que D-04 s'attache justement à ce que cet avantage reste sans conséquence structurelle ; et le joueur devrait calculer au lieu de lire son plafond affiché.
Conséquence : les 3 dégâts de l'Évocateur arrivent au tour 8 et ne repartent jamais. C'est un pouvoir qui change de nature en fin de partie — l'effet voulu.
D-32 · Aucun dégât de zone dans le pool neutre
Toute forme d'AoE, même modeste, est réservée aux classes. tools/lint.mjs refuse tout DEAL_DAMAGE frappant plusieurs créatures adverses sur une carte NEUTRAL (scope ALL, ou count > 1).
Écarté : l'AoE modeste en neutre, que §7.3 prévoyait pourtant dans l'archétype Contrôle du set de lancement. La contradiction avec 03-backlog-classes.md est tranchée en faveur du backlog : une fois l'AoE générique installée, le Mage et le Chaman n'ont plus rien de distinctif à dire, et c'est l'erreur qui rend une extension de classes décevante (D-17).
Le prix, assumé et à surveiller : le Contrôle du set de lancement n'a plus de réponse propre à un terrain large, face à un archétype Agro/large qui existe explicitement. Soupapes de rechange : corps à Provocation à forte endurance, créatures Létal, permanents à Rituel infligeant 1 dégât à une créature adverse aléatoire (répétable mais mono-cible, donc hors du domaine réservé), renvoi en main ciblé.
Condition de révision, fixée d'avance : si l'agro large domine au premier test de table, on ouvre un AoE neutre volontairement faible et cher (1 dégât à toutes les créatures à 4 Ferveur), on n'attend pas l'extension.
D-33 · Invocation sur terrain plein : les surnuméraires sont perdus
Amendé par D-112 (2026-08-13) : la carte reste jouable pour l'invocation par effet ; une créature jouée de la main sur terrain plein, elle, est désormais refusée.
Les invocations se résolvent une par une. Si le terrain est plein au moment de placer une créature, elle n'apparaît pas et l'effet continue. La carte reste jouable même terrain plein.
Écarté : refuser l'invocation, ou refuser de jouer la carte. Il aurait fallu définir par carte le nombre de places nécessaires à sa légalité, et certaines cartes seraient devenues injouables sans raison lisible pour le joueur.
Raison : le joueur voit ce qu'il perd, donc c'est un choix de timing et non un piège. Et c'est la règle la moins chère à implémenter correctement.
Conséquence : Convocation générale n'invoque qu'un Disciple s'il ne reste qu'une place. Scénario de conformité obligatoire, avec sa variante inverse : une invocation qui libère une place en cours de séquence (le premier jeton meurt d'un Râle d'agonie enchaîné) doit-elle permettre au second d'arriver ? Oui — l'état est réévalué à chaque placement, jamais en amont.
D-34 · Étalon de valeur d'effet : 1,5 × coût + 1
Remplace le taux linéaire de 2,5 points d'effet par Ferveur du premier jet, pour les sorts et les permanents. Détail et table de conversion en §9.1 de la spec.
Deux raisons :
- Le sommet devenait absurde. 2,5 × coût autorisait 20 points d'effet à 8 Ferveur. L'efficacité par unité de ressource décroît en haut de courbe dans les TCG qui tiennent : une carte chère arrive tard et doit déjà mériter son tour. Une formule affine reproduit cette décroissance, une formule linéaire ne peut pas.
- Les cartes écrites à l'instinct contredisaient systématiquement l'ancien étalon — 60 à 72 % de la cible. Elles atteignent 75 à 109 % de la nouvelle. Quand la règle contredit systématiquement le jugement, c'est presque toujours la règle qui a tort.
Non bloquant, et volontairement. Le linter rapporte l'écart avec une tolérance de 70 à 115 %, mais n'échoue jamais dessus : l'échantillon est de quatre cartes. Le budget de caractéristiques des créatures, lui, reste bloquant — il est vérifié, pas estimé. Le premier test de table sur cartes imprimées tranchera (§13.3).
Garde-fou conservé : un sort ne doit jamais dominer une créature de même coût sur la puissance immédiate. À 3 Ferveur, une créature reçoit 7 points de caractéristiques et un corps qui persiste ; un sort reçoit 5,5 points consommés d'un coup. L'écart paie la permanence.
Publication
D-35 · Le site de documentation est le même projet que P1, dans le même dépôt
La documentation devient consultable en ligne, avec navigation et recherche, dans le même site que le générateur de cartes. Un seul dépôt, un seul build, un seul déploiement.
Le site est un rendu, jamais un stockage
docs/*.md reste la source de vérité et ne bouge pas. Le site les affiche, il ne les héberge pas.
Écarté : un wiki au sens outil — GitHub Wiki, Notion, Wiki.js, Outline. Tous stockent le contenu chez eux, ce qui crée une seconde source de vérité et impose un mécanisme de synchronisation. Ces mécanismes se dégradent, et le jour où les deux côtés divergent, plus personne ne sait lequel fait foi.
C'est aussi une régression pour le travail avec un agent, qui est un critère explicite du projet : des fichiers markdown versionnés sont le meilleur format possible — lisibles, cherchables, diffables, avec l'historique et le motif de chaque changement dans git. Un contenu en base de données n'est pas accessible sans passer par une API.
Un seul dépôt, pour la donnée avant la doc
Écarté : un dépôt séparé synchronisé.
La raison n'est pas le confort mais data/. Le générateur lit taxonomy.json, ranks.json, sets.json et tout data/cards/. Dans le même dépôt, c'est un import relatif et une carte ajoutée apparaît au build suivant. Dans un dépôt séparé, il faut un sous-module git ou un paquet npm à republier à chaque carte — le problème de synchronisation réapparaît sur l'actif durable, ce qui est bien pire que sur la documentation.
site/ a le même statut que tools/ : jetable et clôturé
Il lit docs/ et data/, il n'écrit jamais, il ne contient aucun contenu propre. Le test : si le supprimer fait perdre une information, il est cassé et il faut remonter le contenu dans docs/ ou data/.
README.md et CLAUDE.md restent à la racine, hors du site : ils s'adressent au dépôt et à l'agent, pas aux lecteurs.
Stack : VitePress
Écarté : Astro (plus flexible, mais navigation et recherche à construire) ; MkDocs Material (superbe en doc, hostile à une application interactive) ; Docusaurus (plus lourd, orienté doc produit versionnée).
Raison : VitePress génère la navigation, la recherche plein texte et le sommaire directement depuis les fichiers markdown existants, sans configuration — c'est exactement la demande. Et comme il repose sur Vue, le générateur de cartes s'y intègre en page applicative de plein droit plutôt qu'en greffon.
Réversibilité : le jour où le générateur devient l'essentiel et la doc une simple section, changer de socle ne coûte que le socle. Les fichiers markdown et data/ ne bougent pas — c'est précisément à ça que sert la séparation actif durable / code jetable (D-19).
Ce que le site apporte que le markdown ne peut pas
La galerie des 120 cartes rendues, la complétion (« 87 / 120 · dont 12 enluminées »), la courbe du set calculée en direct depuis data/, le glossaire cherchable, la bascule normale / enluminée. C'est là qu'est la valeur produit : les membres voient leur carte des mois avant que le jeu existe (D-18).
Site statique, donc hébergeable n'importe où, y compris en sous-domaine de guilde-lgc.fr. Il n'a rien à voir avec le service stateful qu'imposera le duel en temps réel (P3) et ne préempte pas cette décision.
P2 — règles de résolution (tranche 0)
Quinze trous que la préparation du kit de conformité a mis au jour, en relisant la spec v1.1 avec une seule question : pour chaque scénario listé dans data/conformance/README.md, existe-t-il une règle écrite qui dit ce que l'état attendu doit contenir ? Même mouvement que D-29 → D-34 en sortie de P0 — mais vu du moteur, pas du schéma. Tranchés le 2026-08-03, avant le premier scénario. La spec est amendée en v1.2 en conséquence.
D-36 · Main de 10 cartes maximum ; la surpioche brûle
- Main maximale : 10 cartes (constante
handLimitdanstaxonomy.json). - Pioche à main pleine : la carte est brûlée — retirée du deck, posée au cimetière, révélée aux deux joueurs (événement
CARD_BURNED). Elle ne déclenche rien. BOUNCEvers une main pleine : la créature est détruite (elle meurt, ses Râles se déclenchent). Un jeton renvoyé cesse simplement d'exister, main pleine ou non — il n'a pas de carte.TUTORetCOPYvers la main suivent la règle de la pioche : main pleine → brûlée.
Écarté : main illimitée (supprime le scénario au lieu de le trancher, et rend la pioche strictement gratuite) ; refuser la pioche sans brûler (l'ordre du deck deviendrait dépendant du moment où la main se vide — un cauchemar de replay).
D-37 · Les déclenchements simultanés se résolvent par ordre d'entrée en jeu
Chaque entité reçoit à son arrivée en jeu un numéro d'entrée (entrySeq, compteur global monotone de la partie, jamais réutilisé). Quand un même événement déclenche plusieurs capacités, elles se résolvent par ordre d'entrée en jeu croissant, tous camps confondus. La file de déclenchement est FIFO : les déclenchements engendrés en cours de résolution s'ajoutent en fin de file.
Écarté : « joueur actif d'abord, puis l'adversaire » (deux règles au lieu d'une, et le même effet joué au même moment se résoudrait différemment selon le tour) ; l'ordre choisi par le joueur (de la profondeur à coût UI exorbitant, et de l'indéterminisme dans les replays).
Raison : le terrain est non ordonné (D-10) — sans cette règle, deux moteurs corrects divergent sur la même partie. C'est entrySeq — jamais un id de carte — qui ordonne.
D-38 · Les morts sont constatées après chaque effet ; les Râles se résolvent entre les séquences
Deux temps distincts :
- Le constat est immédiat. Après chaque effet primitif, toute entité à PV effectifs ≤ 0 (ou détruite) quitte le terrain sur-le-champ — elle n'est plus une cible légale, ne bloque plus une place, son aura cesse. Ses
ON_DEATHsont mis en file (ordre D-37), pas résolus. - La file se vide entre les séquences. Quand la séquence d'effets en cours (capacité, combat) est terminée, la file des Râles se résout, FIFO. Les morts causées par un Râle ajoutent leurs Râles en fin de file — la cascade est couverte sans réentrance au milieu d'une capacité.
Écarté : résoudre chaque Râle immédiatement au milieu de la séquence en cours (réentrance : l'état change sous les pieds d'une capacité à moitié résolue — la source classique de bugs d'ordre) ; ne constater les morts qu'en fin de séquence (contredit D-30, déjà acté : la cible morte entre deux effets fait perdre le second).
Raison : c'est la règle la plus structurante du moteur — elle fixe la boucle de résolution. Une dizaine de scénarios du kit en dépendent.
D-39 · Durabilité et charges : ce qui les décrémente
- Durabilité : −1 à chaque fin de tour de son contrôleur (après les déclencheurs
ON_TURN_END, avec l'expiration des effets temporaires). À 0, le permanent est détruit — sesON_DEATHéventuels se déclenchent. La Bannière (durabilité 3) vit donc trois tours de son propriétaire. - Charges : −1 à chaque résolution effective d'une capacité déclenchée ou activée du permanent (une capacité dont tous les effets sont restés sans cible ne consomme pas). À 0, détruit.
- Un permanent sans durabilité ni charges est éternel (seuls
DESTROYetBOUNCEl'enlèvent).
Écarté : durabilité décrémentée à chaque fin de tour des deux joueurs (la Bannière ne vivrait qu'un aller-retour et demi — trop court pour son coût, et « Durabilité 3 » se lirait mal).
D-40 · L'Armure absorbe tout, et « perdre des PV » n'existe pas
- L'Armure absorbe toute forme de dégâts — combat, sorts, pouvoirs, fatigue — avant les PV, point par point. Pas de plafond, elle persiste entre les tours, le soin ne la restaure pas (
HEALne touche que les PV,GAIN_ARMORque l'Armure). - « Perdre des PV » n'existe pas comme primitive distincte. Le Pacte du Démoniste (implémenté en
DEAL_DAMAGE) est absorbé par l'Armure ; son texte est aligné (« subit 2 dégâts »). - Les dégâts entièrement absorbés comptent comme des dégâts subis (événement
DAMAGE_TAKENémis, avecarmorAbsorbed).
Écarté : une primitive LOSE_HP contournant l'Armure — une 17ᵉ primitive pour une nuance qu'aucune carte n'exploite. Si un design l'exige un jour, elle s'ajoutera sans refonte.
D-41 · Aucune cible légale : la carte reste jouable, l'effet est perdu
Toute carte est jouable quel que soit l'état du terrain. Un sélecteur sans cible légale rend son effet nul — jamais d'annulation, jamais de remboursement, le reste de la séquence continue. Avertir le joueur est le travail du client, pas du moteur. La légalité d'une intention PLAY_CARD se réduit à : la carte est en main, la Ferveur suffit — plus le cas particulier ci-dessous.
Exception : un permanent joué alors que le terrain des permanents est plein est injouable. Contrairement aux créatures, il n'a pas de règle de surnuméraire (il ne fait que rester en jeu), et le seuil est uniforme — une place — donc lisible, ce qui était l'objection de D-33 aux seuils de légalité.
Écarté : l'injouabilité des sorts ciblés sans cible (façon Hearthstone). Elle protège d'un misclick mais introduit une seconde règle de légalité dont chaque carte hériterait implicitement — exactement ce que D-33 a refusé. Cohérent avec D-29 (le pouvoir héroïque coûte sa Ferveur même si l'effet est nul) et D-33 (les surnuméraires sont perdus).
D-42 · Mise en place, victoire, égalité, abandon
- Mise en place : tirage du premier joueur (RNG seedé) → mélange des decks (RNG) → pioche 3 / 4 → mulligan simultané (chaque joueur soumet le sous-ensemble à remplacer ; les cartes rendues sont remélangées puis on repioche autant — consommation RNG dans l'ordre J1 puis J2, pour le déterminisme) → tour 1 de J1.
- Chaque tour, y compris le premier, commence par la phase Début complète — J1 pioche donc au tour 1 (4 cartes jouables), J2 en a 5 plus son +1 de Ferveur (D-04).
- Victoire : un héros à PV ≤ 0 (Armure épuisée) perd ; la vérification a lieu au constat des morts (D-38). Les deux héros à ≤ 0 dans la même résolution : match nul — l'égalité est un résultat légal du moteur.
- Abandon : intention
CONCEDE, victoire adverse immédiate. Le timer de tour (75 s) reste hors moteur : c'est le serveur (P3) qui envoieEND_TURNd'office — le moteur ne connaît pas le temps.
D-43 · L'état de partie parle anglais
Le schéma d'état applique D-21 sans exception : fervorMax, fervorCurrent, handLimit, entrySeq… La spec garde ses noms français en prose (elle parle aux joueurs) avec la correspondance donnée une fois en §1. Aucun identifiant français dans data/, l'état, les événements ou les scénarios.
Écarté : entériner l'exception ferveurMax/ferveurActuelle que la spec v1.1 écrivait — deux conventions dans le même dépôt, et la promesse D-21 (« un futur i18n indolore ») cassée là où elle compte le plus : l'état que le client affichera.
D-44 · Furtif : invisible pour l'adversaire seulement, rompu par sa propre attaque
- Furtif ne protège que de l'adversaire : son contrôleur cible librement ses propres Furtifs (les buffer est tout l'intérêt).
- Il ne protège que du ciblage (
CHOSEN) et de l'attaque. Les scopes non ciblés —ALL,RANDOM,LOWEST_HP,HIGHEST_ATK— l'atteignent normalement : Furtif rend invisible, pas intangible. - Rupture : uniquement quand la créature attaque (à la déclaration de son attaque). Subir des dégâts, être buffée, déclencher ses capacités ne rompt rien.
- Rappel (§1) : une Provocation furtive n'impose rien — elle n'est pas attaquable.
D-45 · Létal : dégâts de combat, entre créatures, un point suffit
- Létal s'applique aux dégâts de combat infligés par la créature — en attaque comme en défense (les dégâts de combat sont mutuels et simultanés, §1). Dans ce DSL, une créature n'inflige de dégâts que par le combat : les
DEAL_DAMAGEde ses capacités sont des effets de la carte, pas des coups de la créature. - Il faut au moins 1 dégât infligé : une créature à 0 ATK ne déclenche rien, et une instance entièrement absorbée par un Bouclier n'inflige aucun dégât — Létal reste sans effet.
- Létal ne s'applique jamais au héros : il détruit des créatures, le héros n'en est pas une.
- La destruction par Létal survient au constat des morts (D-38), comme une mort par dégâts — les Râles de la victime se déclenchent.
D-46 · Bouclier : une instance entière, sans compter comme des dégâts
- Le Bouclier absorbe l'instance entière, quelle que soit son ampleur. Une instance de 0 dégât (ATK 0) ne le consomme pas — il n'y a pas de dégâts.
- Une absorption n'est pas des dégâts subis :
ON_DAMAGE_TAKEN(Riposte) ne se déclenche pas, la créature n'est pas « blessée » (D-47), aucun événementDAMAGE_TAKEN— un événementSHIELD_BROKENà la place. DESTROYn'est pas des dégâts : le Bouclier n'y peut rien. Même chose pourTRANSFORMetBOUNCE.- Le Bouclier est un flag : il tombe une fois, il ne se recharge pas seul (un nouveau
GRANT_KEYWORD SHIELDle rend).
D-47 · « Blessée » = des dégâts marqués ; l'aura qui disparaît peut tuer
Conséquence directe de la règle d'architecture 4, écrite noir sur blanc :
- L'état d'une créature stocke des dégâts marqués (
damageTaken), jamais des « PV courants ». Les PV effectifs se calculent :hpBase + Σbuffs + Σauras − damageTaken. damaged≡damageTaken > 0.HEALréduit les marques (jamais sous 0). Soigner une créature intacte ne fait rien — et ne compte pas comme un soin dans les événements.- Le constat des morts (D-38) lit les PV effectifs : si une aura disparaît et que
damageTakenatteint les PV effectifs recalculés, la créature meurt à ce moment-là, Râles compris. C'est le recalcul — pas une mutation — qui rend ce cas possible.
D-48 · GAIN_FERVOR MAX : un cristal vide ; plafond absolu 10 partout
MAXrelèvefervorMaxsans rien donner àfervorCurrent— un cristal vide, dépensable à la prochaine recharge. (L'inverse ferait de toute accélération un gain de tempo immédiat et permanent — deux effets pour le prix d'un.)- 10 est un plafond absolu, pour les deux compteurs.
MAXau-delà de 10 est perdu ;CURRENTpeut dépasserfervorMaxmais jamais 10. - La recharge (phase Début) :
fervorCurrent ← fervorMax. La Ferveur non dépensée est perdue — pas de banque.
D-49 · Le sujet et le moment de chaque déclencheur ; précisions de combat
Le tableau qui manquait à §5.3 (repris dans la spec v1.2) :
| Déclencheur | Se déclenche pour… | Moment précis |
|---|---|---|
ON_PLAY | la carte porteuse | quand elle est jouée depuis la main, après son arrivée en jeu (créature/permanent) ou pendant sa résolution (sort) |
ON_DEATH | l'entité porteuse | via la file des morts (D-38) |
ON_TURN_START / ON_TURN_END | l'entité porteuse | aux tours de son contrôleur uniquement |
ON_DAMAGE_TAKEN | l'entité porteuse | quand elle subit ≥ 1 dégât réel (pas une absorption de Bouclier) |
ON_ATTACK | la créature porteuse | à la déclaration de son attaque, résolu avant les dégâts de combat |
ON_ALLY_SUMMONED | l'entité porteuse | quand une autre créature alliée entre en jeu — jouée ou invoquée, sans distinction ; jamais pour sa propre arrivée |
ON_SPELL_CAST | l'entité porteuse | après la résolution complète d'un sort de son contrôleur |
CONTINUOUS | — | recalcul permanent (D-27), pas un événement |
MANUAL | l'entité porteuse | intention du joueur, 1×/tour, coût en Ferveur |
Trois conséquences actées :
- Camaraderie et l'invocation multiple : les invocations se résolvant une par une (D-33), chaque arrivée émet son événement — les porteuses déjà en jeu à cet instant se déclenchent. Deux Disciples invoqués = Kaelis se déclenche deux fois ; le premier Disciple, s'il portait Camaraderie, se déclencherait pour le second.
- Écho auto-déclenché : l'événement partant après la résolution complète du sort, une créature invoquée par ce sort est en jeu à ce moment-là — elle se déclenche. (Écarté : émettre l'événement au moment du cast, avant résolution — il aurait fallu conserver un instantané des entités présentes ; l'état au moment de l'événement fait foi, c'est plus simple.)
TRIGGER_SOURCEdisparu : si la source de l'événement a quitté le jeu au moment où l'effet se résout, le sélecteur ne désigne rien — effet nul (D-41). Jamais de redirection.
Deux précisions de combat :
- Une créature à 0 ATK ne peut pas attaquer (déclarer une attaque sans dégâts possibles serait un non-événement qui casserait Furtif pour rien).
- Si, entre la déclaration (et la résolution d'
ON_ATTACK/ Assaut) et les dégâts, l'un des deux combattants a quitté le terrain, l'attaque est annulée : aucun dégât de part et d'autre. L'attaque est néanmoins dépensée, et le Furtif de l'attaquant est bien rompu (la déclaration a eu lieu).
D-50 · La sémantique des primitives, arrêtée une fois pour toutes
Chaque ligne devient un ou plusieurs scénarios du kit.
| Primitive | Sémantique actée |
|---|---|
SILENCE | Retire tout ce que la carte et la partie ont mis sur l'entité : mots-clés (imprimés et acquis), capacités (donc ses auras émises cessent), buffs PERMANENT et END_OF_TURN. Ne retire pas : les dégâts marqués, les auras reçues (recalculées, D-27), les stats de base. Irréversible. |
SET_STATS | Remplace atkBase/hpBase, efface les buffs accumulés et les dégâts marqués (l'entité repart sur la nouvelle base). Les auras continuent de s'appliquer par-dessus. |
TRANSFORM | Remplace l'entité par une nouvelle entité de la carte cible : nouveau entrySeq, fraîche (pas de dégâts, pas de buffs, malaise d'invocation), l'ancienne disparaît sans déclencher ON_DEATH (elle n'est pas morte, elle n'est plus). |
COPY | Copie la définition de base de la cible (jamais son état courant : ni buffs, ni dégâts). destination: BOARD est une invocation — D-33 s'applique ; HAND/DECK créent une carte (une copie de jeton en main est légale : la carte existe alors le temps d'être jouée). |
BOUNCE | La créature quitte le terrain, sa carte de base retourne dans la main de son propriétaire — buffs, dégâts et mots-clés acquis perdus. Jeton : cesse d'exister. Main pleine : D-36. |
TUTOR | Le deck est une pile ordonnée (mélangée au setup, seedée). TUTOR prend la ou les premières cartes correspondant au filtre en partant du dessus — déterministe, pas de tirage, pas de remélange. Carte révélée à l'adversaire. destination: BOARD est une invocation depuis le deck — D-33 s'applique. |
DISCARD | Main → cimetière. RANDOM consomme le RNG seedé. Ne déclenche rien (aucun déclencheur de défausse en v1). |
HEAL | Réduit les dégâts marqués (D-47) ; sur un héros, remonte les PV, jamais au-dessus de 30, jamais l'Armure. |
DEAL_DAMAGE | Sur un héros : Armure d'abord (D-40). Sur une créature : Bouclier (D-46) sinon dégâts marqués. Sur un permanent : illégal (§5.1 — insensible aux dégâts ; à interdire dans le schéma en tranche 1 de P2). |
DESTROY / SUMMON / DRAW / GRANT_KEYWORD / BUFF / GAIN_FERVOR / GAIN_ARMOR | Déjà entièrement déterminées par la spec, D-26/27/29/33 et les entrées ci-dessus. |
Critère de sortie de la tranche 0, atteint : avec D-36 → D-49, plus aucun scénario de data/conformance/README.md n'est sans règle qui le couvre.
P2 — le format exécutable (tranche 1)
Deux décisions prises en écrivant les schémas d'état (state / intent / event / scenario, dans data/schema/). Elles fixent la partie du kit qui doit survivre à tous les prototypes : le hasard et le format des scénarios.
D-51 · Le hasard : mulberry32, seedé, consommation normée
L'algorithme fait partie de l'actif durable : un prototype dans une autre stack doit rejouer exactement les mêmes tirages, sinon ni le kit ni les replays (P4) ne tiennent.
- Générateur : mulberry32. État 32 bits (
rngStatedans l'état de partie), une dizaine de lignes dans n'importe quel langage. Forme normative entière :next()retourne un entier non signé 32 bits — jamais de flottant entre deux langages. nextInt(k) = next() % k. Le biais de modulo est négligeable pour des k de l'ordre du jeu, et l'arithmétique entière est portable partout.- Mélange : Fisher-Yates descendant. Pour
iden−1à1:j = nextInt(i+1), échangerdeck[i]etdeck[j]. L'indice 0 est le dessus du deck. - Ordre de consommation normé — chaque tirage consomme exactement un
next():- premier joueur :
nextInt(2)(0 = J1 commence) ; - mélange du deck de J1, puis de J2 ;
- mulligan (D-42) : cartes rendues replacées puis remélange complet du deck, J1 puis J2. En partie : sélection
RANDOM=nextInt(taille)sur l'ensemble des éligibles trié parentrycroissant (cartes en main : ordre de la main) ;poold'invocation :nextInt(longueur)sur l'ordre écrit dans la carte ;DISCARD RANDOM:nextInt(taille de main).
- premier joueur :
Implémentation de référence (JavaScript, normative) :
js
function mulberry32(state) {
return {
next() { // entier 0 … 2³²−1 ; state est l'entier stocké dans l'état de partie
state = (state + 0x6d2b79f5) >>> 0;
let t = state;
t = Math.imul(t ^ (t >>> 15), t | 1);
t = (t ^ (t + Math.imul(t ^ (t >>> 7), t | 61))) >>> 0;
return (t ^ (t >>> 14)) >>> 0;
},
get state() { return state; },
};
}
const nextInt = (rng, k) => rng.next() % k;Écarté : PCG32 et xoshiro (statistiquement supérieurs, sans objet ici — on tire des indices de liste, pas de la cryptographie, et mulberry32 est le plus simple à porter fidèlement) ; les tirages scriptés dans les scénarios (écartés au cadrage : le déterminisme lui-même doit être testable, et le scénario seed le teste).
D-52 · Le format des scénarios : état épars, assertions partielles, sous-séquence d'événements
Formalise le choix de cadrage du 2026-08-03. Schémas : scenario.schema.json (qui tire state, intent, event, card).
- État initial épars. Tout champ omis prend sa valeur par défaut documentée dans
state.schema.json(héros 30 PV, Ferveur 10/10, zones vides, tour 1, J1 actif…). Deux défauts sont calculés :fervorCurrent = fervorMax,entryNext = max(entry) + 1, etrngState = seed. Un scénario n'écrit que ce qui le concerne. - Assertions d'état partielles, par
entry.expect.statene contraint que les champs présents ; les entités s'identifient parentry, celles non listées ne sont pas contraintes ;expect.absentliste les entry qui ne doivent plus être en jeu.atkEffective/hpEffectiveassertent les valeurs recalculées (règle d'architecture 4) — c'est ainsi qu'un scénario d'aura prouve le recalcul. expect.eventsest une sous-séquence ordonnée. Chaque assertion matche le prochain événement compatible (les champs présents doivent être égaux) ; des événements non listés peuvent s'intercaler. Ajouter un événement informatif au moteur ne casse donc pas le kit.expect.eventCountsdonne des comptes exacts par type — c'est lui qui prouve « un déclenchement par créature » (Camaraderie, D-49), et0asserte qu'un déclencheur ne part pas.expect.rejected: l'intention d'indice donné doit être refusée, l'état inchangé. Le kit norme le refus, pas le message.- Fixtures embarquées dans
cards[]: conformes àcard.schema.json, en style jeton (token: true, hors collection), ids jamais en collision avec le corpus —tools/conformance.mjsle vérifie, ainsi que la résolution de toutes les références de cartes (corpus à version exacte, ou fixture).
Écarté : le snapshot complet d'état (chaque champ ajouté à l'état casserait 100 scénarios) ; l'égalité stricte de la liste d'événements (elle interdirait tout événement nouveau sans réécrire le kit) ; les références de carte sans version (un scénario est un mini-replay : la version y est explicite, comme l'exige D-22).
Trois scénarios témoins accompagnent les schémas — même rôle que les cartes témoins de P0, éprouver le format avant de produire en masse : scn_invocation_terrain_plein (D-33, cartes réelles), scn_bouclier_vs_letal (D-45/D-46, fixtures), scn_cible_morte_entre_effets (D-30/D-38/D-41, fixture à double effet). npm run check les valide désormais au même titre que le corpus.
P2 — le kit (tranche 2)
Deux décisions prises en écrivant les 72 scénarios du kit. Le mouvement est le même qu'en tranche 0 : chaque fois qu'un scénario ne pouvait pas être écrit sans choisir, le choix est consigné ici — jamais laissé implicite dans un fichier JSON.
D-53 · Les scénarios de mise en place
Un scénario dont la première intention est MULLIGAN est un scénario de mise en place : son état ne fournit que les decks — les listes déposées, dans l'ordre d'enregistrement — et le moteur exécute D-42 en entier avec le seed du scénario (tirage du premier joueur, mélange du deck de P1 puis de P2, pioches 3/4) avant de consommer les intentions MULLIGAN (résolution P1 puis P2), puis enchaîne la phase Début du tour 1. Les champs activePlayer et mains éventuellement fournis sont ignorés : c'est la mise en place qui les produit.
Deux précisions qui conditionnent le déterminisme :
- Mulligan vide : aucun remélange, aucune consommation RNG. D-42 remélange « les cartes rendues » — zéro carte rendue, zéro remélange. Un moteur qui remélangerait quand même consommerait un
next()invisible et divergerait sur tous les tirages suivants. - Le +1 du second joueur (D-04) s'applique au début de son premier tour, après la recharge, en
CURRENT, et s'émet commeFERVOR_GAINED— c'est un événement de partie, pas un artefact de mise en place.
Écarté : un champ « phase » dans l'état de partie (une seule frontière non standard existe, et l'intention MULLIGAN elle-même la signale — un champ permanent pour un cas unique alourdirait tous les scénarios) ; interdire la mise en place au kit (D-42 et D-51 norment précisément sa consommation RNG : c'est justement ce qu'un kit doit verrouiller).
Scénarios : scn_mise_en_place_deterministe, scn_mulligan_remplacement.
D-54 · Menues sémantiques normées par le kit
Le kit asserte des flux d'événements : sans normes de forme, deux moteurs corrects divergeraient sur le kit sans diverger sur le jeu. Chaque point est ancré par au moins un scénario.
| Norme | Détail |
|---|---|
TRIGGER_FIRED : déclenchements seulement | Émis pour les capacités déclenchées par un événement (ON_DEATH, ON_ALLY_SUMMONED, ON_SPELL_CAST, ON_DAMAGE_TAKEN, ON_ATTACK, ON_TURN_*). Jamais pour l'ON_PLAY d'une carte jouée (CARD_PLAYED le trace), ni MANUAL (ABILITY_USED), ni le pouvoir héroïque (HERO_POWER_USED). |
| Garde fausse ≠ résolution vide | Une condition fausse : la capacité ne se déclenche pas — aucun TRIGGER_FIRED, aucune charge consommée. Une résolution sans cible (D-41) : TRIGGER_FIRED est émis, les effets sont nuls — et ne consomment pas de charge (D-39). |
| Les conditions se lisent du point de vue du contrôleur, à la résolution | ALLY/ENEMY = ses créatures / celles d'en face, HERO_HP = son héros, HAND_SIZE = sa main (la carte en cours de résolution n'y est plus), IS_YOUR_TURN = c'est le tour de son contrôleur. |
| Quantités effectives | HEALED et FERVOR_GAINED portent ce qui s'est réellement appliqué (soin réel, gain écrêté par le plafond) ; un soin nul n'émet rien (D-47). DAMAGE_TAKEN porte les dégâts subis en amount, la part absorbée en armorAbsorbed (D-40). |
| Fatigue : deux événements | FATIGUE (la pioche ratée, amount = dégâts) puis DAMAGE_TAKEN sur le héros — l'Armure s'applique comme partout (D-40). |
| Ordres d'émission | Effet ALL : entités par entry croissant, héros dans l'ordre P1 puis P2. Combat : le résultat côté défenseur avant le côté attaquant. Constat des morts : les DIED par entry croissant (D-37, D-38). |
| Cible illégale dès l'intention | Désigner une cible interdite (ex. un Furtif adverse, D-44) ne rejette pas l'intention — la légalité reste mince (D-41) : l'effet est perdu, le sort est payé. |
Écarté : normer les messages d'erreur des refus (D-52 l'exclut déjà) ; faire porter ces normes par le schéma d'événements (ce sont des règles d'émission, pas de forme — c'est le rôle du kit de les fixer).
Critère de sortie de la tranche 2, atteint : 75 scénarios (72 nouveaux + 3 témoins), npm run check vert, testé en négatif. Couverture vérifiée par script : les 30 types d'événements, les 16 primitives, les 5 mots-clés, les 10 déclencheurs, les 7 conditions et les 7 types d'intention sont tous exercés au moins une fois.
Architecture d'exécution (tranche 3 → P3)
Quatre décisions prises le 2026-08-04, avant d'écrire engine/. Elles ne tranchent pas l'infrastructure du duel en ligne — elles rendent son report sûr. Le principe : une décision différée qui est écrite reste une décision ; non écrite, c'est de l'oubli qui se découvre six mois plus tard.
D-55 · TypeScript de bout en bout ; les choix d'infrastructure de P3 sont différés
Acté : le moteur et le serveur de partie sont en TypeScript.
Explicitement différés, sans date : le framework serveur, le moteur de base de données, le mode de partage des données avec le site de guilde, l'hébergement, et la mécanique d'authentification. Aucune tranche en cours n'en dépend (voir D-56).
Raison du TypeScript des deux côtés : la règle d'architecture 3 impose un serveur autoritaire, et la tranche 3 impose un moteur compatible navigateur. Le serveur exécute donc le même code que le mode test et que le futur client. Un serveur dans un autre langage signifierait deux moteurs à maintenir verts sur le même kit — le coût le plus élevé que ce projet puisse s'infliger, pour un gain nul à 40 joueurs.
Raison du report : les trois questions qui décident réellement de l'infrastructure n'ont pas encore de réponse — quelle est la stack et le moteur de base de l'API de guilde existante et est-elle joignable depuis l'hébergement du jeu ; l'API émet-elle déjà des jetons signés ou fonctionne-t-elle par session ; de quoi dispose-t-on pour héberger un processus stateful (Netlify est exclu pour cette brique). Trancher avant ces réponses, ce serait tirer au sort.
Pistes retenues mais non actées — à réexaminer, pas à considérer comme acquises : NestJS + Socket.IO côté serveur (flexibilité, gateway WebSocket de première classe, et surtout le framework déjà connu — un prototype = une variable, §13.3) ; propriété des données plutôt que base partagée à deux écrivains (le site possède comptes, personnages et Sceaux ; le jeu possède collection, decks, parties et replays) ; ouverture du jeu par jeton court signé émis par l'API, vérifié au handshake, le jeu ne voyant jamais de mot de passe.
Deux observations qui réduisent l'enjeu du report, et qu'il faut garder en tête le jour de l'arbitrage :
- La boucle temps réel ne touche aucune donnée partagée. Une partie charge un instantané au départ (deux decks, deux héros, un seed) et écrit une ligne à la fin. Entre les deux, tout est en mémoire. La question « base partagée ou synchronisée » ne concerne donc que le hors-partie, où un appel HTTP à l'API coûte 30 ms que personne ne verra.
- Le format de persistance d'une partie existe déjà. Un replay, c'est
seed + intentions ordonnées + événements: c'est mot pour motscenario.schema.json(D-52). Le journal de partie, la reprise après redémarrage du serveur et le replay de P4 sont un seul et même mécanisme, déjà spécifié.
Écarté : trancher l'infrastructure maintenant (choix à l'aveugle, et aucune tranche ne l'attend) ; un serveur Go/PHP/Python (deux moteurs) ; laisser le report implicite (c'est ainsi qu'une question ouverte devient une contrainte subie).
D-56 · Le contrat d'hermétisme du moteur
C'est la décision qui paie le report de D-55 : elle coûte zéro aujourd'hui et rend tous les choix aval réversibles. Cinq propriétés, à tenir dès la première ligne de engine/ — elles touchent tous les fichiers, donc les rattraper plus tard coûte une réécriture.
- Aucune lecture d'environnement. Ni
Date.now(), niMath.random(), nifetch, nifs, niprocess, niconsole, ni aucun accès àglobalThis. Le hasard entre par le seed (D-51) ; le temps n'entre pas du tout — le timer de tour est hors moteur, c'est le serveur qui envoieEND_TURN(D-39). - Cœur synchrone. Aucun
async, aucunePromisedans le réducteur ni dans ce qu'il appelle. Un seulasyncau cœur contamine tous les appelants, serveur comme client. - État strictement sérialisable. Objets et tableaux simples : ni classe, ni
Map, niSet, niDate. Un état doit survivre à un aller-retourJSON.parse(JSON.stringify(état))à l'identique — c'est ce qui rend le transport, le stockage, la reprise et la comparaison gratuits. - Le catalogue de cartes est injecté, jamais importé. Le moteur reçoit un catalogue ; il ne fait aucun
importversdata/. C'est ce qui laisse le serveur le charger au démarrage, le site l'empaqueter au build et un futur client le télécharger — trois politiques, un seul moteur. C'est aussi la condition pour que la règle d'architecture 1 (« ajouter une carte, aucun déploiement ») survive à P3. - Frontière de paquet étanche.
engine/a son proprepackage.json; rien hors deengine/n'est importable depuisengine/. Une règle de lint le vérifie, elle ne reste pas à la discipline.
Corollaire à inscrire dans le harnais : un test du kit doit prouver 1 et 3 mécaniquement — rejouer deux fois le même scénario dans le même processus doit donner un résultat identique, et l'état final doit égaler son aller-retour JSON.
Écarté : confier ces propriétés à la vigilance (elles se perdent un Date.now() à la fois, et le premier symptôme est un scénario qui échoue un jour sur trois) ; un moteur en classes et méthodes (l'état cesse d'être sérialisable sans couche de conversion — exactement la dette que la règle d'architecture 4 cherche à éviter ailleurs).
D-57 · Espace de travail npm et dépôt unique — la scission est différée, pas exclue
Acté : des frontières de paquets dès maintenant (npm workspaces), à commencer par @lgc/engine ; le dépôt unique est maintenu.
Raison : le dépôt paraît chargé, mais c'est un problème de structure, pas de nombre de dépôts — et la scission a un coût réel (chaîne de publication, versionnage, une carte ajoutée qui exige un paquet republié) pour un bénéfice cosmétique tant qu'il y a un développeur. Le raisonnement de D-35 vaut à l'identique. Surtout : ce qui rend une scission chère plus tard, ce ne sont pas les dépôts, ce sont les imports croisés. Les workspaces les interdisent aujourd'hui, gratuitement. Un dépôt à frontières nettes se scinde en une après-midi ; un dépôt emmêlé, jamais.
L'axe de la scission, le jour venu, n'est pas « un dépôt par application » mais actif durable contre livrable déployable : data/, docs/, engine/ et tools/ restent ensemble (le moteur reste jetable au sens de §13.1, mais il évolue au rythme des règles et du kit qui le juge — l'en séparer ferait diverger les deux) ; ce sont le serveur et le client, qui ont des cycles de déploiement et des secrets propres, qui partiraient d'abord.
Trois signaux déclencheraient l'examen : un second contributeur avec des droits différents ; un besoin de CI et de secrets de déploiement pour le serveur ; un outillage client qui gêne le build du site.
Écarté : quatre dépôts maintenant (réintroduit la synchronisation sur l'actif durable, ce que D-35 refuse) ; les sous-modules git ; une publication npm privée du moteur (une chaîne de release à maintenir pour un seul consommateur interne).
D-58 · Statut du client, et son contrat de remplaçabilité
Acté : le mode test rapide (tranche 4) reste dans site/ — il est hors ligne, ne lit que data/ et n'écrit rien : la charte de D-35 tient. Il est délibérément minimal : c'est un banc d'essai pour valider les 120 cartes, pas une préfiguration d'interface. Le client de duel (P3), lui, ne vivra pas dans site/ : authentifié et porteur d'une session, il violerait cette même charte. Vue reste la piste de départ (le rendu de carte existe déjà en Vue, et VitePress est en Vue) ; ce n'est pas acté.
Le contrat qui rend le client remplaçable — trois règles, à tenir dès le mode test :
- Aucune règle du jeu dans le client. Toute légalité (cartes jouables, cibles valides, coût) vient du moteur, jamais d'une condition réécrite dans un composant. C'est aussi le bon usage du moteur côté navigateur : des affordances sans aller-retour, sans risque de désynchronisation puisque le serveur reste autoritaire (règle d'architecture 3).
- Le client ne parle au serveur que par intentions et événements. Pas d'endpoint ad hoc qui contournerait le protocole.
- Le rendu de carte est isolé derrière un composant unique (aujourd'hui
CardRender.vue). Il sera promu en paquet partagé le jour où un second consommateur existera — pas avant.
Sous ces trois règles, un client Phaser, PixiJS ou autre est un remplacement de la couche de rendu, pas une réécriture : il rebranche le même moteur sur le même protocole. C'est l'application directe de §13.1 — le client est consommable, l'actif est ailleurs.
Écarté : construire le client de duel dans site/ (mélange un site statique documentaire et une application authentifiée, et casse D-35) ; trancher Phaser maintenant (le vrai besoin d'un TCG est l'animation d'éléments discrets et beaucoup de texte de règles, ce que le DOM sert mieux — mais rien n'oblige à le décider avant d'avoir joué) ; la prédiction optimiste côté client en v1 (le serveur envoie les événements, le client anime).
P2 — le moteur (tranche 3)
Deux décisions prises en écrivant engine/ (2026-08-04). Le moteur passe le kit 75/75 ; ces entrées consignent ce que le kit ne dit pas — même discipline qu'aux tranches précédentes : un choix fait en codant est écrit ici, jamais laissé implicite dans le code.
D-62 · Les sémantiques que le kit ne fixe pas, arrêtées par le moteur de référence
Trois points tranchés, chacun dans le prolongement d'une décision actée ; aucun ne contredit un scénario existant. Si un cas réel s'y frotte un jour, un scénario l'épinglera.
- Créature jouée sur terrain plein : perdue, comme une invocation. La carte reste jouable (D-41 : seule exception, le permanent), la Ferveur est payée, la créature n'arrive pas —
SUMMON_LOST, la carte va au cimetière, et son Cri de guerre ne part pas :ON_PLAYse déclenche « après son arrivée en jeu » (D-49), qui n'a pas eu lieu. Écarté : rejeter l'intention — c'est le seuil de légalité que D-33 et D-41 refusent. SUMMONEDest un événement de créature. Poser un permanent n'émet queCARD_PLAYED. Cohérent avec Camaraderie (« une créature alliée entre en jeu », D-49) et avecscn_aura_cumul_deux_sources, qui n'attend queCARD_PLAYED.- La montée de plafond de la phase Début n'émet pas
FERVOR_GAINED. L'événement est réservé à la primitiveGAIN_FERVORet au +1 du second joueur (D-04, D-53) ; le rythme du tour se lit surTURN_STARTED.
Deux points du DSL restent volontairement non définis, aucune carte ni aucun scénario ne les exerçant : DISCARD en mode CHOSEN (le choix de la carte défaussée n'a pas de canal dans l'intention), et la position d'insertion de COPY destination: DECK (dessous de la pile en attendant). À acter le jour où une carte les exploite.
D-63 · Le juge du kit est unique et partagé ; la frontière D-56 porte sur le paquet livré
Acté : les assertions du kit vivent en un seul endroit — tools/kit-harness.mjs — utilisé par les deux harnais : la suite vitest de engine/ (la boucle de développement, un test par scénario) et le runner CLI générique tools/run-kit.mjs. Le runner ne connaît le moteur que par le contrat d'adaptateur — createScenarioAdapter(catalog, scenario) → hydrateScenario / setupMatch / reduce / effectiveStats — si bien qu'un prototype engine-b/ se juge au même kit sans toucher au juge : node tools/run-kit.mjs --adapter ../engine-b/dist/adapter.js.
Précision de périmètre sur D-56.5 : « rien hors de engine/ n'est importable depuis engine/ » s'applique au paquet livré — engine/src/ (→ dist/) — vérifié par engine/scripts/boundary-lint.mjs : imports relatifs internes uniquement, et aucun Date, Math.random, fetch, process, console, globalThis, minuterie, async/await/Promise, class, Map/Set, structuredClone. Le banc d'essai engine/test/, lui, lit data/conformance/ par le système de fichiers et importe le harnais de tools/ : c'est le juge, pas le moteur — il n'est pas livré, et le corollaire de D-56 exige précisément qu'un test du kit existe.
Écarté : dupliquer les assertions dans vitest et dans le CLI (deux juges finissent par diverger, et le kit cesse d'être une spécification unique) ; loger le runner dans engine/ (un prototype B dépendrait du paquet du prototype A pour être jugé) ; étendre la frontière au banc d'essai (le kit deviendrait inexécutable depuis le moteur).
Corpus — la phase de brouillon (2026-08-04)
D-59 · Le versionnement suit le statut du set, pas le calendrier
Tant que sets.json porte status ≠ "released" et releaseDate: null, le corpus est un document de travail : on édite un .v1.json en place, on supprime un fichier, on renumérote — sans v2, sans supersedes, sans changeNote. Au basculement du statut, D-22 s'applique intégralement et sans exception.
Raison : D-22 protège deux choses, les replays et les cartes imprimées. Avant la première partie, il n'existe ni l'un ni l'autre, et une lignée arrivée en v4 avant publication ne raconte rien — elle bruite le dossier et brouille la seule information que le versionnement doit porter, « cette carte a changé après que des gens l'ont jouée ». Adosser la règle à un champ déjà présent dans la donnée plutôt qu'à une date la rend vérifiable, et fait du gel un état du dépôt au lieu d'une promesse.
Trois cartes ne bougent pas, même en brouillon — le socle du kit : crea_add_oublie, sort_convocation_generale et token_disciple, que scn_invocation_terrain_plein épingle en version: 1. Leur nom, leurs stats, leur ambiance restent librement modifiables ; leur id et leur existence, non. Les supprimer casserait la spécification exécutable.
Le gel, le jour venu, fera trois choses : renumérotation canonique (par type puis par coût, 1 → 120), écriture d'un manifeste id + version + empreinte des cartes publiées, bascule de status et releaseDate. Le linter vérifiera ensuite qu'aucune carte verrouillée n'a bougé — l'immuabilité devient contrôlée au lieu d'être espérée, comme les deux règles que le schéma rend déjà inexprimables (D-27, D-29). Cet outil s'écrira ce jour-là, pas avant : le projet ne s'est jamais bien porté de construire pour un jour qui n'existe pas encore.
Corollaire d'outillage : tools/scaffold.mjs --overwrite n'a de sens que pendant cette phase. Il refuse de réécrire une carte dès la v2 et renvoie vers la création d'une version.
Écarté : appliquer D-22 dès maintenant (des v4 avant la première partie, et une discipline qui se relâche parce qu'elle coûte sans protéger) ; fixer une date de gel (invérifiable depuis la donnée) ; migrer les scénarios socles vers des fixtures pour libérer le corpus (ces trois cartes ont précisément été écrites pour être des témoins — les déraciner coûterait plus que de les garder).
D-60 · Aucun jeton ne porte le nom d'un rang
Le jeton token_disciple s'appelle « Disciple ». C'est aussi le premier rang de guilde dans ranks.json, dont la note prévoit explicitement une synergie « vos autres Disciples ». Or le jeton a rankId: null : il ne serait pas ramassé par cette synergie, alors que son nom promet le contraire à qui lit la carte. C'est exactement l'ambiguïté que 04-glossaire.md existe pour tuer.
Décision : aucun jeton ne porte le nom d'un rang. Le jeton est à renommer — pistes : Bleusaille, Recrue, Stagiaire (le dernier colle au registre administratif du set). Le choix du nom revient à Guillaume ; la règle, elle, est actée.
Ce que le renommage entraîne : le rulesText de sort_convocation_generale (« Invoquez deux Disciples 1/1 ») et le text de sa capacité. Rien ne l'attrape automatiquement — rulesText est la vérité de l'affichage, le moteur lit abilities. Renommer un jeton impose donc de grepper le corpus sur son nom. Le kit, lui, n'est pas concerné : les scénarios référencent des cardId, jamais des noms.
D-61 · Quatre légendaires par set, deux par deck
Amende D-14. L'extension en compte quatre — deux créatures et deux sorts, comme le disait déjà sets.json et §7.1 — et un deck n'en embarque jamais plus de deux, toutes cartes confondues. legendaryLimit: 4 dans sets.json, legendaryPerDeck: 2 dans les constantes de taxonomy.json.
Ce que la contradiction cachait : composition prévoyait 4 légendaires depuis P0, legendaryLimit en autorisait 2, et le linter appliquait le second. La prose de D-14 (« deux légendaires par set ») disait probablement « deux créatures » sans le préciser. Les deux places étaient déjà consommées par crea_kaelis_disciple et crea_guilvor : en l'état, aucun sort légendaire n'aurait jamais pu exister. C'est le premier lot de cartes qui a fait remonter le défaut — un an plus tard, il aurait coûté un nerf rétroactif.
Pourquoi le plafond de deck est le bon endroit pour la rareté. L'intention de D-14 était de rendre les légendaires désirables et d'étaler l'honneur sur plusieurs années. Le plafonner au niveau du set paie ce désir avec la seule ressource dont le projet manque : le nombre de membres qu'on peut honorer. Le plafonner au niveau du deck obtient le même effet sans ce coût — quatre membres honorés par extension au lieu de deux, et un choix réel au moment de construire (« laquelle des deux j'emmène ? »). C'est aussi, et surtout, un amortisseur de §10 : un nouveau venu qui n'en possède aucune accuse deux cartes de retard, jamais dix. Le plafond de set ne protégeait de rien de ce côté-là — il devient au contraire structurant à partir de l'extension 2, quand le pool cumulé dépassera la dizaine.
La tension à assumer, parce qu'elle est réelle : §12.2 pose que « la rareté encode l'identité, pas la force ». Un plafond de deck sur une rareté ressemble donc à un correctif de puissance, ce qu'il ne doit pas être. La justification n'est pas mécanique mais sociale — accessibilité (§10) et rareté de l'honneur — et elle doit le rester : une légendaire ne sera jamais écrite plus forte qu'une épique de même coût. Si un jour le plafond sert d'excuse à une légendaire au-dessus du budget, c'est le budget qui aura raison.
Portée technique : c'est une règle de construction de deck, pas une règle de partie. Le moteur ne la voit jamais — il reçoit des decks déjà validés (D-56 : le moteur ne lit pas son environnement). Elle sera appliquée par le deckbuilder et par la validation de deck côté serveur, en P3/P4. Aucune donnée de data/ ne la porte aujourd'hui autrement que comme constante : il n'existe pas encore de deck dans le dépôt.
Écarté : garder 2 par set et corriger composition à la baisse (paie la désirabilité en membres non honorés — le contraire de l'objectif social du projet) ; 4 par set sans plafond de deck (tenable aujourd'hui à quatre cartes, intenable dès la deuxième extension, et un nouveau venu se retrouverait mécaniquement loin derrière) ; un plafond exprimé en points de rareté façon « budget de deck » (une deuxième arithmétique à apprendre pour un pool de 120 cartes).
P3 — cadrage du duel en ligne (tranche 0, 2026-08-05)
Dix décisions prises avant d'écrire server/, dans la discipline de la tranche 0 de P2 : recenser ce que le moteur ne tranche pas, l'arbitrer par écrit, puis coder.
Elles reposent sur quatre réponses données ce jour, qui referment les questions ouvertes de D-55 : l'hébergement du processus stateful est le serveur qui porte déjà l'API de guilde (un processus Node long — ni serverless, ni sessions collantes, ni réveil à froid) ; P3 ne se raccorde pas à l'API de guilde (comptes, collection, Sceaux et decks viendront après) ; l'entrée en partie se fait par lien d'invitation à code opaque, sans authentification ; et le travail commence par le serveur seul, validé par des clients scriptés.
Conséquence directe, qui n'était pas acquise : P3 n'a besoin d'aucune base de données (D-67).
Quatre lacunes ont été trouvées en relisant engine/src/, et non supposées. Trois fondent des décisions ci-dessous ; la quatrième est un non-problème qu'il valait mieux vérifier :
reduce(catalog, state, intent)ne sait pas qui parle — il agit au nom destate.activePlayer(D-65) ;setupMatchveut les deux mulligans d'un coup, alors qu'il faut montrer les mains avant de les demander (D-70) ;- la légalité d'un deck n'est vérifiée nulle part — ni dans le moteur, ni dans
tools/, ni danssite/(D-69) ; - Furtif n'est pas à masquer dans la vue filtrée : D-44 dit « invisible, pas intangible », la créature reste en jeu et comptée, seul le ciblage la protège — et c'est le moteur qui l'applique (D-66).
D-64 · Le cœur du serveur est pur lui aussi ; NestJS + Socket.IO n'est qu'un adaptateur
Acté : la logique de partie côté serveur est une fonction pure — sessionReduce(session, message, now) => { session, outbox }. Aucune E/S, aucune socket, aucun setTimeout, aucun accès disque dans le cœur. Le temps entre par le paramètre now ; les échéances sortent dans outbox sous forme de données ({ at, kind }), et c'est l'adaptateur qui pose les vrais minuteurs.
Acté aussi, et cette fois sans report : l'adaptateur est NestJS + Socket.IO, la piste que D-55 gardait sous le coude. C'est le framework déjà connu, et §13.3 — un prototype = une variable — dit d'en changer un seul à la fois : P3 change déjà le mode d'exécution (un processus stateful, un protocole, deux clients), c'est bien assez pour une tranche.
Raison du cœur pur : c'est ce qui rend « serveur seul d'abord » réellement testable — une partie complète à deux clients scriptés se joue sans réseau, sans horloge et sans framework, en quelques millisecondes et de façon déterministe. Et c'est ce qui garde le framework remplaçable alors même qu'on vient de le choisir : Nest n'est acté que pour la coquille, et un jour où il gênerait, il partirait sans emporter la logique. Même raisonnement que D-56, un cran au-dessus — le contrat d'hermétisme est ce qui a payé le report de D-55 ; on rejoue le coup gagnant, cette fois pour rendre un choix réversible plutôt que pour le différer.
Écarté : la logique dans les gestionnaires de socket (intestable sans réseau, et le framework devient irréversible en une semaine — c'est précisément ce que Nest, avec son injection de dépendances, rend facile à mal faire) ; de vrais minuteurs dans le cœur (tests lents, intermittents, et un chronomètre de 75 s impossible à faire avancer dans un test) ; différer encore le choix du framework (le report ne payait plus rien une fois D-64 posée).
D-65 · L'auteur d'une intention est vérifié par le serveur, jamais par le moteur
Acté : chaque connexion est liée à un siège (P1 ou P2) à l'entrée en partie. Le serveur refuse toute intention dont l'auteur n'est pas state.activePlayer — sauf CONCEDE, seule intention légale hors de son tour (D-42), dont il vérifie que le player correspond bien au siège de la connexion. Ce refus est un refus de protocole, de nature distincte d'un refus du moteur (D-71).
Raison : reduce agit au nom du joueur actif sans jamais demander qui parle. Sans cette garde, n'importe quel client peut jouer les cartes de son adversaire — c'est la faille de triche la plus directe de P3. Le moteur ne peut pas la fermer : il ne connaît pas les connexions, et D-56 lui interdit de les connaître.
Écarté : ajouter un champ player à toutes les intentions — cela casserait intent.schema.json, les 75 scénarios du kit et le mode test, pour déplacer une responsabilité qui resterait de toute façon celle du serveur (un client peut mentir sur un champ de message, pas sur la connexion qui le porte).
D-66 · La vue filtrée est un masquage qui produit un état de partie valide
Acté : projectFor(state, player) remplace, chez l'adversaire, chaque carte en main et en deck par une référence opaque réservée — { cardId: "card_hidden", version: 1 } — en conservant les effectifs. Rien d'autre n'est masqué. Le résultat reste un GameState conforme à state.schema.json.
Raison : c'est la condition pour que le contrat D-58.1 tienne côté client. Les affordances du client sont des reduce() spéculatifs — mécanisme déjà éprouvé en tranche 4 de P2 — et ils exigent un état valide, pas un objet de transport appauvri. Conserver les effectifs garde justes HAND_SIZE_AT_LEAST, la fatigue et la brûlure de surpioche. Le serveur restant autoritaire (règle d'architecture 3), une spéculation qui se tromperait sur de l'information cachée — une défausse aléatoire dans la main adverse — est sans conséquence : les événements du serveur font foi.
Où : dans @lgc/engine, en export séparé du réducteur — la fonction est pure, synchrone, sérialisable, et le kit ne la touche pas. card_hidden est une carte du catalogue (un dos de carte, coût 0) : de la donnée, pas du code.
Écarté : n'envoyer que les événements et laisser le client reconstruire (il lui faudrait l'état initial complet, qui contient la main adverse) ; un objet de transport allégé (le client ne pourrait plus faire tourner le moteur dessus, et D-58.1 tomberait) ; masquer aussi les créatures furtives (D-44 les veut visibles, et les retirer fausserait les décomptes de terrain des deux côtés).
D-67 · Une partie est un journal ; l'état n'est jamais persisté
Acté : le serveur garde l'état en mémoire et n'écrit qu'un journal en ajout — seed, decks, classes, puis les intentions acceptées, dans l'ordre. C'est exactement scenario.schema.json (D-52). Reprise après redémarrage, reconnexion d'un joueur et replay de P4 sont le même mécanisme : rejouer le journal.
Raison : le déterminisme est prouvé mécaniquement (D-56, engine/test/hermetic.test.ts), donc le journal est l'état. Il est plus petit, lisible, comparable, et il survit à un changement de forme de GameState — une sauvegarde d'état, non.
Corollaire : P3 n'a besoin d'aucune base de données. Un fichier par partie sous server/journals/ suffit à l'échelle de la guilde. La question se rouvrira pour le hors-partie (collection, decks), pas pour le duel.
Écarté : sérialiser l'état à chaque coup (plus lourd, et toute évolution de l'état invalide les sauvegardes) ; une base de données dès maintenant (D-55 l'a différée et rien ici ne la réclame).
D-68 · Le temps entre par un seul point, et il sort en données
Acté : le cœur reçoit now et renvoie des échéances ; l'adaptateur les arme. Trois horloges, toutes hors moteur (D-56.1) :
| Échéance | Valeur | Effet |
|---|---|---|
| Chronomètre de tour | 75 s (D-42) | Le serveur injecte END_TURN d'office |
| Grâce à la déconnexion | 120 s | Au-delà, CONCEDE pour l'absent |
| Expiration d'un code d'invitation | 30 min | Le salon est rangé, le code ne vaut plus rien |
Raison : c'est la seule façon de tester un chronomètre sans attendre 75 s, et le pendant strict de D-56.1 côté serveur. Les 120 s couvrent un tunnel de métro ou une bascule wifi/4G sans faire poireauter l'adversaire ; les 30 min correspondent à l'usage réel d'un lien collé sur Discord et rejoint dans la foulée.
Écarté : abandonner la partie dès la déconnexion (un réseau qui hoquette terminerait le duel) ; laisser vivre indéfiniment des salons orphelins en mémoire ; setTimeout dans le cœur.
D-69 · La légalité d'un deck est une fonction pure, à côté du moteur mais hors de sa boucle
Acté : validateDeck(catalog, deck) => erreurs[] — 30 cartes exactement, plafonds de copies par rareté (3 / 2 épique / 1 légendaire), 2 légendaires au total (D-61), jetons interdits, cartes du set publié uniquement. Livrée dans @lgc/engine en export séparé, appelée par le serveur à l'entrée en partie, et plus tard par le client (retour immédiat au deckbuilding) et par tools/.
Raison : D-61 dit que la construction de deck n'est jamais vue par le moteur de partie — pas qu'elle doit être réécrite dans chaque appelant. La loger dans le paquet du moteur lui donne le même catalogue injecté, le même contrat d'hermétisme et le même harnais de test, sans ouvrir un troisième espace de travail. Un serveur autoritaire qui accepte un deck de 42 cartes n'est pas autoritaire.
Écarté : un workspace @lgc/rules dédié (une frontière de paquet pour une fonction — à rouvrir le jour où la construction de deck aura sa propre surface) ; la validation côté client seul (contredit la règle d'architecture 3) ; la loger dans tools/ (le serveur en dépendrait, or tools/ est hors espace de travail).
D-70 · Le mulligan : deux appels à setupMatch, aucune API nouvelle
Acté : pour proposer le mulligan, le serveur appelle setupMatch(catalog, { seed, decks, classes, mulligans: [] }) et lit les mains obtenues. Une fois les deux choix reçus, il rappelle setupMatch avec les mulligans réels et jette le premier résultat.
Raison : la distribution précède le mulligan et ne dépend que du seed (D-51, D-53) — un mulligan vide ne consomme aucun aléa. Les mains montrées sont donc exactement celles du second appel, et la propriété « même seed ⇒ mêmes mains » est déjà éprouvée par le mode test. Coût : un setupMatch gaspillé par partie, invisible.
Écarté : exposer un dealOpeningHands (élargir l'API du moteur pour un besoin d'ordonnancement propre au serveur) ; laisser le serveur re-dériver les mains lui-même (deux implémentations du tirage, qui divergeront le jour d'un changement de mélange).
D-71 · Le protocole : intentions et événements, numérotés et idempotents
Acté : le client n'envoie que { seq, intent } ; le serveur répond { ack: seq, events[], rejected } et pousse à l'adversaire { events[] } — chacun dans sa vue (D-66). Deux règles :
- Idempotence — rejouer un
seqdéjà traité renvoie la même réponse sans rien appliquer. Le réseau perd des acquittements, pas des tours de jeu. - Reprise — à la reconnexion, le client annonce le dernier
seqreçu ; le serveur renvoie le delta d'événements, ou un instantané filtré complet s'il est trop en retard.
Le refus du moteur est transmis tel quel (D-58 : le client l'affiche, il ne le réinterprète pas). Un refus de protocole (D-65, hors tour, partie inconnue, seq incohérent) est d'une autre nature et porte un code distinct.
Écarté : le moindre point d'entrée hors protocole (D-58.2) ; envoyer l'état complet à chaque coup (la règle d'architecture 3 l'interdit, et c'est cher pour rien) ; la prédiction optimiste côté client en v1 (déjà écartée en D-58).
D-72 · Le salon : un code opaque, deux sièges, rien d'autre
Acté : créer une partie produit un code aléatoire non devinable (≥ 128 bits, encodé en base32) et une URL /j/<code>. Le créateur prend un siège, l'invité l'autre ; c'est ensuite le moteur qui tire au sort qui commence (D-42) — le code ne décide de rien. Il est à usage unique et expire au bout de 30 min (D-68). Le pseudo est saisi librement, non authentifié, non durable, et explicitement provisoire jusqu'au raccordement à l'API de guilde.
Raison du pseudo libre : zéro dépendance, et il se remplace par l'identité de l'API sans rien casser le jour venu.
Écarté : appariement automatique ou file d'attente (rien à apparier à l'échelle d'une guilde) ; un code court et mémorisable (devinable, donc parties squattables) ; une liste de membres figée dans data/ (une seconde source de vérité sur l'identité, à maintenir à la main, exactement ce que D-55 veut éviter) ; des comptes propres au jeu (même défaut, en pire).
D-73 · server/, espace de travail npm, catalogue lu sur disque au démarrage
Acté : dossier server/ à la racine, workspace @lgc/server (D-57), jetable au même titre que tools/, site/ et engine/ — l'actif durable reste data/ et docs/. Il lit data/ par le système de fichiers au démarrage et injecte le catalogue (D-56.4) ; jamais d'import compilé.
Raison : c'est ce qui fait survivre la règle d'architecture 1 — « ajouter une carte, aucun déploiement » — à P3 : un git pull et un redémarrage, sans reconstruction. Cela aligne aussi le serveur sur tools/, qui lit déjà data/ ainsi.
Écarté : empaqueter data/ dans le bundle du serveur (chaque carte redevient un déploiement) ; publier @lgc/engine sur npm pour que le serveur le consomme (D-57 l'a écarté) ; loger le serveur hors du dépôt (D-35, D-57 — la scission viendra par l'axe « actif durable contre livrable déployable », le jour où les trois signaux de D-57 s'allumeront).
D-74 · Le client de duel est en Vue ; Three.js est une couche à prouver, pas une fondation
Amende D-58, qui laissait Vue en « piste de départ, non actée ».
Acté : le client de duel (tranche 3 de P3) est en Vue, et CardRender.vue reste le rendu de carte unique (D-58.3). Three.js n'est pas acté : il est traité comme une couche additive à prouver, par une maquette placée avant la tranche 3, avec un critère écrit d'avance.
Raison — le point dur est le rendu de carte, pas le framework. CardRender.vue est du CSS/SVG ; une scène Three.js est un canvas WebGL ; les deux ne se composent pas. Et CardRender.vue ne disparaîtrait pas si le client passait en WebGL : le générateur, la galerie, l'export PNG 744 × 1039 et la planche A4 en dépendent, et ce sont des besoins de P1, indépendants de P3. Un client Three.js n'échangerait donc pas un rendu contre un autre — il en ajouterait un second, à maintenir en parallèle, sur l'objet le plus investi du projet (le gabarit vectoriel doré : rareté = alliage du jonc, type = silhouette de la fenêtre). C'est précisément ce que D-58.3 cherche à empêcher.
Ce que le CSS donne déjà, et qu'il ne faut pas racheter : l'inclinaison 3D avec perspective et parallaxe (preserve-3d) ; l'enluminure holographique réactive au pointeur, effet CSS documenté et abouti — et l'enluminure est du cosmétique pur (D-20), elle ne descend jamais dans le moteur ; les transitions main → terrain, les nombres de dégâts, les morts, qui sont des transformations d'éléments discrets, ce que le DOM compose bien. Ce que Three.js apporte en propre est le volumétrique et le particulaire — un vrai supplément, dont l'attente qu'il crée a une valeur sociale réelle ici (le générateur a été rentable pour cette raison), mais un supplément.
Le critère de la maquette, écrit d'avance pour ne pas se négocier après :
- une carte du corpus dans une scène Three.js, texte de règles lisible à taille de main ;
- une enluminée en shader, comparée honnêtement à la même en CSS holographique ;
- le coût de mise à jour quand les stats effectives changent — buffs et auras touchent aussi les cartes en main (règle d'architecture 4), donc une texture cuite se recuit souvent ;
- et la question qui tranche : combien de gabarits faut-il maintenir à l'arrivée ? Si la réponse est deux, la maquette a échoué, même belle.
Les deux issues, préparées : si la maquette gagne, le chemin probable est la cuisson en texture via html-to-image — déjà utilisé par les exports, avec ses pièges connus (aucun -- dans un commentaire du template, background-clip: text ne survit pas). Si elle échoue sur la lisibilité, l'hybride est l'option par défaut : canvas WebGL pour le terrain et les effets, DOM par-dessus pour les cartes et le texte — elle garde CardRender.vue unique, au prix des shaders sur les cartes elles-mêmes.
Écarté : acter Three.js maintenant (ce serait décider à l'aveugle la seule brique dont le coût principal est un second moteur de rendu, alors que rien dans les tranches 1 et 2 n'en dépend — D-58 disait déjà « rien n'oblige à le décider avant d'avoir joué », et la décision sera dix fois mieux informée après avoir vu une partie tourner) ; réimplémenter le gabarit en WebGL (deux gabarits qui divergeront au premier ajustement de teinte) ; renoncer d'avance à Three.js (le volumétrique est un gain réel, il mérite d'être mesuré plutôt que refusé) ; un client Phaser ou PixiJS (D-58 l'avait déjà écarté pour la même raison : beaucoup de texte de règles et des éléments discrets).
P3 — tranche 1, le cœur pur (2026-08-06)
La tranche 1 est livrée : server/ (@lgc/server, D-73) contient sessionReduce(catalog, session, message, now) => { session, outbox } (D-64), sous un lint de frontière identique à celui du moteur — aucune E/S, aucun minuteur, session strictement sérialisable, seule dépendance admise @lgc/engine. Le critère de fin est prouvé par le banc d'essai : une partie complète entre deux clients scriptés sans aucune règle du jeu (candidats essayés par reduce() spéculatif sur leur seule vue filtrée), zéro réseau, sur deux vrais decks de 30 du corpus, et le journal produit rejoue à l'identique dans le juge du kit (tools/kit-harness.mjs) — égalité stricte de l'état final, pas seulement les assertions. Trois décisions et arbitrages ont été pris en route.
D-75 · La vue filtrée masque aussi le futur : son propre deck et l'état du générateur
Amende D-66, qui masquait la main et le deck adverses et disait « rien d'autre n'est masqué ».
Acté : projectFor(state, player) remplace aussi le deck du joueur lui-même par des références card_hidden (effectifs conservés), et remet rngState à 0. Le résultat reste un GameState valide.
Raison : D-66 masquait ce que l'adversaire ne doit pas savoir ; il restait deux fuites vers le joueur sur son propre futur. L'ordre de son deck est une information cachée pour les deux camps — le transmettre, c'est offrir la liste des pioches à venir dans les outils de développement du navigateur. Et rngState est pire : il permet de prédire tout aléa à venir (cible d'une défausse aléatoire, d'un Rituel du Pilori), donc de choisir son ordre d'actions en fonction du résultat. Une spéculation client qui se trompe d'aléa reste sans conséquence — l'argument de D-66 tient : les événements du serveur font foi.
Écarté : mélanger le deck projeté au lieu de le masquer (il faudrait de l'aléa dans une fonction pure, et l'effectif suffit aux affordances) ; masquer le cimetière ou le Furtif (publics — D-44 est explicite pour le second).
D-76 · Toute poussée d'état embarque la vue filtrée ; l'état complet reste interdit
Précise D-71, qui faisait voyager les seuls événements.
Constat de tranche 1 : un client tenu par D-58 — aucune règle du jeu — ne sait pas replier un événement adverse dans son état : « appliquer DAMAGE_TAKEN » est une règle. Les événements seuls suffisent à raconter, pas à maintenir un état. Le protocole tel qu'écrit rendait le client impossible sans violer D-58.
Acté : chaque ACK et chaque poussée d'événements embarque la vue filtrée à jour (D-66) du destinataire. Les événements restent le récit (animations, journal visuel) ; la vue est l'état qui fait foi. Ce que D-71 écartait — « envoyer l'état complet à chaque coup » — reste écarté : c'est la projection, jamais l'état complet, et son coût à l'échelle d'une guilde est négligeable face à la classe entière de bugs de désynchronisation qu'elle supprime.
Écarté : replier les événements côté client (des règles du jeu dans le client, D-58.2) ; rejouer l'intention adverse côté client (elle n'est pas transmise, et la spéculation divergerait sur tout aléa).
Arbitrages consignés sans nouveau numéro
- D-69 et le brouillon (D-59) : « cartes du set publié uniquement », au pied de la lettre, interdisait tout deck tant que
sets.jsonestplaceholder. Arbitré (option validée par Guillaume) : la règle de set s'arme au premier set publié — tant qu'aucun set n'estpublished, toute carte non-jeton du catalogue est légale ; dès qu'un set l'est, seules les cartes des sets publiés passent. Le gel du set arme la règle sans toucher au code.validateDecklit raretés et statuts dans le catalogue injecté (champs optionnelsraritiesetsets) — même source, pas de seconde vérité. - Périmètre de la tranche 1 (choix de Guillaume) : toute la logique pure — échéances en données (chrono de tour 75 s, grâce de déconnexion 120 s, D-68), idempotence des
seqet reprise par delta ou instantané (D-71) — pas seulement le critère de fin minimal. La tranche 2 n'a plus qu'à traduire : socket → siège, minuteur réel → messageTIME, item outbox → émission, ligneJOURNAL→ fichier, plus le salon (code opaque et son expiration 30 min, D-72). card_hiddenentre au corpus comme jeton hors collection (data/cards/card_hidden.v1.json) — de la donnée, pas du code (D-66). Le motif d'identifiant des deux schémas admet cette unique lignée sans préfixe :^((crea|sort|perm|token)_[a-z0-9_]+|card_hidden)$.- Les événements aussi sont projetés (D-66 appliqué au flux, D-71 « chacun dans sa vue ») : l'identité d'une carte qui entre dans une zone cachée d'un autre joueur (pioche, tutorat, création en main ou en deck) est remplacée par
card_hidden; tout ce qui finit dans une zone visible (jeu, cimetière — défausse et brûlure comprises) reste public. - Écart repéré, non tranché : la spec §1 et
taxonomy.jsonprévoient 3 banques de temps de 30 s en plus des 75 s/tour ; D-68 ne les mentionne pas et la tranche 1 ne les implémente pas. À trancher en tranche 2 (adaptateur), où vivent les vrais minuteurs : soit D-68 s'amende, soit §1 se simplifie. - Les constantes d'horloge serveur vivent désormais dans
taxonomy.json → constants(disconnectGraceSeconds: 120,inviteCodeTtlSeconds: 1800) : injectées comme le reste, valeurs de la spec en repli dans le code.
D-77 · Trois noms stables sous guilde-lgc.fr ; l'hébergement migre derrière eux
Acté (2026-08-06) : les URL publiques du projet, indépendantes de leur hébergement —
| Service | URL | Aujourd'hui | À terme |
|---|---|---|---|
| Le jeu (client de duel, tranche 3) | https://tcg.guilde-lgc.fr | Netlify | VPS |
Le serveur de duel (@lgc/server) | https://tcg-server.guilde-lgc.fr | — (rien avant la tranche 4) | VPS, d'emblée |
| La doc (site VitePress de P1) | https://tcg-docs.guilde-lgc.fr | Netlify — vit encore sur tcg.guilde-lgc.fr | VPS |
Acté aussi : tout finit sur le VPS qui porte l'API de guilde — Netlify n'est qu'un hébergement de transition pour les deux sites statiques. Les URL sont le contrat stable ; l'hébergement change derrière sans que rien ne se renomme — même logique que D-25 : le nom vit dans la configuration, pas dans ce qui le consomme.
Raisons :
- L'URL courte revient au jeu, pas à la doc : c'est le produit quotidien, et c'est elle qui porte les liens d'invitation (D-72) —
tcg.guilde-lgc.fr/j/<code>est ce qu'on colle sur Discord. - Le serveur sur son propre sous-domaine n'est pas une préférence : Netlify ne proxifie pas les WebSockets — un chemin
/apisur le domaine du jeu ne peut pas porter Socket.IO tant que le client est sur Netlify. Et une fois tout sur le VPS, le sous-domaine reste : il découple le déploiement du client (fichiers statiques) de celui du serveur (processus long), et évite au reverse proxy de démêler WebSocket et statique sur un même vhost. - Le même domaine parent pour les trois : un cookie
.guilde-lgc.frdevient possible au raccordement à l'API de guilde (après P3), sans renommage.
Conséquences, à exécuter au déploiement (tranches 3-4), rien avant :
- La doc ne bouge pas avant que le client existe : elle reste sur
tcg.guilde-lgc.frjusqu'à la tranche 3, puis bascule DNS en une fois. À ce moment-là : redirections 301 des anciens chemins de doc verstcg-docs(les favoris et liens Discord existants tomberaient sinon sur le client de jeu),/j/*réservé au salon, et mise à jour des liens du site de guilde. Le banc/testsuit la doc. Sur Netlify les redirections vivent dans_redirects; sur le VPS, dans le reverse proxy — elles survivent donc à la migration. - Deux sites statiques depuis le même dépôt tant que Netlify dure : déplacer
netlify.toml(racine →site/) et en créer un dansclient/, ou configurer les bases dans l'interface Netlify. - Checklist VPS pour
tcg-server: enregistrement DNS vers le VPS (pas un CNAME Netlify) · vhost + certificat dans le reverse proxy existant · en-têtesUpgrade/Connectionpour le WebSocket ·proxy_read_timeoutsupérieur à l'intervalle de ping Socket.IO, sinon les parties se coupent en silence · CORS restreint àhttps://tcg.guilde-lgc.fr(plus localhost en dev). - Le client apprend l'URL du serveur à la construction (variable d'environnement Vite, tranche 3) — jamais en dur.
Écarté : tout servir depuis le domaine du jeu avec un proxy /api (impossible pour les WebSockets tant que le client est chez Netlify, et couplerait les deux déploiements) ; laisser la doc sur l'URL courte (le nom court au produit quotidien, et les liens d'invitation y gagnent) ; des domaines hors guilde-lgc.fr (perdrait le cookie parent futur et l'identité de guilde).
P3 — tranche 2, l'adaptateur (2026-08-06)
La tranche 2 est livrée : server/app/ contient l'adaptateur NestJS + Socket.IO annoncé par D-64, hors de server/src/ — le lint de frontière n'a pas bougé d'une ligne, le cœur reste hermétique. L'adaptateur ne fait que ce que la tranche 1 lui avait laissé : socket → siège (D-65), minuteur réel → message TIME (D-68), ligne JOURNAL → fichier (D-67), item d'outbox → émission, plus le salon (D-72). Aucune règle du jeu n'y est descendue.
Le critère de fin est un test, et il est plus dur que celui de la tranche 1 (server/test/e2e.test.ts) : les mêmes bots sans règles que la tranche 1 jouent une partie complète sur un vrai Socket.IO, le serveur est tué en plein milieu puis relancé, les deux joueurs reprennent leur siège, la partie va jusqu'au bout — et le fichier de journal laissé sur le disque rejoue dans le juge du kit avec égalité stricte de l'état final. Zéro refus du moteur, zéro refus de protocole, sur toute la partie et par-dessus la coupure. Les trois horloges de D-68 sont vérifiées avec de vraies minuteries, en changeant simplement les constantes de taxonomy.json dans un data/ jetable — pas une injection de test : la donnée, comme le ferait un équilibrage.
Le pari de D-64 se lit dans les chiffres : le cœur pur fait environ 500 lignes et porte toute la logique de partie ; l'adaptateur en fait autant et ne porte que de la plomberie. Cinq décisions ont été prises en route, dont trois amendent la tranche 1 — c'est exactement ce qu'une tranche d'adaptation est censée révéler.
D-78 · Le compteur de séquence appartient au serveur : lastSeq voyage dans chaque poussée
Amende D-71, qui numérotait les intentions sans dire qui tient le compteur.
Acté : chaque poussée EVENTS et chaque SNAPSHOT portent le lastSeq du siège destinataire — le dernier seq que le serveur a traité pour lui. Le client n'a donc jamais à mémoriser son compteur : il le relit.
Raison — le trou était réel, et la tranche 2 l'a fait tomber deux fois. D-71 dit « à la reconnexion, le client annonce le dernier seq reçu », ce qui suppose qu'il le connaît encore. Or il ne le connaît plus dans deux cas très ordinaires : (1) un rechargement de page — le compteur vivait en mémoire, il repartirait à 1 et se ferait refuser indéfiniment ; (2) un redémarrage du serveur — le compteur reconstruit par rejeu du journal n'a aucune raison de coïncider avec celui du client, parce que les intentions refusées par le moteur consomment un seq et ne sont pas journalisées (D-67 ne journalise que les intentions acceptées). Sans D-78, la reprise après crash est impossible en pratique, quelle que soit la qualité du journal.
Le coût est un entier par message, il est par siège donc il ne révèle rien de l'adversaire, et il rend le compteur auto-réparateur à tout instant — le pendant naturel de l'idempotence de D-71.
Écarté : persister le compteur côté client (fragile, et faux après un redémarrage serveur) ; écrire le seq dans le journal (il salirait un format qui est aussi celui des scénarios du kit, et ne réglerait toujours pas le rechargement de page) ; renuméroter à chaque reconnexion par un message dédié (un message de plus pour ce qu'un champ fait).
D-79 · Entrer dans une partie n'est pas authentifié ; y revenir l'est — le jeton de siège
Amende D-72, qui décrit l'entrée et ne dit rien du retour.
Acté : à la prise d'un siège, le serveur remet à cette connexion un jeton de siège aléatoire (128 bits) sur un canal qui lui est propre. Reprendre un siège exige { code, seat, seatToken } ; sans le jeton, c'est refusé (BAD_SEAT_TOKEN). Le jeton est stocké côté client (sessionStorage en tranche 3) et persisté à côté du journal, dans un fichier <code>.meta.json — il survit donc au redémarrage, sans quoi la reprise ne servirait à rien.
Raison : D-72 a raison sur l'entrée — deux sièges, premier arrivé premier servi, aucun compte. Mais le code est un secret porteur partagé, collé sur Discord ; sans jeton, quiconque l'a vu passer peut, en cours de partie, chasser un joueur de son siège et jouer ses cartes. C'est le frère jumeau de la faille de D-65 — « le serveur ne sait pas qui parle » — un cran plus haut, et elle se ferme au même endroit et au même prix : quelques lignes dans l'adaptateur.
Le journal reste pur. Le jeton vit dans un fichier voisin, pas dans le .jsonl : celui-ci doit rester exactement ce que D-67 décrit, parce qu'il est aussi le format de scénario (D-52) que le juge du kit rejoue et que le replay de P4 relira. Un secret de transport n'a rien à y faire.
Détail d'implémentation qui n'en est pas un : reprendre un siège délaisse la connexion précédente, elle ne la coupe pas. La couper est le réflexe, et c'est un piège à deux détentes — couper la même socket (un client qui réessaie sa reprise) fait passer un DISCONNECT puis un RECONNECT dans la file et laisse le cœur convaincu qu'un siège mort est connecté, sans horloge de grâce ; couper une autre socket déclenche, chez un client qui suit la consigne « se reconnecter quand le serveur raccroche », une expulsion mutuelle sans fin entre deux onglets (mesurée : 47 aller-retours en 2,5 s). Délaissée, l'ancienne connexion ne reçoit plus rien et ses intentions sont refusées : elle s'éteint d'elle-même.
Écarté : aucun jeton, le code suffit (usurpation de siège triviale) ; dériver le jeton d'un secret serveur par HMAC (élégant, mais il faut alors gérer la stabilité d'un secret entre deux déploiements — une variable d'environnement de plus à ne pas perdre, contre un fichier qui vit déjà à côté du journal) ; des comptes propres au jeu (D-72 l'a écarté, et le raccordement à l'API de guilde le rendra caduc).
D-80 · Les banques de temps sont abandonnées : 75 s par tour, et rien d'autre
Amende §1 et D-68, et referme le point ouvert n° 5.
Acté : le chronomètre de tour est de 75 s, point. Les 3 banques de 30 s que §1 et taxonomy.json annonçaient sont supprimées — du tableau de référence de §1 comme des constantes (timeBanks, timeBankSeconds : personne ne les lisait).
Raison : elles n'ont jamais été implémentées nulle part, et les ajouter aurait obligé à rouvrir le cœur pur de la tranche 1 (un compteur par siège, une échéance de tour calculée à partir du solde restant, un décrément à l'expiration, et les 22 tests correspondants) pour un confort dont aucune partie réelle n'a encore montré le besoin. Une décision d'ergonomie de tournoi n'a pas à être prise avant le premier test de table.
Réversible par construction : le jour où les parties montrent qu'un tour de 75 s coupe des enchaînements légitimes, les banques reviennent en ajout — un champ dans le siège, une échéance calculée autrement, aucune migration : la session n'est jamais persistée (D-67), donc son format ne lie personne.
Écarté : les implémenter maintenant (rouvrir la tranche 1 pour un besoin non constaté) ; les laisser écrites dans §1 sans les implémenter (une spécification qui ment est pire qu'une spécification pauvre — c'est ce qui a créé le point ouvert n° 5).
D-81 · Les échéances se relisent dans la session ; l'outbox n'en est que la notification
Précise D-64/D-68, qui disaient « les échéances sortent dans l'outbox et l'adaptateur pose les vrais minuteurs ».
Acté : l'adaptateur réarme son unique minuteur à partir de la session — le minimum de turnDeadlineAt et des graceAt des sièges déconnectés — et non à partir des items DEADLINE de l'outbox, qu'il traite comme une simple notification.
Raison : une session ressuscitée par rejeu du journal n'a produit aucune outbox — le rejeu jette tout ce qui sort — et ses échéances doivent pourtant être réarmées. Suivre les items d'outbox marche tant qu'on n'a jamais redémarré, c'est-à-dire jusqu'au premier déploiement. Lire la session couvre les deux cas avec moins de code, et supprime toute possibilité de désaccord entre ce que le cœur croit et ce que l'adaptateur a armé.
Corollaire opérationnel : au rejeu, now est l'instant de la reprise, le même pour toutes les lignes — le chronomètre de tour repart donc à 75 s pleines. Rejouer aux horodatages d'origine ferait expirer le tour d'un joueur pour une panne dont il n'est pas responsable.
D-82 · La reprise est à la demande, et le journal s'écrit avant l'acquittement
Acté, deux règles qui ne tiennent qu'ensemble :
- À la demande (choix de Guillaume) : au démarrage, le serveur ne charge rien. Le premier joueur qui revient avec son code fait relire son journal et ressusciter la session en mémoire ; il reçoit ensuite le chemin de reprise normal de D-71. Une partie abandonnée depuis trois jours ne coûte qu'un fichier sur le disque, et il n'y a aucune règle de péremption à écrire.
- Durabilité d'abord : dans le traitement de l'outbox, une ligne
JOURNALest écrite et attendue avant l'émission des messages qui la suivent. UnACKqui part avant que l'intention soit sur le disque est un coup que le client croit joué et que le serveur perdra au redémarrage suivant.
Raison de la seconde : c'est elle qui rend la première honnête. « Rejouer le journal redonne la partie » (D-67) n'est vrai que si le journal est en avance sur ce que les clients ont vu, jamais en retard. C'est aussi ce que le banc d'essai vérifie en tuant le serveur au milieu d'une partie : après reprise, aucun coup acquitté n'a disparu.
Le mode de panne du fichier en ajout, traité : un arrêt brutal en pleine écriture laisse une dernière ligne partielle. Elle est ignorée à la relecture — la perdre coûte au plus une intention, celle dont l'acquittement n'était de toute façon jamais parti. Refuser tout le fichier pour cette ligne rendrait la partie définitivement irrécupérable, exactement le contraire de ce que D-67 promet. Une ligne illisible ailleurs qu'en queue est une vraie corruption : elle passe, et la partie se refuse proprement (le joueur reçoit UNKNOWN_GAME, l'anomalie part dans les logs).
Écarté : balayer server/journals/ au démarrage pour ressusciter toutes les parties en cours (il faudrait alors une règle de péremption, et les 120 s de grâce se remettraient à courir pour des joueurs qui ne sont pas revenus) ; écrire le journal en tâche de fond (plus rapide, et faux au premier arrêt brutal) ; une base de données (D-67 : rien ne la réclame à l'échelle d'une guilde).
D-83 · Le serveur refuse aussi ce que le moteur laisse passer : les types d'intention inconnus
Trouvé en relisant la tranche 2, et reproduit — même famille que D-65.
Acté : le cœur vérifie que intent.type appartient aux sept types de intent.schema.json avant de tenter quoi que ce soit, et refuse sinon (UNKNOWN_INTENT). Et l'adaptateur enveloppe l'appel au cœur : si le moteur lève au lieu de refuser, le siège reçoit un refus MALFORMED — jamais un silence.
Raison : le switch de reduce n'a pas de branche par défaut. Un { type: "POUBELLE" } le traverse sans déclencher aucun rejet et ressort accepté — donc journalisé, ajouté aux intentions, et poussé aux deux sièges avec un état filtré complet. Un joueur assis pouvait ainsi, en une boucle, faire grossir sans limite le fichier de journal et la mémoire (une écriture disque et deux projections d'état par message), et surtout rendre le journal invalide comme scénario (D-52) — le juge du kit et le replay de P4 l'auraient refusé. Symétriquement, un handIndex qui est une chaîne fait lever le moteur : sans le filet, l'exception était avalée par la file de la partie et le client attendait pour toujours une réponse qui ne venait pas.
Où : la garde des types est dans le cœur, parce que c'est la même responsabilité que D-65 — ce que le moteur ne peut pas garder, le serveur le garde, et un test en mémoire vaut mieux qu'un test réseau. Le filet à exceptions est dans l'adaptateur, parce qu'une exception est un accident de transport, pas une règle du jeu.
Écarté : ajouter une branche par défaut à reduce (le moteur est figé par 75 scénarios verts et un contrat d'hermétisme ; le durcir pour une faute d'appelant déplacerait la responsabilité au mauvais endroit) ; valider chaque intention contre intent.schema.json à l'entrée (une dépendance à un validateur JSON Schema dans le chemin chaud, pour ce que sept chaînes et un try couvrent).
P3 — tranche 3, le client de duel (2026-08-06)
La tranche 3 est livrée : client/ contient le client de duel en Vue 3, hors de site/ (D-58, D-74). Il tient le contrat de remplaçabilité à la lettre — aucune règle du jeu, dialogue par intentions et événements uniquement, rendu de carte isolé derrière un composant unique — et il l'a prouvé de la seule façon qui vaille : deux bots qui ne connaissent aucune règle jouent une partie entière en cliquant ce que l'interface leur propose.
Le critère de fin est un test, écrit d'avance et plus dur que celui de la tranche 2 (client/e2e/duel.spec.ts) : deux navigateurs ouvrent un salon, prennent les deux sièges, passent le mulligan, jouent une partie complète contre le vrai serveur ; le serveur est tué en plein milieu, les deux clients voient la coupure puis reviennent seuls — personne ne recharge la page, personne ne reclique — la partie va jusqu'à son résultat, les deux verdicts concordent, aucun refus du moteur n'est apparu de toute la partie, aucune exception n'a été levée dans le client, et le fichier de journal laissé sur le disque rejoue dans le juge du kit jusqu'au même vainqueur.
Le « zéro refus » est la mesure qui compte : il dit que le reduce() spéculatif du client, sur sa seule vue filtrée (D-66, D-75), donne exactement les mêmes réponses que le serveur autoritaire. Le client n'a pas une seconde implémentation des règles avec quoi diverger — il n'a pas de règles du tout.
D-84 · Three.js est écarté pour le moment ; la maquette n'est pas faite, elle est reportée
Acté (choix de Guillaume, 2026-08-06) : la tranche 3 se fait en Vue pur, sans couche WebGL. La maquette Three.js que D-74 plaçait avant la tranche 3 n'a pas été réalisée ; elle n'est pas annulée, elle est reportée sans échéance.
Raison : D-74 avait déjà écrit le critère d'échec de cette maquette — « si la réponse est deux gabarits à maintenir, la maquette a échoué même belle ». Or ce verdict était acquis avant même de la construire : CardRender.vue ne disparaît pas (le générateur, la galerie et les exports PNG/A4 en dépendent, besoins de P1), donc une scène WebGL ajouterait un second gabarit au lieu d'en remplacer un. Construire la maquette pour se le faire confirmer aurait coûté une tranche entière avant d'avoir un jeu jouable.
Ce que le report ne coûte pas : le contrat D-58 rend la couche de rendu remplaçable. Le client parle au serveur par intentions et événements, et rien d'autre ; y ajouter un canvas plus tard ne touche ni le protocole, ni le magasin de session, ni les affordances.
À rouvrir quand — et pas avant : une fois le jeu réellement joué en guilde, si le plateau paraît inerte au point de nuire au plaisir. Le critère de D-74 reste valable tel quel le jour où la question revient.
Écarté : faire la maquette d'abord par fidélité au plan (le plan servait à éviter un pari technique, pas à l'imposer) ; acter Three.js sans maquette (ce serait exactement le pari que D-74 refusait) ; abandonner définitivement la piste (rien ne le justifie — c'est un ajout possible, pas une impasse démontrée).
D-85 · Le fil (wire.ts) remonte dans @lgc/server : un protocole, une déclaration
Acté : wire.ts — noms de canaux, formes de charge utile, SessionInfo, et les formes publiques du salon — quitte server/app/ pour server/src/, et @lgc/server l'exporte. Le paquet gagne deux sous-chemins, @lgc/server/wire et @lgc/server/types, pour qu'un consommateur puisse prendre le protocole sans embarquer sessionReduce.
Raison : le client a besoin des mêmes quatre noms d'événements et des mêmes formes que l'adaptateur. Tant que le fil vivait dans app/, hors du paquet livré, le client n'avait d'autre choix que de les redéclarer — et deux déclarations d'un même protocole finissent toujours par diverger, silencieusement, au premier champ ajouté. Le fil n'est que du type et de la constante : il passe le lint de frontière D-64 sans l'amender (aucune E/S, aucun minuteur, aucune classe), ce qui est la preuve qu'il n'a rien d'un morceau d'adaptateur.
La frontière ne bouge pas : src/ reste le paquet pur et le lint le vérifie toujours, module par module. Ce qui change, c'est qu'on reconnaît le protocole pour ce qu'il est — une sortie du cœur, au même titre que ServerMessage qui y vivait déjà.
Écarté : exporter server/dist-app/app/wire.js par un sous-chemin (le client dépendrait d'un artefact de l'adaptateur, donc de NestJS, pour lire quatre chaînes) ; recopier le fil dans client/ (deux sources de vérité sur l'actif le plus fragile du projet) ; un troisième paquet @lgc/protocol (une frontière de plus pour soixante lignes, alors que le cœur les produit déjà).
D-86 · @lgc/card-render : le second consommateur existe, D-58.3 s'applique
Acté : le rendu de carte quitte site/.vitepress/theme/ pour un paquet workspace @lgc/card-render, consommé par site/ et par client/. Il emporte avec lui les trois modules qui n'ont jamais été autre chose que le pont data/ → Vue : CardRender.vue (le gabarit), cards.js (les libellés lus dans taxonomy.json/ranks.json/sets.json, D-21, et le corpus d'affichage), art.js (les illustrations de data/art/) et catalog.js (le catalogue injecté dans le moteur, D-56.4).
Raison : D-58.3 avait fixé la condition d'avance — « il sera promu en paquet partagé le jour où un second consommateur existera, pas avant ». Le client de duel est ce second consommateur. Sans la promotion, il y aurait deux CardRender.vue à maintenir : précisément l'échec que D-74 désignait pour la maquette WebGL, obtenu cette fois sans même changer de technologie.
Paquet SOURCE, sans build : Vite compile le .vue et résout les import.meta.glob chez le consommateur. Contrepartie assumée et écrite dans son README : ce paquet exige un bundler Vite. Il est consommé par alias, des deux côtés, plutôt que par résolution node_modules — un .vue atteint par lien symbolique de workspace se fait pré-empaqueter par optimizeDeps et n'est pas transformé par le plugin Vue. Le paquet est déclaré dans les workspaces (D-57) : c'est la frontière qui compte, la résolution passe par l'alias.
Ce que le paquet ne prend pas : rien du duel. site/ reste hors des workspaces npm et garde son propre node_modules — la charte D-35 tient, le site n'est toujours qu'un rendu de docs/ et data/.
Écarté : copier le composant dans client/ en attendant (deux gabarits) ; un alias direct de client/ vers un fichier de site/ (le client dépendrait d'un dossier du site sans frontière déclarée, contre l'esprit de D-57) ; publier un paquet npm construit (un build à republier à chaque carte, sur l'actif durable — le problème que le dépôt unique existe pour éviter).
D-87 · Combien de cibles une intention demande est une question de donnée, tranchée par le moteur
Trouvé en écrivant les affordances, et c'est un piège à retardement.
Acté : @lgc/engine exporte playChoiceSlots, heroPowerChoiceSlots, abilityChoiceSlots et choiceSlotsOf (module prompt.ts, hors du réducteur — même statut que validateDeck en D-69 et projectFor en D-66). Elles rendent le nombre de cibles que le joueur doit désigner, en appliquant la règle de D-30 : les couples (side, kind) distincts des sélecteurs CHOSEN consomment targets[] un à un.
Raison, et elle est contre-intuitive : D-41 dit qu'une capacité sans cible légale reste jouable et que l'effet est simplement perdu. Conséquence directe — reduce accepte un sort ciblé joué sans targets. Un client qui déduirait « cette carte veut une cible » d'un refus du moteur n'en obtiendrait donc jamais : il enverrait tous les sorts ciblés dans le vide, la Ferveur serait payée, la carte irait au cimetière, et rien ne se passerait. Aucune exception, aucun refus, aucune trace dans le journal — un jeu injouable et un bug muet. Un test du kit l'épingle désormais (engine/test/prompt.test.ts, premier cas).
Pourquoi dans le moteur et pas dans le client : la réponse se lit dans le DSL des cartes, et le DSL appartient au moteur. Le client garde son contrat (D-58.1) — il demande le nombre, il ne le décide pas, et la légalité de chaque cible reste un reduce() spéculatif.
Limite écrite : une capacité dont la condition est fausse à la résolution ne consomme pas son choix ; le compte rendu est donc un majorant — le bon nombre à demander, jamais moins. Aucune carte du corpus ne dépasse un choix ; un test le vérifie et cassera le jour où ce ne sera plus vrai.
Écarté : sonder par différence d'états (jouer avec et sans cible, comparer — coûteux et fragile) ; coder la liste des cartes à cible dans le client (une règle du jeu dans le client, D-58.1) ; ajouter un champ « ciblée » aux cartes (une seconde source de vérité à côté du DSL, qui se désynchroniserait au premier ajout d'effet).
D-88 · Le plateau est une scène de dimensions fixes, mise à l'échelle par zoom
Acté : l'écran de duel est une boîte de 1440 × 900 pixels de scène, centrée et mise à l'échelle pour tenir dans la fenêtre (plafond 2,2×, plancher 0,5×). Toute la géométrie du duel — la flèche de désignation, les zones de dépôt, la pose de l'éventail — se pense dans ce repère unique ; game/stage.ts traduit vers l'écran par un seul facteur. Le reste du client (accueil, salon, formulaire de siège, mulligan, fin) reste en pages fluides ordinaires.
Raison : un plateau de jeu n'est pas un document. Il doit être vu en entier, tout le temps, et les distances y portent du sens. En mise en page fluide, la courbe d'une flèche, la tolérance d'une zone de dépôt et le chevauchement des cartes deviennent chacun conditionnels à la largeur disponible — trois accords fragiles à maintenir au lieu d'un repère. Un formulaire, lui, n'a aucun besoin de repère géométrique : d'où la frontière, écran de duel d'un côté, pages de l'autre.
zoom et non transform: scale(), et ce n'est pas un détail d'implémentation : un transform étire un rendu calculé à 1440 px comme on étire une image, donc tout sous-arbre que le navigateur promeut en couche — une transition d'opacité, une carte qu'on déplace — est rastérisé à sa taille puis agrandi. Symptôme observé sur Safari : l'écran devient flou au gré des actions à la souris. zoom agit avant la mise en page ; le texte est calculé à sa taille finale. Les coordonnées ne changent pas de sens pour autant : getBoundingClientRect rend des valeurs déjà zoomées.
Contrepartie assumée, écrite : en portrait étroit, tout rétrécit au lieu de se réorganiser. Une disposition téléphone sera un autre écran, pas une adaptation de celui-ci.
Écarté : une mise en page fluide (il aurait fallu retraduire toutes les positions absolues de la maquette, et rendre conditionnels la flèche et les dépôts) ; un canvas (D-84 vient de l'écarter, et le gabarit de carte est du CSS) ; laisser la scène déborder avec un défilement (un plateau qu'on fait défiler n'est plus un plateau).
Piège consigné : la scène est en overflow: clip, jamais hidden. La main plonge sous le cadre, donc elle déborde ; hidden masque le débord mais laisse une boîte défilable, et le navigateur la fait défiler pour révéler la carte qui reçoit le focus — toute la scène remonte de 60 px, en-tête compris. Invisible au chargement, visible seulement après quelques clics.
D-89 · Le clic et le glisser sont le même état ; le geste ne décide jamais de la légalité
Acté : les trois gestes du joueur (poser une carte, attaquer, lancer le pouvoir héroïque) et leurs deux façons de se faire (clic puis clic, ou glisser-déposer) partagent une seule machine à états, client/src/game/gesture.ts. Appuyer OUVRE la désignation ; relâcher sans avoir bougé la laisse ouverte — c'est exactement le clic-clic d'avant ; relâcher après avoir bougé tranche.
Raison : trois gestes × deux façons font six chemins, donc six occasions de diverger. Écrits séparément, ils auraient fini par ne plus refuser les mêmes choses. Le corollaire est ce qui protège le projet : le click() de Playwright ne déplace pas le pointeur entre l'appui et le relâchement, donc moved reste faux, donc le banc e2e de la tranche 3 — deux bots sans règles qui ne cliquent que ce qu'on leur propose — continue de juger le client sans une ligne de changement.
Aucune règle du jeu dans ce fichier (D-58.1) : legal et slots arrivent tout faits des affordances, c'est-à-dire du moteur. La machine ne décide que d'une chose : quel geste correspond à quelle intention.
Écarté : un chemin de glisser distinct du chemin de clic (six chemins à maintenir, et le banc e2e ne couvrirait plus que la moitié) ; remplacer le clic par le glisser (le bot sans règles ne sait pas glisser, et le tactile comme le clavier y perdraient) ; déduire la cible du geste plutôt que des affordances (une règle du jeu dans le client).
D-90 · Deux dépôts distincts : on POSE sur son terrain, on VISE avec une flèche
Acté : une carte qui ne désigne personne suit le curseur et se dépose sur la ligne de terrain du joueur. Une carte qui vise — sort ciblé, créature à Cri de guerre ciblé — reste levée dans la main et tend une flèche, exactement comme une créature qui attaque ; elle ne se dépose que sur une cible légale. Lâchée ailleurs, elle revient en main.
Raison : deux gestes qui posent la même question au joueur méritent la même réponse à l'écran. Attaquer et lancer un sort ciblé, c'est le même acte — désigner — et la flèche le dit mieux que n'importe quelle carte qui se promène. Symétriquement, lâcher un sort ciblé au milieu du terrain n'est pas un raccourci pour « ouvre-moi la désignation » : le geste a désigné le vide, il est annulé. Qui préfère choisir en deux temps garde le clic.
Écarté : tout faire suivre le curseur (la carte masque le plateau qu'on vise, et rien ne distingue « je pose » de « je désigne ») ; accepter le dépôt sur le terrain pour une carte ciblée en basculant alors en désignation (deux façons d'arriver au même endroit, dont une qu'on déclenche par erreur) ; refuser le glisser aux sorts (le clic-clic marchait, mais le geste naturel est le glisser — c'est le retour d'usage qui a tranché).
D-91 · Quelles cibles un choix accepte est une question de moteur, comme le combien
Le même piège que D-87, un cran plus loin — et trouvé de la même façon : en regardant l'écran.
Acté : @lgc/engine exporte legalChoicesFor (module targeting.ts, le pendant exact de la branche CHOSEN de resolveSelector) et, au-dessus, playChoiceTargets / heroPowerChoiceTargets (module prompt.ts). Elles rendent, pour chaque emplacement de choix, la liste des entités que le sélecteur accepterait. Le client s'en sert au lieu d'essayer les cibles une à une.
Raison : D-41 interdit à reduce de refuser une intention pour une cible illégale — l'effet est perdu, l'intention passe. Conséquence directe : essayer chaque entité par un reduce() spéculatif les fait TOUTES passer. Le plateau allumait donc les permanents et les deux héros pour un sort qui ne parle qu'aux créatures. Ni exception, ni refus, ni trace : exactement la famille de bug muet que D-87 a déjà coûté une fois.
Purement additif : reduce n'a pas changé d'un octet, le kit de conformité non plus. La règle Furtif (D-44) est appliquée par le même code que la résolution, ce qui rend impossible un désaccord entre ce qu'on propose au joueur et ce que la résolution retiendra.
Le client garde son contrat (D-58.1) : il demande, il ne déduit pas. Les attaques, elles, restent des reduce() spéculatifs — le moteur les refuse pour de bon, Provocation comprise.
Écarté : filtrer les cibles dans le client d'après side/kind (une règle du jeu dans le client, et une seconde implémentation du DSL de ciblage) ; faire refuser reduce sur cible illégale (ce serait revenir sur D-41, dont le kit dépend) ; s'en tenir au comportement observé (le joueur voit des cibles que le jeu ignorera — un mensonge d'interface).
D-92 · Trois registres de récit : la bande, les toasts, le journal
Acté : le client raconte à trois échelles de temps. La bande centrale porte les phrases du moteur — désignation en cours, refus, motif de blocage d'une créature — relayées telles quelles, jamais réécrites. Les toasts, en haut à droite du plateau, disent ce qui vient d'arriver et s'effacent en quatre secondes. Le journal, en tiroir, garde tout.
Une ligne par ACTION, pas par événement : le moteur émet une attaque puis ses deux blessures séparément ; annoncées telles quelles, elles donnaient « Vous subissez 4 », sans dire par qui ni sur quoi. Le composant regroupe donc chaque ATTACK_DECLARED avec les DAMAGE_TAKEN qui la suivent, et rattache les dégâts orphelins à la dernière carte jouée.
Raison : un plateau ne dit que l'ÉTAT. Après un échange, deux nombres ont changé et une tuile a disparu — sans son ni animation, il faut deviner. Le journal dit tout mais demande qu'on l'ouvre. Il manquait le présent.
C'est du récit, pas un repli d'état (D-76) : la vue filtrée arrive faite, les événements ne servent qu'à raconter. D'où le droit d'en ignorer, qui serait un bug muet dans un état reconstruit à côté — et d'où le silence au-delà de huit événements d'un coup : c'est une reprise après coupure (D-78), pas du jeu qui se déroule, et repasser la partie en accéléré dans le coin de l'écran n'aiderait personne.
Écarté : tout mettre dans le journal (il faut l'ouvrir, donc quitter le plateau des yeux au moment précis où il se passe quelque chose) ; annoncer chaque événement du moteur (le lot d'une attaque en produit quatre, dont deux illisibles hors contexte) ; des animations sur le plateau (le vrai remède, mais une tranche à lui seul — les toasts sont ce qu'on peut avoir tout de suite).
P4 — les animations du plateau
Quatre décisions prises avant d'écrire une ligne. L'ouverture était déjà dans le dépôt : D-92, en actant les toasts, écartait explicitement « des animations sur le plateau (le vrai remède, mais une tranche à lui seul) », et D-55 le prévoyait dès l'architecture — « le serveur envoie les événements, le client anime ». Rien ne change côté protocole.
D-93 · Les animations sont un récit joué sur les événements ; l'état ne les attend jamais
Acté : la vue filtrée est appliquée à l'instant où elle arrive, sans exception. Un lot d'événements produit à côté une partition — une liste d'effets datés en millisecondes depuis l'arrivée du lot — jouée par-dessus un plateau déjà à jour. Décalage de 120 ms entre deux effets d'un même lot, pour qu'un échange se lise « il attaque → elle encaisse → elle meurt » et non d'un bloc. Aucun effet ne retarde pending, ni un ACK, ni le chronomètre de tour, ni le recalcul des affordances.
Le plateau devient le quatrième registre du récit. D-92 en tenait trois : la bande dit la règle, les toasts disent ce qui vient d'arriver, le journal garde tout. Il manquait celui qui montre. La conséquence la plus concrète n'est pas esthétique : le sort que l'adversaire vient de lancer n'était lisible que dans un toast de 11,5 px en haut à droite, pendant que le joueur regardait sa créature perdre 4 PV au centre. Il se lit désormais sur la carte elle-même, à l'endroit où l'œil est déjà.
Le silence au-delà de huit événements d'un coup (D-92) s'applique tel quel, et pour la même raison : au-delà, ce n'est pas du jeu qui se déroule, c'est une reprise après coupure (D-78) qui rejoue le journal. Un plafond s'y ajoute : si une partition dépassait 1,5 s, le décalage tombe de 120 à 40 ms plutôt que de faire attendre le joueur — un sort qui invoque trois jetons ne doit pas coûter deux secondes de spectacle.
Raison : les événements sont déjà le récit et la vue déjà l'autorité (D-71) ; il n'y a donc rien à ajouter au protocole, seulement à jouer ce qui arrive. Toute la difficulté d'un moteur d'animation de TCG tient dans la tentation de faire attendre l'état — et cette tentation, ici, n'a aucune contrepartie : le joueur qui regarde une créature mourir ne peut de toute façon rien faire d'autre pendant ces 300 ms.
Écarté :
- La chorégraphie complète, à la Hearthstone — le plateau joue le lot du début à la fin et l'affichage retarde sur l'état. Plus beau, et ça coûte un second état : une copie de présentation, distincte de la vue qui fait foi, qu'il faut rattraper à chaque poussée, réconcilier après une reprise (D-78) et arbitrer contre un chronomètre de 75 s que le serveur ne pousse pas (D-81). C'est exactement la classe de bugs que D-71 a supprimée en envoyant la vue à chaque coup ; la réintroduire pour du décor serait un mauvais échange. À rouvrir si le décalage se voit vraiment en jeu — pas avant.
- La décoration pure, chaque effet partant à l'arrivée de son événement — le minimum de machinerie, mais l'échange complet (une attaque, deux blessures, une mort) se déclenche d'un seul bloc, et un bloc ne se lit pas mieux qu'un saut d'état. Les 120 ms de décalage sont tout ce qui sépare les deux, et c'est peu cher.
D-94 · Une couche d'effets au-dessus de la scène : rien de ce qui bouge n'est cliquable
Acté : FxLayer.vue, posée dans la scène en pointer-events: none, porte tout ce qui vole — la carte qui monte du deck, le sort révélé au centre, le fantôme de la créature qui meurt, les chiffres flottants. Les tuiles et les cartes réelles ne se déplacent jamais. Et quand une créature doit s'animer sur place — la ruée d'attaque, le sursaut du coup — c'est .stack qui se transforme à l'intérieur, jamais .tile.
Raison : deux pièges déjà payés dans ce dépôt convergent exactement ici.
- Une animation CSS l'emporte sur une propriété statique, quelle que soit la spécificité. C'est ce qui rendait le halo rouge de cible légale invisible sous la pulsation dorée d'une créature prête. Animer
.tilereproduirait la panne à l'identique, et le symptôme serait tout aussi trompeur. - Playwright exige d'un élément qu'il soit stable — rectangle inchangé sur deux images — avant de cliquer. Une tuile dont la géométrie bouge fait retenter le bot, et
client:e2eest le critère de fin de la tranche.
Le précédent existe déjà dans la main, et il a été payé de la même façon : la plaque de clic ne bouge pas, seule la face se lève par-dessus. On applique au terrain la règle qui a sauvé la main.
Corollaire pour la pioche : l'emplacement réel de la carte dans l'éventail n'apparaît qu'à l'atterrissage du fantôme. C'est ce qui garde le réagencement de la main en un seul saut, comme aujourd'hui, au lieu de 320 ms pendant lesquelles dix plaques de clic glissent sous le pointeur.
Écarté : un <canvas> ou un <svg> d'effets (il faudrait y redessiner les cartes, donc un second gabarit — la raison même pour laquelle Three.js a été écarté en D-84) ; animer les éléments réels en « faisant attention » (c'est ce qu'on croit toujours faire ; les deux pièges ci-dessus ont été découverts en jouant, pas en relisant).
D-95 · Les positions viennent d'un registre d'ancres relevé après chaque rendu
Acté : anchors.ts tient une table ancre → rectangle, en pixels de scène, relevée une fois par rendu — jamais par image, jamais par événement. Elle couvre les créatures et permanents (par entry, via le data-entry qui existe déjà), les deux héros, les quatre piles, l'éventail de la main et le centre du plateau. Elle garde la dernière position connue d'une entité qui a disparu.
Raison : quand une attaque tue, la cible n'est déjà plus dans la vue au moment où le lot arrive — le lot porte l'attaque, les blessures et la mort ensemble. Mesurer la cible au moment de jouer l'effet est donc trop tard par construction : la ruée n'aurait pas de destination et la mort pas de point de départ. Le registre fusionne au lieu de se réinitialiser : ce qui disparaît du DOM garde sa dernière position connue, et ce qui vient d'arriver a déjà la sienne, puisque le relevé suit le rendu. C'est ce double effet — et lui seul — qui donne un départ à la mort et une destination à la ruée.
Le coût est une lecture de mise en page par poussée du serveur. À comparer aux deux par événement de pointeur que DuelBoard s'interdit déjà explicitement : on est trois ordres de grandeur en dessous du seuil qui avait justifié cette précaution.
Écarté : calculer la géométrie à partir des constantes de mise en page, comme restCentre le fait pour la main. Ce n'est pas la même situation, et la différence est décisive : la main est posée par le composant qui la calcule, ses deux versions ne peuvent pas diverger. Le terrain, lui, est posé par du CSS (display: flex, gap: 14px, top: 190px) ; un gap modifié dans la feuille de style ferait tirer les effets à côté, sans qu'aucun test ne s'en aperçoive, et le lien entre la cause et le symptôme serait perdu.
D-96 · prefers-reduced-motion est l'unique interrupteur, et c'est lui que le banc e2e active
Acté : un seul chemin de coupure, la requête média standard. Le banc Playwright pose reducedMotion: "reduce" dans son contexte : le bot joue sur un plateau sans effet, et client:e2e reste exactement le critère de fin qu'il est aujourd'hui.
Réduit ne veut pas dire absent. Ce qui disparaît est le déplacement — le vol, la ruée, la poussée du bandeau. Ce qui reste est le marquage : le fondu, la teinte, le chiffre qui apparaît et s'efface sans courir. Un joueur qui coupe les animations pour raison vestibulaire doit continuer de voir qu'il s'est passé quelque chose, et où ; sinon on lui rend le plateau muet que cette tranche vient précisément réparer.
Écarté : un drapeau maison (?fx=off, un réglage rangé en session). Deux interrupteurs, c'est toujours un que personne ne teste — et le jour où le banc e2e passe par le drapeau maison, plus rien ne garantit que le mode accessible fonctionne encore.
D-97 · Les illustrations se génèrent en ligne de commande, depuis Notion, vers data/art/
Acté : un CLI tools/artwork.mjs lit la direction artistique dans la base Notion, assemble un prompt, demande l'image à OpenRouter et l'écrit dans data/art/. Cinq colonnes nouvelles la portent — Référence (fichiers), Format (sélection), Cadrage, Action / posture, Scène (texte) — auxquelles s'ajoute Idées d'illustration, qui existait déjà et devient la description libre du visuel.
Trois choix structurent l'outil.
Le catalogue de prompts est de la donnée, dans tools/artwork/catalog.mjs. Une colonne accepte le nom d'une entrée du catalogue — le paragraphe part alors dans le prompt — ou n'importe quel texte, qui part mot pour mot. C'est la même économie que les cartes (règle d'architecture 1) : ajouter un cadrage ou une scène est une ligne de donnée, jamais du code. La page HTML « Forge d'artworks » qui a servi de brouillon reste un jouet de tâtonnement ; elle n'est plus la référence, catalog.mjs l'est, et docs/05-charte-illustrations.md y renvoie au lieu de recopier ses blocs.
Trois blocs ne se saisissent pas. L'identité de la carte est lue dans la ligne Notion ; la forme de la fenêtre — arche, lentille, plaque — est déduite du type, parce que c'est elle qui décide de ce qui sera rogné et qu'on ne veut pas dépendre d'une saisie pour ça ; et la règle sociale de la charte §1 est injectée dans tout prompt de créature. Cette dernière est le critère de design de premier rang du projet : elle n'a pas à dépendre d'une case cochée. Corollaire : une créature-membre sans capture voit son personnage WoW inventé, et le CLI le dit carte par carte. Elle n'est pas écartée pour autant — le but immédiat est d'illustrer tout le set en une passe pour le tester à table, et l'avertissement suffit à retrouver les cartes à reprendre avant le gel.
Deux fichiers sortent, un seul est versionné. data/art/<id>.webp est la version que résout art.js, et elle entre dans le dépôt. data/art/originals/<id>.png est l'original, rangé dans un sous-dossier hors du glob Vite (data/art/*.<ext> n'est pas récursif) et exclu de git : 200 Mo sur 120 cartes, et chaque régénération s'empilerait dans l'historique. L'impression à 300 dpi fera l'objet d'un outil dédié, plus tard. Reste versionné le sidecar data/art/originals/<id>.json, qui garde le prompt exact, le modèle et la date : la génération n'étant pas déterministe, il ne rejoue pas le tirage — il rejoue la demande, seule chose qu'on cherche six mois plus tard.
La charte est réduite d'autant. docs/05-charte-illustrations.md ne contient plus ni bloc de style, ni prompt négatif, ni tableau de prompts par carte : tout ça vit dans le catalogue et dans Notion. Il ne lui reste que ce qu'aucun catalogue ne peut porter — la règle sociale, et ce que la fenêtre mange selon le type.
Le CLI n'écrit jamais dans Notion, dans la continuité de la règle de synchronisation du corpus : Notion est l'atelier, le dépôt fait foi, et un script qui écrirait dans la base ouvrirait la porte à la reconstruction complète qui écraserait les saisies à la main.
Le modèle est google/gemini-3.1-flash-lite-image en 1K par défaut, --hd bascule sur google/gemini-3.1-flash-image-preview en 2K ; les deux sont des variables d'environnement. Le ratio part dans image_config.aspect_ratio, jamais seulement dans la phrase du prompt : dire « 3:2 » au modèle ne garantit rien, le paramètre si.
Écarté : une interface web dans site/. Le générateur y aurait eu sa place visuellement, mais il lui faut deux clés secrètes et un droit d'écriture sur data/art/ — donc un serveur, alors que site/ est un site statique qui ne stocke rien en propre (D-35). Un usage ponctuel ne justifie pas d'inventer un back-office.
Écarté : recopier la base Notion dans un JSON du dépôt que le CLI lirait. Ça évitait un jeton, au prix d'un instantané à rafraîchir à la main — c'est-à-dire d'une source de vérité de plus, et de la garantie qu'elle serait périmée le jour où on en aurait besoin.
Écarté : écrire directement en WebP sans garder l'original. Le WebP à 85 suffit à l'écran et pèse cinq fois moins, mais l'export PNG d'une carte à 300 dpi part du visuel : on ne rattrape pas une définition perdue, et on ne régénère pas la même image.
Écarté aussi : une colonne « Référence » en texte, contenant soit une URL soit une description. Les deux usages n'ont pas la même nature — l'un est un fichier, l'autre une intention — et les confondre imposait de deviner à l'exécution laquelle des deux on lit. La capture va dans une propriété Fichiers, où elle se glisse d'un geste ; l'intention reste dans « Idées d'illustration », où elle était déjà.
Écarté : versionner les originaux, ou les passer sur git-lfs. Le .webp est la version qui sert ; l'original est un confort local en attendant l'outil d'impression. Payer 200 Mo de dépôt — et un historique qui grossit à chaque régénération — pour un fichier dont rien ne dépend aujourd'hui, c'est acheter une dette avant d'avoir le besoin.
Horizon assumé : cet outil sert à illustrer vite un set jouable. Il n'a pas vocation à durer — Notion doit un jour céder la place à un outil de création de cartes maison, et l'impression à un générateur 300 dpi. Ce qui survivra à ces deux remplacements est ce qui est déjà dans data/ : les visuels, et le prompt qui les a produits.
D-98 · Le rang Adepte porte les rerolls, et devient la famille tribale basse du set
Le corpus a ses 39 cartes-membres, une par personne, et il reste 21 places de créatures. Trois manques se dessinaient en même temps : la courbe est creuse en bas (5 cartes à 0-1 pour une cible de 12, 9 à 2 pour 22) et aucun membre ne peut y descendre — le rang Membre plancher à 3, Champion à 4 ; l'archétype TRIBAL est à 1 carte pour une cible de 15 ; et le corpus compte un seul Artisan pour 24 DPS.
Décision : le rang Adepte entre dans ranks.json — order: 2, entre Disciple et Membre, fourchette 1 à 3 Ferveur — et accueille les rerolls, c'est-à-dire le personnage secondaire d'un membre déjà établi. C'est un rang qui existe dans la vraie guilde ; la table était incomplète.
Ce que ça résout d'un coup. Un reroll est narrativement moins gradé que son main, donc il tombe naturellement dans la fourchette basse qui manquait. Il porte un rôle souvent différent de celui du main — le DPS qui monte un tank, le personnage qu'on lève pour les métiers — ce qui rééquilibre les rôles sans toucher à une seule carte existante. Et il donne enfin une famille assez nombreuse pour qu'une synergie tribale ait un sens.
Le lien avec le personnage principal passe par memberId, jamais par evolutionOf. Deux cartes peuvent partager un memberId, rien ne l'interdit. evolutionOf aurait semblé fait pour ça, mais D-15 le réserve aux évolutions entre extensions — « Kaelis, Disciple » au set 1 devient « Kaelis, Conseiller » au set 3 — et le linter impose que l'évoluée coûte plus cher que sa base. S'en servir ici passerait le contrôle tout en brûlant, dès le set 1, le mécanisme narratif prévu pour les sets 2 et 3.
La rareté d'un Adepte est plafonnée à peu commune. Sans ce garde-fou, le reroll d'un membre de dix-neuf ans hériterait mécaniquement d'une place épique, puisque c'est l'ancienneté qui pilote la rareté. Le reroll est une carte secondaire par construction : l'ancienneté sert le main, pas l'alt. Ce n'est pas une entorse à §12.2 — la rareté continue d'encoder l'identité, et l'identité d'un reroll est précisément d'être secondaire.
Disciple reste dans la table, et n'aura aucune carte. Un personnage qui vient d'arriver dans la guilde n'a pas encore d'histoire à raconter ; le set de lancement ne l'honorera pas. Le rang ne disparaît pas pour autant : ranks.json décrit la guilde, pas le set. C'est aussi ce qui déplace la synergie basse annoncée par §6.1 — « vos autres Disciples » devient « vos autres Adeptes ».
Effet de bord sur D-60. Le rang Disciple ne portant plus de synergie ni de carte, l'ambiguïté entre le jeton « Disciple » et le rang perd son urgence. La règle tient quand même : tant que le rang existe dans la table, aucun jeton ne doit porter son nom. Le renommage reste à faire, il est simplement moins pressant.
Écarté : faire des rerolls des créatures non-membres. Ç'aurait comblé la courbe aussi bien, mais en perdant exactement ce qui fait l'intérêt du set — le lien avec une personne réelle. Un PNJ générique à 2 Ferveur ne fait sourire personne ; le reroll de quelqu'un qu'on connaît, si.
Écarté aussi : rattacher le reroll au main par une relation mécanique (synergie « si vous contrôlez son personnage principal »). Séduisant sur le papier, injouable en pratique : le deck ne fait que 30 cartes, la probabilité d'avoir les deux moitiés d'une paire est trop faible pour qu'on puisse compter dessus, et la carte serait morte le reste du temps.
D-99 · Le set de lancement est complet à 120 cartes, et trois cartes-témoins de P0 changent d'identité
Le corpus passe de 87 à 120 cartes collectionnables : les 21 places de créatures ouvertes par D-98 sont remplies (17 Adeptes, Baveis, trois Artisans), et les 23 sorts et 10 permanents manquants sont écrits. tools/lint.mjs ne signale plus aucune carte à concevoir. Les quotas de rareté de sets.json sont atteints exactement, type par type, sans arrondi : 32/18/14/6/2 en créatures, 14/10/7/3/2 en sorts, 4/4/2/2 en permanents.
Trois cartes-témoins de P0 sont réaffectées. Elles avaient été écrites pour éprouver le format, pas pour honorer quelqu'un ; les garder telles quelles aurait fait entrer trois placeholders dans un set fini.
crea_kathadevientcrea_fae, id compris. C'est le seul changement d'identifiant jamais consenti sur ce projet, et il est instructif :client/src/game/decks.tsetserver/test/helpers.tsont dû être repointés, et deux journaux de partie sur sept sont devenus injouables — un journal cite descardId, il ne se répare pas. C'est le coût réel d'un id qui change après diffusion, et la raison pour laquelle la règle reste : un id ne change pas. L'exception est accordée une fois, parce que le corpus n'est encore joué par personne.crea_kaelis_discipleest transformée sur place, id conservé. Six scénarios de conformité la citent, dontscn_aura_arrivee_apres_posequi vérifie littéralementatkBase: 1. Sont donc gelés : l'id, le coût 2, les stats 1/2 et la Camaraderie. Tout le reste change — elle s'appelle Bleusaille, elle est PNJ, commune, sans rang nimemberId. La place de légendaire qu'elle occupait passe à Baveis. Au passage, son nom ne cite plus un rang, ce qui va dans le sens de D-60.crea_gorak_forgeronpasse en PNJ. Elle perd rang,memberIdet statut de membre, et garde id, coût, stats, mot-clé, capacité et numéro. Les 17 scénarios qui la citent ne testent que son id : ils restent verts. Elle reste Artisan, ce qui sauve l'axe de synergie — trois Artisans de haut de courbe (6, 6 et 7 Ferveur) viennent le compléter, et le rôle passe de 1 à 4 porteurs.
Baveis prend la seconde place de légendaire créature. Escargot ninja, familier d'un chasseur de la guilde : 3 Ferveur, 1/2, Furtif et Létal. C'est la seule carte du set à porter les deux — elle n'est pas ciblable avant d'avoir frappé, et ce qu'elle frappe meurt quelle que soit sa taille. 1 ATK, parce que c'est un escargot. La rareté encode l'identité, pas la force (§12.2) : la blague est la carte.
Le payoff tribal des Adeptes existe enfin. crea_nahela — « Cri de guerre : vos autres Adeptes gagnent +1/+1 » — cible par filter.hasRank: "adepte", une référence vivante à ranks.json et non une enum en dur (D-12). C'est la première carte du corpus à filtrer par rang, et elle valide que le sélecteur du schéma sait le faire.
Écart assumé sur la courbe. La courbe finale est plus lourde que la cible entre 2 et 4 Ferveur (24/22, 27/24, 25/20) et plus légère au-dessus de 6 (8/11, 4/8, 3/7). L'écart est délibéré : D-11 pose que l'erreur systématique des sets maison est le sommet trop lourd, et que s'il faut couper, on coupe en haut. Un set qui manque de finishers se corrige en extension ; un set qui en a trop ne se joue pas. À rouvrir après le premier test de table, avec des données plutôt qu'un pressentiment.
Quatre cartes nomment encore le jeton « Disciple » : sort_convocation_generale, sort_putsch_rate, et désormais crea_elorion et perm_annonce_epinglee. Le jour où D-60 impose de renommer token_disciple, ces quatre textes changent ensemble — c'est écrit dans leurs notes de conception pour qu'on ne l'oublie pas.
Effet de bord sur D-13, à surveiller. « Kaelis, Disciple » était le témoin vivant de l'indépendance rareté ⊥ rang : un Disciple légendaire. En le réaffectant, le set perd sa seule illustration. Disciple n'a plus aucune carte, Adepte est plafonné à peu commune (D-98), et il ne reste donc plus une seule carte à rang bas et rareté haute. La règle n'est pas abrogée — rien dans le schéma ni dans le linter ne lie les deux axes, et une extension pourra la démontrer à nouveau — mais elle n'est plus prouvée par le corpus. C'est le genre de règle qui se réintroduit silencieusement par habitude ; à rouvrir au set 2, avec une carte faite pour ça.
Écarté : supprimer crea_kaelis_disciple et écrire une carte neuve à sa place. Six scénarios de conformité seraient tombés, et il aurait fallu les réécrire pour tester exactement la même chose. Transformer sur place coûte un nom incohérent avec l'id — crea_kaelis_disciple s'appelle « Bleusaille » — et c'est le moindre mal : l'id est une clé technique, pas un titre, exactement comme crea_mae porte « Maënor ».
Écarté aussi : compléter le set avec davantage de cartes non-membres génériques plutôt qu'avec les rerolls. Plus rapide à écrire, et strictement sans intérêt : ce set existe pour que quelqu'un reconnaisse son pseudo sur une carte.
D-100 · Les captures de personnages arrivent par convention de nom, appariées sur le nom du personnage
Acté : tools/refs.mjs télécharge le rendu Battle.net main-raw de chaque personnage depuis un export de la table character du site de guilde, le rogne, et le dépose en data/art/refs/<id>.png. artwork.mjs regarde ce dossier en premier pour sa référence. Aucune colonne à remplir dans le cas normal : déposer le fichier suffit, exactement comme pour les visuels eux-mêmes.
C'est ce qui remplace la pièce jointe Notion. Une propriété Fichiers ne survivait pas au passage au classeur, et un lien Google Drive « partagé » renvoie une page HTML, pas l'image. La convention de nom n'a ni hébergement, ni authentification, ni colonne à tenir à jour, et elle marche hors ligne.
L'appariement se fait sur le nom du personnage, pas sur memberId. C'est le point qui compte, et il n'était pas évident : memberId désigne la personne — un compte, plusieurs personnages — tandis qu'une carte représente un personnage, celui dont elle porte le nom. crea_gigui porte memberId: u_demono : apparier par memberId lui donnerait le rendu de Demono. Sur les 54 cartes-membres du corpus, l'appariement par nom en résout 51 ; par memberId, il en manquerait bien davantage. Les trois restantes (crea_dakota, crea_demono, crea_pwyl) portent des pseudos sans personnage homonyme et se règlent à la main dans tools/artwork/refs-manuel.json.
Les homonymes se départagent par membre de la guilde, puis personnage principal, puis niveau d'objet — dans cet ordre. Le isMain passe avant l'ilvl à dessein : cinq cartes ont plusieurs personnages du même nom à l'accent près, et un reroll mieux équipé n'est pas le personnage que la carte représente. Le cas crea_naveis le montre : le candidat écarté affiche un ilvl de 674 contre 275 au bon, valeur manifestement fausse que le tri par équipement aurait suivi aveuglément. Chaque arbitrage est affiché avec ses candidats écartés — l'outil décide, il ne cache pas qu'il a décidé.
Le rognage est calculé sur le canal alpha, pas deviné. Le rendu fait 1600 × 1200 dont le sujet n'occupe qu'environ 460 × 600 : envoyé tel quel au modèle, c'est 85 % de vide payé en jetons et une composition faussée. On calcule la boîte englobante des pixels dont l'alpha dépasse un seuil, et on rogne dessus. La transparence est conservée : il n'y a donc littéralement aucun décor à ignorer, ce que la consigne du prompt demandait jusqu'ici par des mots.
data/art/refs/ n'est pas versionné, comme data/art/originals/ (D-97) : ce sont des rendus de Blizzard, ils se retéléchargent en une commande, et ils changent quand la personne change de transmogrification. tools/character.sql non plus — c'est un export de circonstance, pas une source.
Écarté : héberger les captures et remplir une colonne Référence avec l'URL. Ça déplace le problème vers un hébergement à maintenir, et la colonne serait périmée à la première refonte d'équipement. La colonne survit tout de même, en tant qu'exception : un chemin relatif au dépôt quand le nom ne suit pas la convention.
Écarté : apparier par memberId en acceptant les trous. Le raccourci paraissait plus « propre » puisque memberId est la clé documentée du corpus — mais il répond à une autre question que celle posée. La clé d'une carte-membre vers un rendu, c'est le personnage ; memberId sert à savoir de qui on parle, pas à savoir quoi dessiner.
D-101 · La direction artistique est pré-remplie, en texte libre et sans validation
Acté : les quatre colonnes Format / Cadrage / Action / Scène du classeur n'ont plus aucune validation Sheets, et les 126 lignes sont servies — Format 3:2 · Carte fenêtre partout, Illustrateur IA partout, Référence vide (la capture arrive par convention de nom, D-100). Idées d'illustration n'est remplie que pour les 72 cartes non-membres : une phrase dense qui part mot pour mot dans le bloc SUJET du prompt, et qui fixe l'apparence des 18 PNJ (race, tenue, détail signature) pour qu'elle soit reproductible d'un tirage à l'autre.
La validation est retirée parce qu'elle coûtait plus qu'elle ne rapportait. Google Sheets marque d'un triangle rouge toute valeur hors liste, même en mode avertissement — or le texte libre est un usage de premier rang, pas une erreur. Le catalogue reste listé dans l'onglet Référentiel, et c'est le générateur qui tranche à l'exécution : entrée du catalogue → son paragraphe, n'importe quoi d'autre → mot pour mot, avec un avertissement qui n'empêche rien.
Les scènes des membres sont corrélées à la classe et volontairement variées — cinq cartes au plus par scène (seize elfes de la nuit n'envoient que trois cartes en Forêt nocturne). Jamais fondées sur la capacité, qui bougera encore ; la classe, elle, ne bouge pas.
Piège consigné, devenu une entrée de catalogue : une cellule Scène vide ne donne pas un fond neutre — SCENE_DEFAUT = 'conseil' l'envoie en Salle du Conseil. D'où l'entrée neutre / « Fond neutre » (fond anthracite dégradé, sans décor), écrite explicitement sur les 13 cartes qui la veulent (totems, sorts abstraits, permanents).
Écarté : garder la validation « en avertissement ». C'est le mode qui affichait déjà les triangles ; il n'existe pas de mode plus doux côté Sheets, donc la seule position tenable est aucune règle dans le classeur, toutes dans le générateur.
D-102 · Notion est débranché : l'atelier se lit par un export CSV
Acté : tools/artwork/notion.mjs est supprimé, remplacé par tools/artwork/atelier.mjs — un analyseur CSV RFC 4180 maison d'une trentaine de lignes, zéro dépendance, en-têtes appariés par pliage (casse, accents, apostrophes ignorés, pas les mots). La source est l'export de l'onglet Cartes (Fichier → Télécharger → .csv), déposé en tools/atelier.csv (gitignoré), ou passé par --csv. Disparaissent avec Notion : NOTION_TOKEN, LGC_NOTION_DATA_SOURCE, le téléchargement de référence par pièce jointe, et le champ notion: du sidecar, remplacé par atelier: "atelier.csv:<ligne>".
Un export est une photographie. À refaire après chaque grosse saisie, sinon on génère l'atelier d'hier sans s'en apercevoir — c'est le prix, assumé, de l'absence de connexion directe. En échange : aucune clé, aucun quota, ça marche hors ligne, et la ligne exacte qui a produit chaque image est consignée dans le sidecar.
Le parseur est maison parce que le format l'exige à peine : sept cas limites couverts (virgule citée, "", saut de ligne dans une cellule, CRLF, BOM, cellule vide finale, ligne blanche), vérifiés contre le module csv de Python sur 126 lignes × 19 champs, zéro écart. Deux cellules du classeur portent un vrai saut de ligne — c'est ce qui interdit le split(',') naïf et justifie les trente lignes. Un message d'erreur dédié couvre l'erreur la plus probable : exporter le mauvais onglet (« la colonne id manque — est-ce bien l'onglet Cartes ? »).
Écarté : lire l'URL publiée du Sheet. Le classeur change d'identifiant à chaque réimport (Fichier → Importer → Remplacer crée une nouvelle feuille) : l'URL serait à remettre à jour aussi souvent que l'export, sans en avoir la simplicité.
Écarté aussi : --csv obligatoire sans défaut. Le chemin tools/atelier.csv est une convention qui s'apprend une fois ; l'option reste pour les cas particuliers.
D-103 · L'emblème de guilde entre par la colonne Référence — un nom, jamais un chemin
Acté : le blason de la guilde (phénix d'or cerclé) doit apparaître sur les cartes où l'héraldique a sa place. Le fichier vit dans data/art/emblemes/ — dossier versionné, contrairement à refs/ : un rendu Blizzard se retélécharge, le blason non. La colonne Référence, orpheline depuis D-102, est réhabilitée : elle porte le nom de l'emblème (« logo »), plusieurs se séparent par une virgule, et un nom introuvable fait échouer la carte en listant les emblèmes disponibles — seul endroit de la direction artistique où l'on refuse au lieu d'avertir, parce qu'une héraldique silencieusement absente ne se remarque qu'après le tirage.
L'ordre des images jointes est annoncé au modèle : la capture du personnage d'abord, les emblèmes ensuite, et l'ouverture du prompt le dit. Sans cette phrase, le modèle traite l'emblème comme une seconde référence de sujet et fabrique un personnage-oiseau.
Le piège du disque rouge, payé au premier tirage : la référence présente le blason sur un disque rouge sombre, et demander « même palette » a fait recopier le fond de présentation comme s'il faisait partie du dessin — invisible sur les bannières rouges, pastille autocollante sur un tabard. Le bloc réécrit sépare la fidélité (le dessin : même oiseau, même découpe) de la matière, imposée par le support — brodé, frappé, dans la cire, sur le cuir — et fait passer les couleurs de la guilde par le support, jamais par un aplat derrière la figure. Ne jamais y réintroduire « même palette » ni « fond rouge sombre ».
Seize cartes le portent (sujet : 5 · vêtement ou objet : 6 · décor : 5), assez pour l'identité, pas assez pour le papier peint. Écartée notamment : sort_putsch_rate, dont l'idée montre un sceau brisé — briser le blason de la maison est exactement la vanne que §12.5 interdit.
Écarté : une 43ᵉ colonne dédiée. insert_cols d'openpyxl déplace les cellules et rien d'autre — formules, listes déroulantes et mises en forme conditionnelles seraient à refaire à la main pour un champ qui sert seize fois.
Écarté aussi : un chemin de fichier dans la colonne. Un chemin dans un tableur pourrit au premier déplacement de dossier et personne ne s'en aperçoit avant la génération ; un nom se résout et se vérifie.
D-104 · La colonne « Classe » entre au classeur, et n'est câblée nulle part
Acté : une colonne Classe en position J, juste après Rôle / Type — le classeur passe à 42 colonnes. En-tête bleu (domaine de Guillaume : qui est sur la carte), même si le pré-remplissage est automatique. Les libellés sont ceux de playableClass.label de la base du site, tels quels — « Chasseur de démon » au singulier, « Evocateur » sans accent — pour que la jointure reste triviale ; ne pas les « corriger » lors d'une régénération.
L'appariement carte ↔ personnage se fait par le nom (comme D-100), avec deux leçons de plus : unicodedata.NFD ne décompose pas ø, æ, œ, ß, ł — il faut translittérer avant — et les homonymes se départagent par contrainte de groupe (les cartes d'un même memberId appartiennent au même compte) puis par rôle croisé aux spécialisations jouées. Un arbitrage reste manuel et le reste : crea_kwaftt est Chaman contre l'heuristique, ne pas le recalculer.
Rien n'est câblé dans la forge, et c'est la décision. Le seul gain identifié — un décor par défaut selon la classe — est mort le jour même : les 126 cellules Scène sont servies (D-101). La colonne attend l'outil de création de cartes maison, et elle a déjà payé sa place autrement : la refonte D-105 se lit dedans.
Écarté : câbler la classe dans catalog.mjs « tant qu'on y est ». Le principe de la forge est inverse — ce que le jeu sait, on va le chercher ; ce qui se saisit est ce que le jeu ignore — et un câblage sans consommateur est exactement le code qu'on retrouve mort deux mois plus tard.
D-105 · La classe donne la saveur, en primitives neutres ; l'axe Adepte s'étoffe
Acté (arbitré) : les 54 cartes-membres sont relues à la lumière de la colonne Classe (D-104), avec une latitude bornée — capacité, mots-clés et ATK/PV bougent ; coût, rang et rareté ne bougent pas (la courbe validée en D-99 reste vraie). Et la couleur de classe s'exprime uniquement en primitives neutres : on choisit, parmi les ~15 actions du moteur, celle qui évoque la classe — les mécaniques à forte personnalité restent au backlog de l'extension (D-08/D-17).
Six contresens corrigés, budget bloquant revérifié à chaque fois : Frigide (chevalier de la mort Furtif → Létal 3/3, le métier et la classe d'accord entre eux) · Snootsh (Fougue → drain à l'Assaut) · Yuna (démoniste Furtive → le pacte : 2 dégâts à son propre héros contre une carte) · Fal (guerrier Furtif → la rage en Riposte) · Maxaff (le +0/+2 offert → frappe de sang : se soigne en Riposte) · Bambounou (le soin au héros → la BL : +1 ATK collectif jusqu'à la fin du tour, renfort allié légal au sens de D-32 ; la soupape anti-agro y perd sa plus grosse pièce, à surveiller au premier test de table). Les cas défendables restent : le Fougue d'un chaman est un loup fantôme, le Furtif d'un druide un félin, le Létal d'un démoniste l'affliction — et les vanilla restent vanilla.
Second volet : l'axe Adepte passe de une à trois cartes. Nahela (D-99) reçoit deux compagnes — Banjo, dont le soin devient un soin de famille (chaque autre Adepte allié), et Tenalore, le mentor qui va chercher un Adepte dans le deck (TUTOR filtré par hasRank, la référence vivante de D-12 ; le moteur le couvrait déjà). L'archétype TRIBAL passe de 3 à 5 cartes, et quatre étiquettes d'archétype sont recomptées pour suivre la réalité.
Écarté : les synergies de classe (« vos autres Chamans gagnent… »). Les plus savoureuses sur le papier, mais la classe n'existe ni dans le schéma, ni dans le moteur, ni dans data/cards/ — c'est une colonne d'atelier (D-104). L'introduire au modèle de données est le chantier de l'extension de classes, pas un effet de bord d'une refonte de saveur.
Écarté aussi : rouvrir les coûts pour donner plus d'air à la refonte. Chaque carte refondue tient dans son budget en jouant sur ATK/PV ; toucher aux coûts aurait rouvert la courbe entière pour un confort local.
D-106 · Le Furieux passe du forum au Discord — second renommage d'id consenti
Acté : crea_furieux_du_forum devient crea_furieux_du_discord — nom (« Furieux du Discord »), ambiance (« @everyone, à 3 h 12… ») et direction artistique transposés du mur d'affichage au cristal de communication à lueur bleutée. Le renommage de l'id est consenti explicitement, D-59 le permettant tant que le set est en brouillon : la guilde vit sur Discord, pas sur un forum, et l'id est un mot qu'on lit — dans l'atelier, les logs, les fichiers d'art.
Le coût, identique à celui de Katha → Fae (D-99), est payé en connaissance de cause : cinq fichiers de code repointés, les fichiers d'art et le sidecar renommés, et six journaux de partie sur sept morts — un journal cite des cardId, il ne se répare pas. Aucun scénario de conformité ne citait la carte : le kit est indemne, et c'est la différence avec les trois cartes verrouillées du socle, qui, elles, ne bougeront jamais.
La règle demeure : un id ne change pas. L'exception se justifie précisément parce que son prix est encore bas — des journaux de développement — et qu'il montera à chaque partie jouée. C'était la dernière fenêtre où ce renommage était bon marché ; après le premier duel de guilde, il aurait été refusé.
Écarté : garder l'id historique sous le nouveau nom, comme crea_mae porte « Maënor ». C'est la règle ordinaire et elle aurait suffi — mais l'écart id/nom de Maënor est un accident d'orthographe, celui-ci aurait été un mensonge de plateforme, recopié dans chaque nouvel outil. Tant que corriger ne coûte que des artefacts de dev, on corrige.
D-107 · Le son dérive de la partition visuelle, et le mixage vit dans le client
Acté : client/src/game/sound.ts transforme la liste d'effets datés que rend déjà partition() (D-93) en une liste de cues — un identifiant, une date en millisecondes depuis l'arrivée du lot, un gain local. audio.ts les joue par Web Audio, et c'est le seul module du client qui fasse du bruit. Le branchement tient en une ligne dans playFx, l'entonnoir unique par lequel passent déjà la partition et la distribution d'ouverture.
Raison : la propriété qu'on cherche est que le choc s'entende à l'instant du contact, pas à l'arrivée du message réseau — exactement ce que FX.contact (130 ms) obtient déjà pour le chiffre qui monte et le sursaut de la gemme. Recalculer cette chronologie à partir des événements aurait produit deux calendriers à tenir synchrones, et le second aurait dérivé au premier réglage. Un récit qui se décale n'échoue pas, il ment (D-92) : la règle valait pour la phrase et l'image, elle vaut pour le son.
Trois propriétés tombent alors gratuitement, et c'est ce qui rend la décision bon marché :
- Le silence des reprises, sans une ligne de code :
partition()rend déjà une liste vide au-delà deFX.catchUpévénements (D-78). Repasser la partie en accéléré au casque serait pire qu'à l'écran. - Le resserrement : quand
layout()rejoue au pas court parce que la partition dépassaitFX.budget, les sons se resserrent avec elle. - La destination donne le timbre : la partition sait déjà qu'une carte va au terrain, à la rangée des permanents ou au cimetière. Un permanent n'émet aucun
SUMMONEDet se serait fait prendre pour un sort si le son avait relu les événements — le piège exact déjà payé à l'image.
Le mixage vit dans sound.ts, pas dans les fichiers. La table SFX porte, par son, un gain et une priorité. Un fichier trouvé sur internet n'a aucune raison d'arriver au bon niveau, et le renormaliser à la main serait un travail à refaire à chaque remplacement. Le banc /fx joue chaque cue, expose un curseur par identifiant et recopie la table à recoller — même méthode que pour les durées : un mixage se règle à l'oreille, en jouant.
Pourquoi pas dans data/ avec les horloges de D-68 : celles-là sont des règles de partie, que le serveur applique et qu'un autre prototype devrait respecter. Un gain ne sort jamais du navigateur, et D-20 interdit déjà de faire dépendre la donnée d'un choix de rendu.
Trois règles de foule, parce que ce qui se compte à l'œil ne se compte pas à l'oreille :
- Un choc par instant, le plus prioritaire gagne (
damage-hero>attack-melee>damage-creature). Un échange sonne donc comme un choc, pas comme un choc plus deux coups — et un coup au visage se distingue de la lame. - Un choc par endroit, quel que soit le nombre de chiffres.
stackFloatsles sépare volontairement de 110 ms pour qu'on les compte à l'œil ; trois sons à 110 ms ne s'entendent pas comme trois coups mais comme un grésillement. Le premier sonne, renforcé de 15 %, exactement commestrikesne retient que le premier sursaut. - Une fenêtre de dédoublonnage qui AVANCE : trois pioches d'affilée (0, 80, 160 ms) ne font qu'un son. Mesurer contre le dernier son gardé aurait laissé passer la troisième, hors fenêtre du premier.
Le camp change le gain, jamais le son — ce qui vient d'en face est joué à 0,8. Pas de panoramique : la scène est vue de face, un son venu de la gauche mentirait sur la géométrie. Une seule exception, le sort adverse révélé au centre, qui garde son plein niveau : il ne se voit nulle part ailleurs, et on n'en lit sinon que les conséquences.
Écarté : dériver le son des événements du moteur, en parallèle de l'image (deux calendriers, une dérive garantie) · mettre les gains dans taxonomy.json (D-20) · un panoramique par camp · un son par carte ou par classe (120 × 13, et le type suffit — il est déjà porté par l'effet) · des annonces parlées (une voix vieillit mal et il en faudrait une par langue).
D-108 · Un son absent est synthétisé, jamais silencieux — et le son a son propre interrupteur
Acté : chaque identifiant de SoundId est un nom de fichier, client/public/audio/sfx/<id>.mp3. Tant que le fichier n'existe pas, game/tones.ts en fabrique un tenant-lieu synthétisé par oscillateurs et bruit filtré. Déposer le mp3 le remplace, sans toucher au code — même convention que data/art/<id>.<ext> pour les visuels (D-97/D-100), et pour la même raison : une liste à tenir à jour finit périmée. tools/audio.mjs normalise ce qu'on dépose dans data/audio/raw/ (non versionné) vers client/public/audio/ (versionné : un clone du dépôt doit sonner).
Raison : le jeu doit sonner avant que les vrais fichiers existent, sinon rien n'est vérifiable — ni le branchement, ni la chronologie, ni le mélange. Les vingt-cinq timbres n'ont qu'un cahier des charges, être distinguables : c'est ce qui permet d'entendre que le bon cue part au bon instant, ce qu'un unique bip répété ne prouverait pas.
Écarté : des fichiers mp3 de remplacement commités. Un fichier vide ferait échouer decodeAudioData ; un fichier de bip se remplace un par un et l'oubli ne se signale pas — un bip qui reste ne ressemble pas à une panne, alors qu'un timbre synthétisé disparaît de lui-même dès que le fichier arrive, et que le banc /fx affiche en clair ce qui manque encore.
La musique n'a pas de tenant-lieu, délibérément : une ambiance synthétisée en boucle serait plus agressive qu'un silence, et on l'écouterait vingt minutes d'affilée. Pas de musique tant qu'il n'y a pas de fichier — ce n'est pas une panne.
Le son a son propre interrupteur, et ce n'est PAS prefers-reduced-motion. D-96 en fait l'unique interrupteur du mouvement ; le réglage du son est un second axe, avec deux volumes séparés et deux muets, persistés dans localStorage. Ce n'est pas une incohérence à corriger : le mal des transports et le besoin de silence ne sont pas la même gêne, et quelqu'un qui coupe les animations peut vouloir garder le son — l'inverse aussi. La courbe des curseurs est perceptuelle ((v/100)²) : linéaire, tout le réglage semble se jouer dans les vingt derniers pour cent, et on l'attribue au son alors que c'est l'oreille.
Le réglage tient en une largeur d'icône dans la barre du haut, qui est déjà pleine à 1440 px sur une scène fixe : deux curseurs à demeure la feraient déborder. Il ouvre un panneau, et M coupe ou rétablit tout.
Écarté : faire de prefers-reduced-motion l'interrupteur du son aussi (deux gênes différentes) · une fenêtre « activer le son » au démarrage (le premier clic du joueur est de toute façon « Ouvrir un salon », et c'est lui qui réveille l'audio) · des curseurs visibles en permanence dans la barre.
D-109 · Les sons abstraits sont FORGÉS, pas trouvés — et le foley reste à enregistrer
Acté : dix-neuf des vingt-cinq effets sont synthétisés par tools/audio/synth.py (numpy/scipy), déposés en WAV dans data/audio/raw/, puis passés à tools/audio.mjs comme n'importe quelle source. Le script est au dépôt : ces mp3 sont des artefacts, leur source est versionnée — même statut que les illustrations et leur forge (D-97).
Raison : un mp3 trouvé quelque part ne se retouche pas. Trop fort, trop long, trop clair : on le remplace ou on le subit. Ici chaque son est une fonction de vingt lignes — on change un chiffre, on relance, c'est refait. Le premier jet l'a prouvé deux fois, et sur des fautes qu'aucune oreille n'aurait su nommer : card-play-creature était centré à 137 Hz — un « boum » qui avalait la carte — et attack-melee ouvrait sur 75 ms de sifflement de lame, donc l'impact tombait 75 ms après le contact, ruinant en silence toute la synchronisation de fx.ts. Les deux se sont vues sur la forme d'onde et le spectre, et se sont corrigées en trois lignes.
Deuxième raison, qui ne se voit qu'au déploiement : rien n'est emprunté, donc rien n'est à créditer. Pas de page d'attribution à tenir, pas de licence à vérifier avant de montrer le jeu à la guilde.
Les six sons de MATIÈRE ne sont pas là, et c'est la moitié de la décision : card-draw, card-deal, card-pick, card-play-creature, card-play-permanent, spell-reveal. Une carte qui glisse, un paquet qu'on bat, un permanent qu'on pose — c'est du foley, ça s'enregistre. La synthèse en donne une imitation crédible, jamais convaincante, et prétendre le contraire aurait figé un à-peu-près là où un enregistrement de dix secondes fait mieux. Ils restent joués par les timbres du navigateur (D-108) : le jeu sonne, et le manque est écrit noir sur blanc au banc /fx comme sur la page « Sons ».
Les effets sont normalisés en CRÊTE, pas en loudness. loudnorm vise une loudness intégrée, et pour y parvenir sur un son court il applique un gain qui varie dans le temps : il rabote l'attaque, c'est-à-dire exactement ce qui fait qu'un impact est un impact. Mesuré sur un premier jeu : de −0,9 à −3,2 dB selon le son, le plus grave étant le plus écrasé — soit l'inverse de l'intention, damage-hero devant rester le plus gros son du jeu. Une crête commune à −1 dBFS est le point de départ neutre, et c'est le bon : le mixage vit dans la table SFX (D-107) et se règle à l'oreille au banc. Normaliser en loudness, c'était mixer deux fois, dont une en aveugle. loudnorm reste en vigueur pour la musique, qui est son terrain : un flux long et continu.
Conséquence sur le site : une page /sons affiche les vingt-cinq sons, leur libellé, leur gain, et les joue — au gain de la table, donc tels qu'ils sonneront en partie — plus une séquence aux vraies durées de FX. Elle ne stocke rien (D-35) : les mp3 sont ceux du client, atteints par import.meta.glob comme la galerie atteint data/art/ ; les libellés viennent du champ when de la table SFX, qui fait foi. C'est aussi ce qui rend le manque visible sans qu'on ait à le tenir à jour.
Écarté : télécharger des sons depuis une banque. Ce n'est pas un refus de principe — c'est que la chaîne d'outils disponible ne rapporte pas de binaire, et surtout qu'un fichier trouvé arrive sans source. Les six sons de matière font exception parce que là, l'enregistrement bat la synthèse.
Écarté : brancher synth.py sur npm run check. C'est un outil de FABRICATION, pas de validation, et data/ ne doit pas dépendre d'une pile Python. Il se lance à la main, quand on veut retoucher un son.
D-110 · Le set de lancement passe de 120 à 127 cartes
Acté : set_01 est rouvert. size 120 → 127, sorts 36 → 39, permanents 12 → 16, créatures inchangées à 72. Les quotas de rareté sont réattribués à la carte près (sorts : communes 14 → 16, peu communes 10 → 11 · permanents : peu communes 4 → 6, rares 2 → 4) et targetCurve est remontée à 127. legendaryLimit ne bouge pas : aucune des sept nouvelles n'est légendaire.
Raison : sept running gags de la guilde n'avaient pas de carte, et ils sont meilleurs que ce qu'un quota rond protégeait. Le site de guilde qu'on redécouvre à chaque saison (« On a un site internet ? », déjà l'ambiance de Misk — les deux cartes se lisent en duo), la +15 du Trône des Marées à 89 morts, la clé de trop et sa chanson, les deux rosters, le Kallax de Naveis, la bougie que tout le monde veut cliquer. Le plafond de 120 était une contrainte de CONCEPTION, pas une contrainte du moteur : rien dans engine/, server/ ni client/ n'en dépend, et tools/lint.mjs n'en tire qu'un seul contrôle bloquant — le numéro de collection doit rester ≤ set.size. D-59 tient : tant que le set est en brouillon, on ajoute, on renumérote, on ne crée pas de v2.
Ce que ça change ailleurs, et qu'il ne faut pas oublier : les 65 plages de l'onglet Suivi du classeur pointaient sur Cartes!$X$2:$X$127 et couvrent désormais $134 (133 lignes = 127 cartes + 6 jetons). Une carte ajoutée sans étendre ces plages ne serait comptée nulle part, en silence.
Les sept, et pourquoi elles ne doublonnent pas — chacune a été écrite contre une carte existante :
- Le Site de la guilde (permanent 4 F, rare, durabilité 2) — tuteur de membre, calé sur « Conseil du butin » (2,8 points le tuteur) ;
filter.isMembervaut « la carte a un rang », donc jamais un PNJ. - La +15 maudite (permanent 4 F, rare, durabilité 4) — 2 dégâts à une créature aléatoire de chaque camp. Mono-cible des deux côtés (
RANDOM,count 1) : D-32 tient, et c'est une des soupapes anti-agro que D-32 laissait explicitement à trouver, sans ouvrir le dégât de zone neutre. - Le Raid reroll (permanent 3 F, peu commune) et Le Raid principal (permanent 4 F, peu commune) — cycle « Les deux rosters », deux auras
+1/+1bornées par rang.hasRankne prend qu'une valeur : « Champions et Conseillers » s'écrit en DEUX effets, c'est la forme propre. Le Haut Conseiller est exclu — Guilvor est légendaire à 9 F. Le Raid principal est à 129 % de l'étalon, assumé : il ne fait rien avant le tour 4 là où « Les logs du raid » travaille dès le tour 1. - La clé de trop (sort 1 F, peu commune) —
GAIN_FERVOR 2en modeCURRENTcontre 2 dégâts à son propre héros. Seule accélération accessible tôt (« Nuit blanche » est à 8 F), et le barème vient d'elle. Le plafond n'est pas touché (D-04). - Un p'tit Kallax ? (sort 3 F, commune) — second
SET_STATSdu set, petit frère de « Transmogrification » : ça remplace les stats, donc sur un gros corps c'est une punition. - La Bougie (sort 2 F, commune) — Fougue + pioche. Ne périme pas « Pull timer » (1 F, Fougue seule) : elle coûte 1 de plus pour le cantrip. Se lit en trio avec Naveis et son illustration.
Écarté : couper sept cartes existantes pour rester à 120 (il aurait fallu choisir des victimes et casser des numéros de collection) · ouvrir un set_02 pour les accueillir (elles n'auraient pas été dans les decks du premier test de table, qui est précisément ce qu'on prépare) · monter Le Raid principal à 5 Ferveur pour un ratio propre (il devenait strictement moins bon que « Les logs du raid », donc injouable).
Le prix, à surveiller au premier test de table : la courbe s'alourdit encore par le bas (14/25/29/28 pour 13/23/26/23 visés entre 0 et 4 Ferveur). D-11 dit que l'erreur à craindre est le SOMMET trop lourd, pas la base — on laisse, et on rouvre avec des données.
D-111 · Un deck fourni par plan de jeu, et chaque carte servie AU MOINS une fois
Acté : le test de couverture de client/test/decks.test.ts cesse d'exiger que chaque carte du set ait UN SEUL foyer. Ce qui reste bloquant, c'est qu'aucune carte ne dorme : les 127 collectionnables sont dans au moins un des six préréglages. Le partage entre decks devient légal, et n'est plus surveillé que par un garde-fou souple (moins d'un quart du set partagé — au-delà, c'est que les six plans de jeu ont fusionné).
Raison : la règle du foyer unique, posée à la refonte du 2026-08-10, n'était pas une règle de jeu — c'était une astuce de couverture. Elle a produit deux fourre-tout : « La Pouponnière » a hérité des 17 Adeptes parce qu'il fallait bien les caser, « Pex sauvage » de toutes les vanilles pour la même raison, et aucune Provocation ne pouvait renforcer à la fois le mur de « Point divers » et la pouponnière. Le but est d'avoir six plans de jeu distincts ET que rien ne dorme ; seul le second a besoin d'être garanti par un test.
Ce que la passe a corrigé, en plus des sept cartes de D-110 : crea_sentoriu était devenue 6/3 Fougue (DPS, archétype AGGRO) alors qu'elle tenait le mur de « Point divers » en 3/6 Bouclier — elle passe à « Motion d'urgence ». crea_calyonia a perdu Provocation pour un soin de zone : elle reste en contrôle, rien à faire. Le renommage de perm_tableau_de_service en « Le Calendrier » n'a pas touché son id, donc pas les listes.
Où sont allées les sept — chacune dans le deck dont elle sert le plan, jamais pour faire nombre :
- Le Raid reroll ×2 → La Pouponnière :
filter.hasRank: "adepte", c'est la deuxième source de+1/+1tribal après Nahela, et la première qui dure. - Un p'tit Kallax ? ×2 → La Pouponnière : un Adepte 1/1 devient un mur 4/4, que Gorak et La Forge de guilde renforcent ensuite. C'est le seul gros tour du deck.
- Le Raid principal ×2 → Soirée progress : huit corps touchés sur treize (cinq Champions, deux Conseillers).
- La Bougie ×2 → Soirée progress : Fougue + pioche sur des corps de 5/4, et le trio d'ambiance avec Naveis.
- Le Site de la guilde ×2 → Point divers : huit lignées à rang dans le deck, et le tuteur va chercher Guilvor à 9 Ferveur.
- La +15 maudite ×2 → Commission de discipline : le deck ne tient que neuf créatures, donc le camp allié du
RANDOMest souvent vide — le coût symétrique n'en est pas un ici. - La clé de trop ×2 → Motion d'urgence : la seule accélération sous 8 Ferveur, dans le deck qui se fait déjà mal avec Yuna.
Deux cartes changent aussi de deck sans y être forcées : « Hall of Fame » et « La Salle des trophées » quittent Soirée progress pour Pex sauvage. Elles recopient la créature ayant le plus d'ATK — un 7/3 Eléogeon ou un 6/5 Akiba, pas un 5/4. Au passage, Soirée progress redescend de dix cartes à 5 Ferveur et plus à sept, ce que D-11 réclamait.
Écarté : un septième deck (210 emplacements pour 127 lignées, ce serait confortable — mais une identité de plus à écrire et un pictogramme de plus à tracer dans DeckIcon.vue) · une refonte complète des six listes (la passe cible les sept nouvelles et Sentoriu ; les courbes seront rejugées après le premier test de table, avec des données) · ouvrir data/decks/ (le raisonnement de la refonte du 2026-08-10 tient : le deckbuilding est P4, ces listes sont un dépannage).
Le prix, à surveiller : à six decks de 30, il n'y a que 53 emplacements pour les deuxièmes et troisièmes exemplaires de 127 lignées. Le partage est désormais permis mais reste inutilisable en pratique — la liberté n'est réelle qu'avec un septième deck. Et « Pex sauvage » ne tient toujours que 16 lignées : c'est le deck qu'un test de table risque de trouver monotone.
P4 — retours de partie (2026-08-13)
D-112 · Une créature de la main sur terrain plein est injouable
Acté : jouer une carte Créature alors que le terrain du joueur porte déjà six créatures est refusé par le moteur — rien n'est payé, la carte ne quitte pas la main, aucun corps ne part au cimetière. C'est la seconde exception à D-41, de même forme que celle du permanent : une carte qui n'a nulle part où se poser n'est pas jouable. D-33 n'est amendé que sur ce point : l'invocation par effet (SUMMON, Cri de guerre, Râle) continue de perdre ses surnuméraires, une place à la fois, état réévalué à chaque placement.
Écarté : garder D-33 tel quel et se contenter de griser la carte dans le client (le client n'a aucune règle du jeu, D-58 — ses affordances sont des reduce() spéculatifs : tant que le moteur accepte, la carte reste jouable, et le serveur autoritaire l'encaisserait quand même) · refuser aussi les sorts d'invocation sur terrain plein (« Convocation générale » deviendrait morte en main, et le socle du kit — scn_invocation_terrain_plein — dit précisément le contraire depuis P2) · rembourser la Ferveur après coup (D-33 avait écarté toute annulation ; un remboursement rouvrirait la question de ce qu'on rembourse quand un Cri de guerre a déjà résolu).
Raison : D-33 avait tranché une question — que fait une séquence d'invocation qui déborde — et sa réponse est bonne : le joueur voit ce qu'il perd, c'est un choix de timing. Mais elle s'appliquait par ricochet à un geste qui n'a rien d'un choix de timing : poser une créature de la main. Là, il n'y a pas de compensation à arbitrer, pas d'autre effet qui continue — juste 4 Ferveur et une carte échangées contre un passage direct au cimetière, sans aucun retour. En partie, ça ne s'est pas lu comme une règle mais comme un bug — c'est le retour qui a ouvert cette décision. L'objection de D-33 (« il aurait fallu définir par carte le nombre de places nécessaires ») ne porte pas ici : une carte Créature en demande une, toujours, et c'est le moteur qui le sait — aucune donnée à ajouter dans data/cards/.
Conséquence : playCard porte deux gardes symétriques avant tout paiement (permanents D-41, créatures D-112) ; la branche « surnuméraire perdu » de la main devient inatteignable, et n'est plus qu'un garde de type pour un état hydraté hors bornes. Le client suit sans une ligne : affordances.ts demande la légalité au moteur, la carte se grise dans la main et le motif de refus s'affiche tel quel (D-58). Scénario de conformité obligatoire : scn_creature_terrain_plein_injouable, en regard de scn_invocation_terrain_plein qui garde D-33 — le kit passe de 75 à 76.
Le prix : une carte de plus qui peut rester bloquée en main, et un cas de plus à surveiller à l'équilibrage — un deck large qui remplit son terrain se prive lui-même de ses gros corps. C'est le comportement de Hearthstone, dont le combat est l'inspiration assumée (D-02), et c'est le sens du refus : le joueur garde sa carte pour le tour où il aura la place.
P4 — l'éditeur de decks (2026-08-13)
D-113 · Un éditeur de decks dans la doc, et le code « LGC1. » comme pont vers le client
Acté : le site de documentation gagne une page /editeur — recherche et filtres sur les 127 cartes, deck de 30 construit au clic, courbe de Ferveur et statistiques, decks sauvegardés dans le localStorage du navigateur. Le pont vers le client de duel est un code de deck : LGC1. + base64url d'un payload { v, name, heroClass, cards: [[id, n], …] }, paires triées par id (même deck ⇒ même code). Le client sait le coller (« Prendre ma place ») : le code s'ajoute à « Mes decks », dans SON localStorage, affiché sous les six préréglages par la même vignette ; chaque deck s'exporte en un clic dans l'autre sens. Encodeur et décodeur vivent dans le moteur (engine/src/deckcode.ts, à côté de deck.ts et comme lui HORS de la boucle du réducteur), base64url et UTF-8 écrits à la main pour respecter l'hermétisme D-56. C'est un outil de TEST : l'éditeur ne sera pas repris tel quel dans le client final, seul le module deckcode est conçu pour durer.
Trois choix de format, tous tournés vers la compatibilité future : le code repose sur les ids, jamais sur les versions de cartes — une carte ajustée est une version plus haute sous le même id, la résolution prend la plus haute, le code survit ; le préfixe porte la version du format (LGC2 inconnu ⇒ refus propre, jamais un décodage de travers) ; le décodeur ne consulte pas le catalogue — il rend un payload ou une erreur de forme, et c'est le consommateur qui résout avec validateDeck. L'import est tolérant : un id disparu du catalogue reste affiché avec un badge « introuvable », le deck est illégal mais éditable et sauvegardable — cohérent avec le brouillon toléré de D-69.
Écarté : le localStorage comme pont (doc et client sont deux ORIGINES — ports en dev, domaines après le VPS — le stockage ne se partage pas ; c'est la contrainte qui a dicté toute l'architecture) · héberger l'éditeur dans le client (il grossirait une application jetable et ne marcherait que serveur allumé) · un paquet workspace @lgc/deck-editor monté des deux côtés (de la plomberie pour un outil jetable) · embarquer les six préréglages dans l'éditeur (coupler le site au code du client ; la passerelle existe déjà — sélectionner un préréglage dans le client et copier son code) · un format compressé ou à numéros de collection (les numéros peuvent bouger en brouillon, les ids jamais ; et un JSON lisible en base64 se débogue à l'œil) · data/decks/ (écarté en D-111, rien de neuf).
Le prix : les decks « sauvegardés » vivent dans un localStorage de navigateur — un vidage de cache les emporte, le code copié ailleurs est la seule sauvegarde durable. Assumé pour un outil de test ; la collection persistée côté serveur reste l'affaire du produit final (P5, comptes de guilde).
P4 — retours du testeur (2026-08-14)
D-114 · count sur un sélecteur : n cibles DISTINCTES, et le schéma fait foi
Acté : count vaut pour tous les scopes que le schéma autorise à le porter — CHOSEN, RANDOM, LOWEST_HP, HIGHEST_ATK — et signifie partout la même chose : n entités distinctes, jamais deux fois la même. Le pool est réduit à ce qu'il contient (count 2 sur un terrain d'une créature en touche une, sans refus — D-41 tient). Pour LOWEST_HP/HIGHEST_ATK, les n meilleures dans l'ordre du critère, l'égalité tranchée par entry croissant — le déterminisme d'abord. Pour CHOSEN, le sélecteur consomme n entrées de intent.targets[], et un doublon dans les cibles fournies est ignoré (la seconde occurrence ne désigne rien, l'effet est perdu pour elle) plutôt que refusé.
D-30 n'est ni abrogée ni élargie, elle est bornée : elle régit le partage des choix entre sélecteurs — deux sélecteurs CHOSEN de même couple (side, kind) désignent toujours le même ensemble —, jamais le nombre de cibles à l'intérieur d'un sélecteur. « +0/+2 et Provocation » ne demande toujours qu'un choix ; « renvoyez deux créatures » en demande deux. Les deux règles se composent : un sélecteur count: n occupe n emplacements du curseur, et un second sélecteur de même couple réutilise les n mêmes cibles.
Raison : c'était déjà la règle — le schéma l'écrit noir sur blanc depuis P0 (« Nombre de cibles. Ignoré si scope vaut ALL ou SELF ») — et trois cartes du set l'utilisaient (« Clé mythique + 12 », « Mine de sel », « Déconnexion serveur »). Le moteur, lui, ne l'honorait que pour RANDOM : LOWEST_HP/HIGHEST_ATK rendaient une cible en dur, et buildChoices ne rangeait qu'un EntityRef par couple. Les trois cartes ne faisaient donc que la moitié de ce qu'elles annoncent, et un testeur l'a vu avant nous. Cette décision n'invente rien : elle écrit la sémantique que le schéma présupposait, pour qu'un second moteur ne puisse pas en inventer une autre.
Ce qui manquait, et qui compte plus que le correctif : aucun des 76 scénarios du kit n'exerçait count sur un sélecteur de cible, et rien ne comparait le schéma au moteur. Un écart de cette forme — la donnée exprime ce que le code ignore — est silencieux par construction : ni le validateur (la carte est conforme), ni le linter (l'équilibrage est bon), ni le kit (personne n'a écrit le cas) ne pouvaient le voir. Deux remèdes, tous deux livrés avec la décision : trois scénarios de kit, un par scope ; et un contrôle de tools/lint.mjs qui refuse toute construction du DSL que le moteur n'implémente pas. Le second est le vrai livrable — il vaut pour les écarts qu'on n'a pas encore commis.
Écarté : interdire count > 1 hors RANDOM dans le schéma et réécrire les trois cartes (c'était la solution la moins chère, et la mauvaise : le DSL perdait une capacité que la donnée savait déjà exprimer, et les trois cartes perdaient leur identité — « détruisez les deux créatures adverses ayant le moins de PV » n'a pas d'équivalent mono-cible) · rendre les cibles avec doublons pour CHOSEN (« renvoyez deux créatures » en désignant deux fois la même n'a pas de sens joueur, et ouvrait un cumul d'effets sur une seule entité) · trancher l'égalité de LOWEST_HP par un tirage RNG (le hasard doit rester lisible : il est déclaré par le scope RANDOM, jamais subi dans un scope déterministe).
Le prix, assumé : SourceContext.choices passe de EntityRef à EntityRef[], ce qui touche resolution.ts, prompt.ts, le client (affordances.ts boucle désormais sur les emplacements) et le bot (candidateIntents doit énumérer des paires de cibles, sinon il ne jouera jamais ces trois cartes). Aucun de ces appelants n'avait de raison de prévoir le cas : c'est le coût d'une règle écrite dans le schéma mais jamais dans le code.
D-115 · La surface du DSL est de la DONNÉE, et « Ninja loot » fonctionne enfin
Acté : data/schema/engine-surface.json déclare, pour chaque kind de cible, les scope qu'un moteur doit résoudre, s'il lit filter, et quels scopes honorent count. tools/lint.mjs refuse toute carte qui en sort ; engine/test/surface.test.ts vérifie la table dans les deux sens sur le moteur de référence — ce qu'elle promet est rendu, ce qu'elle refuse ne l'est pas. La table vit dans data/, donc elle est de l'actif durable (D-19) : un prototype B se juge à elle comme il se juge au kit.
Raison, et elle est une leçon : le schéma dit ce qu'une carte a le DROIT d'écrire ; rien ne disait ce qu'un moteur sait en FAIRE. L'écart entre les deux est silencieux par construction — le validateur voit une carte conforme, le linter un équilibrage correct, le kit ne teste que les cas qu'on a pensé à écrire, et la carte ne fait rien en partie. Quatre cartes ont vécu comme ça : trois y perdaient la moitié de leur effet (D-114), et « Ninja loot » ne faisait strictement rien — kind: CARD_IN_HAND n'était résolu par aucune branche, la Ferveur était payée, aucun événement n'était émis. Aucun de nos garde-fous ne pouvait le voir, parce qu'aucun ne comparait les deux fichiers. Celui-ci ne fait que ça.
CARD_IN_HAND est donc implémenté, et bordé : eligiblePool sait lister une main (par camp, dans l'ordre de la main — c'est ce qui rend le tirage RANDOM reproductible, D-51), ResolvedTarget gagne un cas { player, index }, et COPY sait copier une carte en main. Le reste est fermé, et pour des raisons de fond, pas de fatigue : pas de CHOSEN (une carte en main n'a pas d'entry, et EntityRef ne sait nommer qu'une entrée ou un héros — le joueur ne PEUT pas la désigner) · pas de LOWEST_HP/HIGHEST_ATK (elle n'a pas de stats en jeu) · pas de filter (matchesFilter juge une créature EN JEU, dégâts marqués et mots-clés effectifs compris ; un demi-filtre serait exactement l'écart que cette décision existe pour interdire). ANY continue de vouloir dire « ce qui est en jeu » : une zone cachée se demande par son nom, sinon tout sort de zone existant atteindrait les mains sans qu'aucune carte ne l'ait écrit. CARD_IN_DECK reste sans aucun scope — fouiller un deck, c'est TUTOR, qui a ses propres params.
Un troisième défaut, corrigé au passage : LOWEST_HP et HIGHEST_ATK classaient les permanents et les héros par un score de zéro au lieu de les écarter. « Détruisez le permanent qui a le moins de PV » rendait donc un permanent arbitraire — déterministe, mais dénué de sens. Le critère écarte maintenant les cibles qui ne le portent pas : un permanent n'a ni ATK ni PV, une carte en main non plus, et le héros n'attaque jamais (D-29). C'est le test de surface qui l'a trouvé, pas une partie.
Écarté : réécrire « Ninja loot » avec ce que le moteur savait déjà faire (moins cher, mais on perdait le sens exact de la carte — copier CE que l'adversaire a en main — et le corpus aurait gardé le trou qui l'a produite) · mettre la table dans tools/ (elle décrit un contrat de moteur, pas une règle d'outillage : sa place est avec le schéma et le kit, que tout prototype emporte) · la dériver automatiquement du code du moteur (une table dérivée d'un moteur ne peut plus lui donner tort — c'est justement ce qu'on veut qu'elle puisse faire) · un TRANSFORM ou un DISCARD sur CARD_IN_HAND tant qu'aucune carte ne le demande (la table s'élargit quand une carte l'exige, jamais « au cas où » : chaque case ouverte est une promesse à tenir).
Le prix : une table de plus à tenir à jour. Elle est bordée des deux côtés — le lint casse si une carte en sort, le test casse si le moteur en sort, et deux assertions vérifient qu'elle couvre exactement les enums du schéma. Une case oubliée ne peut donc pas rester oubliée longtemps.
D-116 · Une carte piochée vaut 2 points d'effet, pas 1,4
Acté : le barème de §9.1 passe de 0,7 carte piochée à 0,5 carte piochée — une carte vaut 2 points d'effet. La défausse imposée reste à 1,4 : une carte arrachée à l'adversaire vaut moins qu'une carte tirée pour soi (elle est aléatoire, elle peut emporter une carte dont il ne voulait pas, et elle ne vaut rien contre une main vide). Une seule carte est recoûtée : « Onglet 3 » passe de 3 à 4 Ferveur, en gardant ses trois pioches. Neuf design.effectPoints sont réécrits.
Raison, et elle vient du document lui-même : les créatures ne se budgètent pas au point d'effet mais au budget de caractéristiques, et elles payaient déjà une pioche 2 points — Archiviste du Conseil (4 F) porte 3/4 = 7 pour un budget de 9, Naveis (5 F) 5/4 = 9 pour 11, l'Alchimiste de garde (6 F) 4/5 = 9 pour 13, soit −4 pour deux pioches. La spécification donnait donc deux prix à la même chose selon le type de carte, et c'est le moins cher qui servait aux sorts. Le retour du premier testeur — « j'augmenterais la valeur de la pioche, Onglet 3 et Réunion d'officiers c'est trop fort » — ne faisait que nommer l'écart. Signal qui achève de trancher : sous l'ancien taux, les deux cartes qu'il désignait comme trop fortes étaient les plus basses du barème (76 %, pour un plancher de 70 %). C'est le même argument qui avait fait passer la pente de 2,5 à 1,5 : quand la règle contredit systématiquement le jugement de table, c'est la règle qui a tort.
Pourquoi monter le coût d'« Onglet 3 » plutôt que baisser sa pioche : à 2 points la carte, trois pioches valent 6 points — 109 % de l'étalon à 3 Ferveur, 86 % à 4. Les deux corrections tenaient donc, mais le nombre EST la blague : « Onglet 3 » pioche trois cartes. À 3 Ferveur pour deux cartes elle devenait l'Arcane Intellect de Hearthstone, correcte et anonyme ; à 4 Ferveur pour trois, elle reste elle-même. Conséquence à connaître : la courbe de « Commission de discipline » monte d'un cran, et la courbe du set passe à 29 cartes à 4 Ferveur pour 23 visées — c'est le sommet, pas la base, que D-11 dit de surveiller ; on laisse et on rouvrira avec des parties.
« Réunion d'officiers » n'est PAS touchée, contre l'avis du testeur et contre ma propre première proposition : au nouveau taux elle vaut 4,8 points pour un étalon de 5,5, soit 87 % — en plein milieu de la bande. Les deux recoûts envisagés sortaient du cadre (2 Ferveur pour une défausse et une pioche : 85 %, mais ce n'est plus la même carte ; 4 Ferveur : 69 %, sous le plancher). Ce qui gêne en partie n'est donc pas son prix mais sa variance — deux défausses au hasard font très mal contre une main pleine et rien contre une main vide. Ça se corrige sur des parties, pas sur un barème.
Trois écarts assumés, écrits dans la carte plutôt que subis : « Un dernier try » et « Retour au cimetière » montent à 125 % de leur étalon, « Wipe sur le trash » à 118 %. Tous trois sont des cantrips à 2 Ferveur, et c'est là que le barème sur-punit : une carte y pèse la moitié du plafond, alors qu'en partie « 3 PV rendus et une carte » à 2 Ferveur est un tour d'attente, pas un tour fort. Le linter les signale, leur design.notes dit pourquoi on les garde — c'est exactement l'usage prévu de la tolérance, et le précédent existe (« Le Raid principal » à 129 %, D-110).
Écarté : monter la défausse imposée à 2 points comme la pioche (« Réunion d'officiers » passait alors à 109 %, mais on aurait payé le même prix pour une carte qu'on obtient et pour une carte qu'on détruit — deux effets qui ne se jouent pas pareil) · monter à 2,5 points la carte pour coller à l'intuition Hearthstone du neutre (aucune donnée interne ne le soutient ; les créatures disent 2, on suit les créatures) · toucher à la pente 1,5 × coût + 1 (c'est le taux de conversion qui était faux, pas la pente — et la dette listée dans CLAUDE.md visait la pente) · nerfer les trois cantrips à 2 Ferveur pour rentrer dans la bande (ce serait laisser le barème décider contre le jugement, l'erreur exacte que cette décision corrige).
D-117 · Un seul format de liste de deck, et un parseur partagé
Acté : card-render/src/decklist.js devient le seul lecteur de liste de deck du projet. /editeur et /test l'appellent tous les deux. Il accepte un code « LGC1. » (D-113), une liste « 3 crea_x », un id nu, un @version (ignoré), les commentaires # et //, et — c'est neuf — le nom français de la carte aussi bien que son id. Un test d'aller-retour garde le chemin exact : ce que l'éditeur exporte, le mode test le relit, pour les six préréglages et pour un code de deck.
Raison : l'éditeur exportait ${n} ${id}, le mode test n'acceptait qu'un id nu. Coller l'export de l'un dans l'autre rejetait donc toutes les lignes — cartes anciennes comprises — sous le message « cartes inconnues », qui se lit comme un catalogue périmé. Un testeur en a conclu que le mode test ne connaissait pas les nouvelles cartes ; le catalogue était complet, c'étaient deux pages du même site qui ne parlaient pas la même langue. Le parseur tolérant existait déjà, dans DeckEditorView.vue : deux parseurs voisins, un seul qui savait. Il n'y avait pas de code à écrire, il y avait un code à déplacer — dans card-render/, le paquet partagé qui existe pour ça (D-86).
Deuxième moitié du même retour : le deck rapide ne montrait qu'un tiers du set. quickDeckLines() parcourait la collection triée par coût croissant et s'arrêtait à 30 : le deck par défaut des deux joueurs, c'étaient les 14 cartes à 1 Ferveur et les 16 premières à 2, et 97 lignées sur 127 ne pouvaient pas sortir d'une partie de test — dont six des sept cartes de D-110, et toutes celles que le testeur citait. C'est ce qui l'a poussé à coller son propre deck, donc à rencontrer le mur du format. L'échantillon avance maintenant d'un pas régulier dans la même liste triée, bornes incluses : 1 → 9 Ferveur, trente lignées distinctes, et la carte la plus chère du set en fait toujours partie. (Les bornes incluses ne sont pas un détail : un pas entier aurait reproduit le même défaut en plus petit, en laissant dehors la queue de la liste.)
Et les decks de /editeur s'ouvrent directement dans /test : les deux pages sont la même origine — c'est tout le site de documentation — donc le localStorage est partagé et il n'y a rien à faire transiter. La réserve de D-113 (« le localStorage ne traverse pas les origines ») visait le pont vers le client de duel, une autre application ; elle ne s'applique pas ici. /test lit la clé de l'éditeur, ne l'écrit jamais, et verse le deck choisi dans la zone de texte — qui reste la source de vérité.
Écarté : importer DECK_PRESETS depuis client/ dans le site (D-113 l'avait déjà écarté — ça couplerait le site au code d'une application jetable ; le code « LGC1. » est le pont, et il marche) · remonter les préréglages dans data/decks/ (écarté en D-111, rien de neuf) · garder deux parseurs en synchronisant leurs formats à la main (c'est exactement ce qui a échoué, sans que personne ne s'en aperçoive) · faire du mode test un lecteur de codes uniquement (une liste tapée à la main reste la façon la plus rapide d'éprouver deux cartes).
D-118 · Un Cri de guerre ne part que de la main
Acté, et seulement écrit : ON_PLAY n'est déclenché que par l'intention PLAY_CARD. Une créature qui arrive autrement — invoquée par un effet (SUMMON), copiée (COPY), tirée du deck vers le terrain (TUTOR) ou issue d'un TRANSFORM — ne déclenche pas son Cri de guerre. ON_ALLY_SUMMONED (Camaraderie), lui, voit toutes les arrivées, quelle qu'en soit l'origine : une créature invoquée est bien « arrivée » aux yeux des autres, elle n'a simplement pas de mot à dire sur la sienne.
Le moteur faisait déjà exactement ça — placeCreature n'émet que SUMMONED et n'enfile que ON_ALLY_SUMMONED — mais rien ne l'écrivait, et une note de conception affirmait le contraire : « Hall of Fame », lue de près, promettait que ses copies déclenchaient leur Cri de guerre. La note est fausse depuis le jour où elle a été écrite. Une règle non écrite n'est pas une règle : le prochain moteur aurait pu trancher dans l'autre sens sans que rien ne le contredise, et un designer aurait pu concevoir une carte autour d'une promesse que le jeu ne tient pas.
Pourquoi c'est la bonne règle, et pas seulement celle qu'on a : sans elle, « invoquez une copie de X » vaut strictement plus que jouer X — on paie une fois pour deux déclenchements —, et tout effet d'invocation devient un multiplicateur de Cris de guerre. C'est le comportement standard du genre pour cette raison précise.
Livré avec : scn_cri_de_guerre_depuis_la_main_seulement — le même Crieur, invoqué puis joué de la main, une seule salve de dégâts sur deux arrivées. La règle est désormais dans le glossaire, dans le kit, et dans la note de la carte qui la démentait.