Skip to content

Journal d'exécution ​

Ce fichier est une archive, pas une consigne. Il conserve le détail des phases livrées : périmètre acté, choix d'exécution tranche par tranche, arbitrages pris en route, post-mortem. Le CLAUDE.md n'en garde que ce qui reste vrai et actionnable aujourd'hui.

À lire quand on reprend un chantier terminé, qu'on se demande pourquoi c'est comme ça, ou qu'on s'apprête à refaire quelque chose qui a déjà été tenté. Pour le quoi et le pourquoi d'une décision, c'est 02-decisions.md (D-01 → D-87). Pour l'état courant du projet, c'est le CLAUDE.md.

Règle d'entretien : à la fin de chaque phase, l'historique d'exécution qui s'est accumulé dans CLAUDE.md descend ici. Ce fichier grossit sans limite — c'est son métier. Le CLAUDE.md, lui, ne grossit pas.


P1 — périmètre acté (D-35) ​

Le raisonnement qui a mis P1 en tête de file : le générateur est la brique la plus rentable — la moins risquée, la plus rapide, la seule qui crée de l'attente avant que le jeu existe, et elle débloque le prototypage de mécaniques sur table (cartes imprimées, zéro ligne de moteur).

Un seul site VitePress statique, dans ce dépôt, qui réunit trois choses :

  1. La documentation consultable — navigation, recherche plein texte et sommaire générés depuis docs/*.md. Les fichiers restent la source de vérité ; le site est un rendu, jamais un stockage. Ni wiki à base de données, ni dépôt séparé synchronisé : les deux réintroduiraient une seconde source de vérité.
  2. Le générateur de cartes — formulaire → JSON → prévisualisation en direct, import et recadrage de visuel, bascule normale / enluminée, export PNG haute résolution et planche A4 (3 × 3, sans enluminure). Spécifications d'impression dans sets.json (printSpec).
  3. La galerie — les cartes rendues, filtres, complétion (« 87 / 120 · dont 12 enluminées »), courbe du set calculée en direct depuis data/.

C'est le générateur qui impose le dépôt unique : il lit taxonomy.json, ranks.json, sets.json et data/cards/ en import relatif. Un dépôt séparé exigerait un sous-module ou un paquet npm republié à chaque carte — le problème de synchronisation reviendrait sur l'actif durable.

Le site est statique : hébergeable n'importe où, y compris en sous-domaine de guilde-lgc.fr, et sans rapport avec le service stateful du duel (P3).

Choix d'exécution P1 (2026-08-03) ​

  • Livraison par tranches : les trois sont livrées — 1) squelette + doc consultable · 2) rendu de carte + galerie · 3) générateur (formulaire → JSON → préview, visuel + zoom/position, exports JSON / PNG 744×1039 et 815×1110 fond perdu / planche A4 3×3 sans enluminure, budget §9 et étalon D-34 vérifiés en direct). Un export du générateur a été validé par tools/npm run check (0 erreur).
  • Limites assumées du générateur P1 : le visuel importé sert à la préview et aux exports PNG, artAsset reste null (pas de gestion d'assets) ; les capacités s'éditent en JSON brut (le DSL est trop riche pour un formulaire) ; la planche A4 passe par l'impression navigateur (mm physiques exacts).
  • Rendu de carte (site/.vitepress/theme/components/CardRender.vue) : proportions du printSpec (unité CSS = 1 mm), cadres par type, gemmes coût/ATK/PV, libellés lus dans taxonomy.json/ranks.json (D-21). Les mots-clés et déclencheurs sont engraissés automatiquement dans le texte de règles (liste dérivée de taxonomy.json ; **…** pour un gras manuel) — le JSON reste du texte brut. Deux micro-choix d'affichage à valider : le libellé « Jeton » est le seul en dur (absent de taxonomy.json) ; la ligne de type d'une créature affiche rang · rôle (ou le type non-membre) sans le préfixe « Membre » — « Membre — PNJ » aurait été contradictoire.
  • Direction visuelle des cartes : vectoriel doré (2026-08-05, refonte de CardRender.vue) — cadre fantasy classique entièrement dessiné en CSS/SVG, aucun asset raster. Le choix est structurant : le gabarit reste net à n'importe quel DPI, imprimable, recolorable par variable, et ne demande ni gestion d'assets ni détourage. Écartés : le registre néon sci-fi (collision avec le registre WoW), le rendu peint façon Hearthstone (infaisable en CSS, imposerait des assets), le placeholder sobre.
    • Deux axes de lecture indépendants. La rareté pilote l'alliage du jonc — les teintes de taxonomy.json (code couleur WoW) sont désaturées puis mélangées à un bronze sombre, sinon le cadre vire au néon ; LEGENDARY échappe au calcul et reste franchement or. Le type pilote la silhouette de la fenêtre d'art : arche pour une créature (un portrait), lentille pour un sort (un phénomène), plaque droite pour un permanent (un lieu). La silhouette est le seul signal de type qui survit à une illustration plein cadre — une teinte de fond, elle, disparaîtrait.
    • Rien ne dépasse des 63 × 88 mm : les gemmes sont rentrées dans la carte (l'ancien écusson légendaire, posé en négatif au-dessus du bord, était rogné par l'export 744 × 1039). Nom et texte de règles se réduisent par palier de densité, jamais par mesure DOM — html-to-image rejoue les styles calculés, pas une mise en page JS.
    • Piège d'export, à ne pas réintroduire : aucun commentaire du <template> de CardRender.vue ne doit contenir -- (les lignes de tirets décoratives, typiquement). html-to-image sérialise le DOM dans un <foreignObject> SVG, où -- est un token XML invalide : l'export PNG échoue alors avec un [object Event] muet. Même famille de piège : background-clip: text ne survit pas à l'export (l'aperçu et le PNG divergeraient) — le gabarit n'utilise que des couleurs pleines sur le texte.
    • Police d'affichage : Cinzel (@fontsource/cinzel, auto-hébergée comme Poppins — une police distante ne serait pas embarquée dans l'export PNG), pour le nom, la ligne de type et les chiffres des gemmes. Poppins reste le texte de règles. Repli Georgia/serif si le paquet n'est pas installé : la carte se dégrade, elle ne casse pas. cd site && npm install après un git pull.
  • Illustrations : data/art/, par convention de nom (2026-08-05) — la limite P1 « artAsset reste null » est levée. Les visuels sont de l'actif durable : ils vivent dans data/, pas dans site/public/ (D-35 — le site ne stocke aucun contenu propre), et un prototype B les lirait tels quels. C'est le seul dossier de data/ qui ne soit pas du JSON ; le reste de la règle tient, il reste indépendant de toute stack.
    • Déposer data/art/<id>.<ext> suffit — aucune édition de JSON. site/.vitepress/theme/lib/art.js résout par import.meta.glob, artAsset reste disponible pour les cas particuliers et fait foi quand il est renseigné. Le visuel suit l'id, pas la version : un nerf ne redessine rien (D-22).
    • Format : 3:2 paysage, 1536 × 1024 minimum, .webp, sans texte ni cadre peints. La fenêtre recadre en cover et sa forme dépend du type — pour une créature, les angles hauts sont mangés par l'arche, donc tête dans le premier tiers. Vérifié de bout en bout : un visuel déposé traverse le rendu, la galerie et l'export PNG 744 × 1039.
    • La charte de style vit dans docs/05-charte-illustrations.md — bloc de style à recoller dans chaque génération (c'est lui qui fait que 120 visuels forment une famille), prompt négatif, et les prompts prêts à coller des cartes témoins. Il porte aussi la règle sociale : les créatures représentent de vraies personnes, l'humour vient de la situation, jamais du physique, et le portrait flatte toujours.
    • Reste ouvert : la génération elle-même (Claude n'a pas d'outil d'image dans Cowork), et le branchement au générateur IA du site de guilde, qui exigerait une API.
  • Déploiement : Netlify, en ligne sur https://tcg.guilde-lgc.fr (netlify.toml à la racine : base site, publish site/dist) — hébergement de transition. D-77 : la doc déménagera sur tcg-docs.guilde-lgc.fr quand le client de duel prendra l'URL courte (tranche 3), avec redirections 301 des anciens chemins ; à terme, tout est servi par le VPS.
  • Le générateur exporte le JSON en téléchargement (le site n'écrit jamais, D-35) : dépôt manuel dans data/cards/ puis cd tools && npm run check. Import de visuel P1 = upload local ; le branchement au générateur IA du site de guilde exigerait une API, plus tard.
  • Note technique : srcDir remonte à la racine du dépôt ; les pages de docs/ ne résolvent pas site/node_modules, d'où l'alias explicite de vue et vue/server-renderer dans .vitepress/config.mts.

P2 — cadrage acté (tranche 0, 2026-08-03) ​

Ordre acté par la spec : le kit de conformité s'écrit avant le moteur — les scénarios sont la spécification exécutable, le moteur est ce qui la satisfait.

Choix d'exécution P2 ​

  • Moteur dans ce dépôt, dossier engine/ à la racine — prototype jetable, même statut que tools/ et site/ (le raisonnement D-35 s'applique : un dépôt séparé réintroduirait la synchronisation sur l'actif durable). Un futur prototype B serait engine-b/, jugé au même kit.
  • TypeScript strict, cœur zéro dépendance, compatible navigateur ; harnais de test vitest. Un prototype = une variable : la stack « ennuyeuse » d'abord.
  • Scénarios : assertions partielles + événements attendus — jamais de snapshot complet (il casserait à chaque champ ajouté à l'état). Détail dans data/conformance/README.md.
  • Hasard : algorithme PRNG normatif + seed dans le scénario. L'algorithme fait partie de l'actif durable (piste : mulberry32 — à acter en tranche 1 avec le schéma). Requis de toute façon pour les replays (P4).
  • Cartes des scénarios : hybride — fixtures synthétiques pour isoler chaque règle, plus quelques scénarios « fumée » sur cartes réelles versionnées.
  • Le périmètre inclut le mode test rapide (partie contre soi-même, main imposée, état arbitraire) en dernière tranche, page applicative dans site/ — d'où la contrainte navigateur sur le moteur.

Tranches ​

  1. Tranche 0 — lacunes de règles Fait : D-36 → D-50 actées, spec v1.2, handLimit dans taxonomy.json, texte du Pacte aligné (D-40), npm run check vert.
  2. Tranche 1 — schémas d'état Fait (2026-08-03) : state / intent / event / scenario.schema.json dans data/schema/ (état épars à défauts, D-52), PRNG acté : mulberry32 (D-51, implémentation de référence au journal), 3 scénarios témoins dans data/conformance/scenarios/, tools/conformance.mjs chaîné dans npm run check.
  3. Tranche 2 — le kit Fait (2026-08-04) : 75 scénarios (72 nouveaux + 3 témoins) couvrant les 30 événements, 16 primitives, 5 mots-clés, 10 déclencheurs, 7 conditions et 7 intentions — couverture vérifiée par script, npm run check vert, testé en négatif. Deux décisions prises en route : D-53 (scénarios de mise en place : MULLIGAN en tête, decks seuls, mulligan vide = zéro RNG) et D-54 (sémantique d'émission des événements : TRIGGER_FIRED réservé aux déclenchements, quantités effectives, ordres par entry, cible illégale = effet perdu sans rejet). Limites assumées listées dans data/conformance/README.md.
  4. Tranche 3 — engine/ + runner CLI Fait (2026-08-04) : @lgc/engine, 75/75 scénarios verts. TypeScript strict, cœur zéro dépendance, compatible navigateur, sous le contrat D-56 prouvé mécaniquement — chaque scénario rejoué deux fois donne l'identique, l'état final égale son aller-retour JSON, reduce ne mute jamais son entrée (état d'entrée gelé dans les tests), et engine/scripts/boundary-lint.mjs interdit imports externes et lectures d'environnement dans src/. Le juge est partagé (D-63) : tools/kit-harness.mjs sert la suite vitest et le runner CLI générique tools/run-kit.mjs --adapter <module>, par lequel un prototype B serait jugé au même kit. Décisions prises en route : D-62 (sémantiques hors kit), D-63 (juge partagé, périmètre de la frontière).
  5. Tranche 4 — le mode test rapide Fait (2026-08-04) : page /test du site (TestView.vue), délibérément minimale (D-58) — un banc d'essai pour valider les cartes, pas une préfiguration du client. Deux entrées : partie rapide (setupMatch — seed, decks en texte pré-remplis depuis le corpus, classes, mulligan par indices : même seed ⇒ mêmes mains, D-51) et état arbitraire (JSON épars → hydrateState, main imposée, défauts D-52). Partie contre soi-même, stats effectives recalculées, journal intentions/événements/refus, annulation (les états sont des valeurs), export d'un brouillon de scénario (seed + état initial + intentions acceptées + eventCounts/result observés, format D-52) — vérifié : un export du banc rejoue à l'identique et sans échec dans le juge du kit (tools/kit-harness.mjs).

Choix d'exécution tranche 4 ​

  • Le contrat D-58 est tenu littéralement : aucune règle du jeu dans la page. La légalité est le refus du moteur, affiché tel quel ; les affordances (cartes grisées, cibles d'attaque marquées) sont des reduce() spéculatifs sur l'état courant — le moteur est pur, l'essai ne coûte rien. Même l'attaque d'un permanent part au moteur, qui la refuse.
  • Le moteur est importé en source : alias Vite @lgc/engine → engine/src/index.ts dans site/.vitepress/config.mts — aucun build de engine/ requis pour le site, Vite compile le TS comme le reste.
  • Le catalogue est injecté par le site (theme/lib/engineCatalog.js, D-56.4) : contrairement à lib/cards.js (affichage, dernière version), il porte toutes les versions, jetons compris, plus hero-powers et les constantes de taxonomy.json.
  • Rendu : CardRender.vue pour les mains (D-58.3) ; le terrain utilise des tuiles textuelles compactes (stats effectives, mots-clés effectifs, badges silence/malaise/attaque dépensée) — l'état, pas la carte.
  • Gotcha workspaces + srcDir : le node_modules racine (workspaces D-57) porte estree-walker@3 (ESM pur) que le rendu SSR de VitePress se met à externaliser à la place du @2 attendu par le compilateur Vue — corrigé par un alias explicite vers la copie de site/, même famille de remède que l'alias vue.

Architecture d'exécution — ce qui est acté, ce qui est différé (D-55 → D-58, 2026-08-04) ​

Acté : moteur et serveur en TypeScript (le serveur exécute le même code que le mode test — un autre langage voudrait dire deux moteurs à maintenir verts sur le même kit). Frontières de paquets par npm workspaces dès la tranche 3, à commencer par @lgc/engine, dans le dépôt unique maintenu (D-57 : la scission en plusieurs dépôts est différée, pas exclue — l'axe sera « actif durable contre livrable déployable », et les workspaces la rendent bon marché en interdisant les imports croisés dès maintenant). Le mode test vit dans site/, le futur client de duel non.

Différés le 2026-08-04, refermés le 2026-08-05 par la tranche 0 de P3 (voir la section suivante) : framework serveur, base de données, partage des données avec le site de guilde, hébergement et authentification sont désormais tranchés. Le framework du client l'a été à son tour le 2026-08-05 : Vue (D-74). Plus rien n'est différé de cette liste.

Ce qui rend le report sûr : le contrat d'hermétisme (D-56) côté moteur, et le contrat de remplaçabilité côté client (D-58 : aucune règle du jeu dans le client, dialogue par intentions/événements uniquement, rendu de carte isolé derrière un composant unique). Sous ces contrats, un serveur Nest ou Fastify, un client Vue ou Phaser sont des adaptateurs interchangeables. Pistes non actées (ne pas les traiter comme acquises) : NestJS + Socket.IO, propriété des données plutôt que base partagée à deux écrivains, ouverture par jeton court signé émis par l'API.

Deux faits à ne pas réoublier : la boucle temps réel ne touche aucune donnée partagée (instantané au départ, une ligne à la fin) ; et le format de persistance d'une partie existe déjà — seed + intentions + événements, c'est scenario.schema.json (D-52), donc journal de partie, reprise après redémarrage et replay de P4 sont un seul mécanisme.

P3 — cadrage acté (tranche 0, 2026-08-05) ​

Dix décisions, D-64 → D-73, prises avant d'écrire server/. Elles referment les questions que D-55 laissait ouvertes.

Les quatre réponses d'infrastructure : l'hébergement est le serveur qui porte déjà l'API de guilde — un processus Node long, donc 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 passe par un lien d'invitation à code opaque, pseudo saisi librement, zéro authentification ; le travail commence par le serveur seul, validé par des clients scriptés. Conséquence qui n'était pas acquise : P3 n'a besoin d'aucune base de données (D-67 — la persistance est un journal de partie).

Le pari du cadrage (D-64) : le cœur du serveur est pur lui aussi — sessionReduce(session, message, now) => { session, outbox }, aucune E/S, aucun minuteur, le temps entre par now et les échéances sortent en données. NestJS + Socket.IO est acté, mais seulement comme adaptateur : une partie complète se teste sans réseau, sans horloge et sans framework. C'est D-56 rejoué un cran au-dessus.

Quatre faits trouvés en relisant engine/src/, à ne pas réoublier :

  • reduce ne sait pas qui parle — il agit au nom de state.activePlayer. Une intention de P2 pendant le tour de P1 serait exécutée comme si elle venait de P1. Faille de triche n° 1 de P3, entièrement à la charge du serveur (D-65).
  • setupMatch veut les deux mulligans d'un coup, alors qu'il faut montrer les mains avant de les demander. Résolu sans toucher au moteur : deux appels, le premier à vide (D-70).
  • La légalité d'un deck n'est vérifiée nulle part — ni moteur, ni tools/, ni site/ (D-69).
  • Furtif n'est pas à masquer dans la vue filtrée : D-44 dit « invisible, pas intangible ». Le masquage se réduit à la main et au deck adverses, effectifs conservés, et le résultat reste un GameState valide — c'est ce qui laisse le client faire ses reduce() spéculatifs (D-66).

Les tranches : 0 ce cadrage · 1 le cœur pur LIVRÉE · 2 l'adaptateur Nest/Socket.IO, le salon, les horloges réelles et la reprise après crash LIVRÉE le 2026-08-06 (D-78 → D-83) · 3 le client de duel LIVRÉE le 2026-08-06 (client/, hors site/, en Vue — D-58, D-74 ; D-84 → D-87) · 4 le déploiement sur le VPS et la bascule de la doc vers tcg-docs — la seule qui reste.

Noms et hébergement (D-77, 2026-08-06) : le jeu sur tcg.guilde-lgc.fr (l'URL courte au produit quotidien — elle porte les liens d'invitation /j/<code>), le serveur sur tcg-server.guilde-lgc.fr (sous-domaine imposé : Netlify ne proxifie pas les WebSockets), la doc déplacée sur tcg-docs.guilde-lgc.fr. Netlify est une transition : tout finit sur le VPS. Les URL sont le contrat stable, l'hébergement migre derrière. La bascule de la doc attend la tranche 3 (redirections 301 comprises) ; la checklist DNS/proxy/CORS est au journal.

Le client (D-74, 2026-08-05 ; amendé par D-84, 2026-08-06) : Vue acté, CardRender.vue reste le rendu de carte unique (D-58.3) — il vit désormais dans @lgc/card-render (D-86). Three.js est écarté pour le moment (D-84) : la maquette n'a pas été faite, elle est reportée sans échéance, parce que son critère d'échec était déjà acquis d'avance. Le point dur n'est pas le framework mais le rendu : CardRender.vue est du CSS/SVG, une scène Three.js est un canvas WebGL, et le composant ne disparaîtrait pas de toute façon (le générateur, la galerie et les exports PNG/A4 en dépendent — besoins de P1). Un client WebGL ajouterait donc un second gabarit au lieu d'en remplacer un. Critère de la maquette, toujours valable le jour où la question revient : texte de règles lisible à taille de main, une enluminée en shader face à la même en CSS, le coût de recuisson quand les stats effectives changent (règle 4, y compris en main) — et si la réponse est « deux gabarits à maintenir », la maquette a échoué même belle. À rouvrir seulement si le plateau paraît inerte une fois le jeu réellement joué en guilde.

Horloges actées (D-68, toutes hors moteur) : tour 75 s (D-42) · grâce à la déconnexion 120 s, au-delà abandon d'office · expiration d'un code d'invitation 30 min. Les trois vivent en constantes dans taxonomy.json ; les deux premières tombent d'office dans le cœur pur, la troisième appartient au salon (tranche 2). Les banques de temps que §1 annonçait sont abandonnées (D-80).

Choix d'exécution tranche 1 (2026-08-06) ​

  • Périmètre : toute la logique pure (choix de Guillaume) — sièges et decks (D-65, D-69, D-72), mulligan en deux setupMatch (D-70), protocole seq/idempotence/reprise (D-71), échéances en données et injections d'office (D-68), vues et événements projetés par siège (D-66, D-75), journal en lignes d'ajout (D-67). La tranche 2 n'est plus qu'un traducteur : socket → siège, minuteur réel → TIME, ligne JOURNAL → fichier, item outbox → émission, plus le salon.
  • sessionReduce(catalog, session, message, now) dans server/src/session.ts, sous scripts/boundary-lint.mjs (copie du lint D-56 : seule dépendance admise @lgc/engine, ni Date, ni minuterie, ni Map/Set, ni async). La session est une valeur : clone JSON en entrée, déterminisme testé.
  • Moteur enrichi (exports séparés du réducteur) : projectFor (D-66, D-75) et validateDeck (D-69) dans @lgc/engine ; Catalog gagne deux champs optionnels rarities et sets que seul validateDeck lit. card_hidden.v1.json au corpus ; le motif d'id des schémas admet cette unique lignée réservée.
  • Le critère de fin est un test (server/test/full-game.test.ts) : deux bots sans règles (candidats filtrés par reduce() spéculatif sur leur seule vue filtrée — zéro refus serveur sur toute la partie, la vue suffit donc aux affordances), partie complète sur deux vrais decks de 30 (43 tours, 172 intentions, seed 2026), journal-scénario accepté par tools/kit-harness.mjs avec égalité stricte de l'état final, et reconstruction depuis les seules lignes JOURNAL (le chemin de reprise après redémarrage).
  • Mulligan, détail utile : les mains montrées sont celles du premier setupMatch moins la pioche du tour 1 (comptée par les événements CARD_DRAWN) — la phase Début du premier tour a déjà eu lieu dans le résultat jeté.

Choix d'exécution tranche 2 (2026-08-06) ​

  • L'adaptateur vit dans server/app/, hors de src/ — le lint de frontière D-64 n'a pas bougé, le cœur reste hermétique. main.ts · bootstrap.ts (levable sur un port éphémère, c'est ce qui rend le banc e2e possible) · config.ts (le seul module qui lit l'environnement) · catalog.service.ts (D-73) · journal.store.ts · replay.ts · session-runner.ts (le traducteur) · lobby.service.ts (D-72) · lobby.controller.ts · duel.gateway.ts · wire.ts.
  • Le fil : quatre événements entrants (join, resume, mulligan, intent), un seul canal sortant server qui porte les ServerMessage de la tranche 1 tels quels, plus un canal session qui n'appartient qu'à l'adaptateur (siège + jeton, D-79). Côté HTTP, trois routes et rien de plus : POST /api/games, GET /api/games/:code (état public : ni main, ni deck), GET /healthz.
  • Six décisions prises en route : D-78 (lastSeq dans chaque poussée — sans lui la reprise est impossible), D-79 (jeton de siège, persisté dans un <code>.meta.json à côté du journal pour ne pas salir le format de scénario ; reprendre un siège délaisse l'ancienne connexion au lieu de la couper), D-80 (banques de temps abandonnées, point ouvert n° 5 refermé), D-81 (les échéances se relisent dans la session, pas dans l'outbox), D-82 (reprise à la demande + journal écrit avant l'acquittement + dernière ligne tronquée tolérée), D-83 (le cœur refuse les types d'intention inconnus — reduce n'a pas de branche par défaut et les laissait passer pour acceptés ; l'adaptateur rattrape les exceptions du moteur en MALFORMED).
  • Les gardes que la relecture a ajoutées (toutes couvertes par un test) : une connexion ne prend qu'un siège (sinon un seul client prend les deux et la partie devient injouable) ; un client parti pendant l'écriture du journal de son JOIN est bien déconnecté après coup (sinon siège fantôme sans horloge de grâce) ; une partie ressuscitée déjà terminée est mise en file d'éviction (sinon GET /api/games/:code, non authentifié, épingle des sessions en mémoire) ; plafond de 200 parties vivantes sur la route de création.
  • Le banc e2e teste l'artefact construit (dist-app/), pas les sources : esbuild — donc vitest — ne sait pas produire emitDecoratorMetadata, dont l'injection de dépendances de Nest a besoin. npm run server:test construit l'adaptateur avant de lancer vitest.
  • Deux choses qu'un vrai client doit faire, à reprendre telles quelles en tranche 3 (elles sont dans test/bot-client.ts) : vider le tampon d'émission de Socket.IO à la déconnexion — sinon une intention numérotée avant la coupure repart avec un seq périmé — et se reconnecter à la main quand c'est le serveur qui a raccroché, ce que Socket.IO ne fait pas tout seul et qui est exactement l'allure d'un déploiement propre sur le VPS.
  • Variables d'environnement : PORT, LGC_DATA_DIR, LGC_JOURNAL_DIR, LGC_ALLOWED_ORIGINS, LGC_PUBLIC_URL. Toutes ont un défaut utilisable en développement.

Choix d'exécution tranche 3 (2026-08-06) ​

  • Périmètre : la boucle complète (choix de Guillaume) — accueil, création de salon, /j/<code>, choix pseudo + classe + deck, attente, mulligan, plateau, écran de fin, reprise après coupure. Plus rien n'empêche de jouer une partie.
  • Trois décisions prises avant d'écrire : le fil remonte dans @lgc/server (D-85) ; le catalogue est empaqueté au build côté client, pas téléchargé (le rendu de carte oblige déjà à embarquer data/schema/ et data/art/ — une route de catalogue ferait deux politiques de chargement dans la même application) ; @lgc/card-render est un paquet source consommé par alias des deux côtés (D-86).
  • Une décision prise en route, et c'est la plus importante : D-87. reduce accepte un sort ciblé joué sans cible (D-41 : l'effet est perdu, l'intention n'est pas refusée). Un client qui déduit « il faut cibler » d'un refus n'en obtiendra donc jamais — il enverrait tous les sorts ciblés dans le vide, sans erreur, sans trace. Le moteur répond maintenant à la question (playChoiceSlots), et un test l'épingle.
  • Les affordances sont des reduce() spéculatifs sur la seule vue filtrée (D-58.1) : cartes jouables, cibles légales, attaques possibles. Optimisation qui compte : on n'énumère les cibles que si la carte en demande — sinon c'est une vingtaine de reduce() par carte de main, à chaque changement d'état, pour rien.
  • Le plateau n'utilise pas CardRender : sur le terrain, ce qui compte est l'état (stats effectives, mots-clés reçus d'aura, attaque dépensée), pas la carte. Tuiles compactes, gabarit complet à la loupe au survol. CardRender sert la main et le mulligan (D-58.3 tient : un seul rendu de carte, et le terrain n'en rend pas).
  • Deux manques de lisibilité corrigés après le premier essai de Guillaume (2026-08-06), tous deux dans le périmètre de la tranche — c'est le plateau qui était illisible, pas une fonctionnalité en plus :
    • Les capacités s'affichent sur la tuile, avec le même engraissement des mots-clés que la carte (boldRulesHtml, remonté dans card-render/src/cards.js — deux implémentations divergeraient au premier mot-clé ajouté). Le texte est amputé des phrases qui ne disent qu'un mot-clé (abilityTextOf) : les pastilles les portent déjà, et en mieux, puisqu'elles sont effectives et incluent ce qu'une aura vient d'accorder. Reste ce qui manquait : « Cri de guerre : … », « Aura : … », « Rituel : … ».
    • Le malaise d'invocation est visible, et c'est le MOTEUR qui l'explique. Quand une de mes créatures ne peut attaquer personne, le client redemande reduce(ATTACK → héros adverse) et affiche la phrase du refus telle quelle : « Malaise d'invocation : pas d'attaque le tour d'arrivée. », « Attaque déjà dépensée ce tour. », « Fougue : peut attaquer une créature, pas le héros. » Aucune règle n'est écrite dans le client (D-58.1) et l'explication suivra le moteur si la règle change. La sonde vise le héros parce que c'est l'attaque la plus permissive : le refus qui en revient est le vrai motif, pas un effet de cible.
    • Ne pas réabréger ce motif : « Fougue : peut attaquer une créature, pas le héros » réduit à « fougue » se retrouvait juste sous la pastille FOUGUE, où il ne disait plus rien. On retire seulement la citation de règle finale — le « (D-07) » s'adresse au développeur et reste dans l'infobulle.
  • Le chronomètre de tour est un INDICATEUR, pas une horloge de référence : le serveur ne pousse pas ses échéances (D-81), donc le compte à rebours repart du numéro de tour reçu et dérive de la latence. Ne pas « corriger » cette dérive — l'autorité est au serveur, qui injecte END_TURN quand il le décide.
  • Les deux comportements de connexion de la tranche 2 sont repris tels quels (vider sendBuffer à la coupure, se reconnecter à la main quand le serveur raccroche), plus un troisième trouvé ici : un serveur arrêté ne raccroche pas, il ne répond pas — la tentative échoue en connect_error, pas en disconnect. Sans relance sur cet événement, un client qui rate sa première reprise pendant un redéploiement reste hors ligne jusqu'à l'abandon d'office.
  • Les decks fournis vivent dans client/src/game/decks.ts, pas dans data/ : le corpus est en brouillon (D-59) et le deckbuilding est P4. Le risque (une liste qui rote quand une carte est renumérotée) est couvert par un test qui passe chaque préréglage à validateDeck avec le vrai catalogue.
  • Le contrôle de types vise les paquets CONSTRUITS (engine/dist, server/dist) là où Vite aliase les sources — sinon les règles de lint du client remontent dans engine/src/, où elles n'ont rien à dire. D'où : client:check construit d'abord ses voisins.
  • Le banc e2e a été instable une heure, et la leçon vaut d'être écrite : pendant la coupure provoquée, le navigateur journalise ERR_CONNECTION_REFUSED à chaque tentative de reconnexion. Une assertion « aucune erreur de console » les comptait comme des fautes — alors qu'elles sont la preuve que le client réessaie. Le banc sépare donc deux paniers : une exception non rattrapée est toujours un échec, une erreur de console est filtrée des seuls motifs réseau attendus pendant l'outage. Vérifié 5 fois de suite au vert.
  • Un bug préexistant corrigé en passant : le build du site échouait sur un lien mort (docs/05-charte-illustrations.md → ../data/art/README.md, valide sur GitHub, hors du site car data/** est srcExclude). ignoreDeadLinks le déclare.
  • Une bascule de la tranche 2 corrigée en passant : test/e2e.test.ts supposait que P1 commence toujours. Le premier joueur est tiré au sort (D-42), donc « l'autre siège » n'est pas toujours b — le test échouait une fois sur deux ou trois, avant la tranche 3. Vérifié 6 fois au vert après correction.
  • Playwright a besoin d'un navigateur : npx playwright install chromium une fois sur le Mac. LGC_CHROMIUM=<chemin> permet d'en imposer un déjà présent (conteneur) ; vide sur une machine de développement.

P4 — les animations du plateau — cadrage acté (tranche 0, 2026-08-08) ​

Ce que ça répare : le plateau ne disait que l'ÉTAT. Après un échange, deux nombres avaient changé et une tuile avait disparu ; les toasts (D-92) ont mis un présent en mots, sans jamais montrer où il se passait. D-92 écartait explicitement les animations en les qualifiant de « vrai remède, mais une tranche à lui seul ». C'est cette tranche.

Périmètre retenu (choix de Guillaume) : la pioche, l'arrivée sur le terrain, la ruée d'attaque — les trois demandées — plus la mort d'une créature, les actions de l'adversaire (sa pioche, sa carte qui quitte sa main, son sort révélé au centre), les chiffres flottants, le bandeau de tour et le coup au héros.

La mort n'est pas un supplément, c'est un prérequis technique de la ruée. Le serveur envoie l'attaque, ses blessures et la mort dans le même lot, avec la vue déjà à jour : quand le client reçoit ATTACK_DECLARED, la cible a déjà quitté le terrain. Sans fantôme de mort, la créature se jetterait sur du vide. C'est de là que vient D-95.

Tranches ​

TrancheContenuCritère de fin
0Le socle : la partition (game/fx.ts, pure), le registre d'ancres (game/anchors.ts), la couche (FxLayer.vue), le banc d'essai /fx, reducedMotion dans PlaywrightLe banc joue les neuf effets ; client:check vert ; client:e2e inchangé (il ne voit encore rien)
1Mes gestes : pioche, arrivée sur le terrain, ruée, mortUne partie à deux navigateurs, les quatre effets lisibles, client:e2e vert
2L'adversaire : sa pioche, sa carte qui quitte sa main, son sort révélé au centreUn joueur qui n'a pas quitté le plateau des yeux sait ce que l'autre a joué
3L'impact et le tour : chiffres flottants, sursaut de la gemme recalé sur le contact, bandeau de tour, liseré au hérosSur une rediffusion d'écran, on suit un échange sans lire un seul toast
4Le réglage : durées ajustées en jeu, audit du mode réduit effet par effet, CLAUDE.md réécritclient:check et client:e2e verts, les pièges nouveaux écrits

TERMINÉ le 2026-08-08 — les cinq tranches livrées, client:check et client:e2e verts.

Choix d'exécution tranche 0 (2026-08-08) ​

  • La partition est une fonction pure, sans DOM et sans horloge : partition(events, seat) → Effect[], chaque effet portant son type, ses ancres symboliques, sa durée et son instant de départ. Testable en vitest exactement comme affordances.ts et gesture.ts — c'est la même discipline, et pour la même raison : ce qui décide se teste sans navigateur.
  • La chorégraphie est faite par le navigateur, pas par des minuteries. Chaque effet est un élément portant animation-delay: <at>ms et animation-fill-mode: both ; le décalage de 120 ms est donc un attribut, pas un setTimeout. Une seule minuterie par lot, pour vider la couche à la fin. C'est aussi ce qui rend prefers-reduced-motion gratuit : une requête média suffit à tout couper (D-96).
  • Les ancres sont symboliques dans la partition, résolues à l'affichage : { at: "entity", entry }, { at: "deck", player }, { at: "centre" }… La fonction pure ne connaît aucun pixel, et le registre (D-95) est le seul endroit qui lit le DOM. Une variante { at: "point", x, y } permet à un geste de fournir son propre point de départ — c'est ce qui fera partir l'arrivée d'une créature de là où le doigt a lâché la carte, et non d'un emplacement théorique.
  • Le regroupement « une attaque et ses blessures » est écrit deux fois — dans EventToasts.vue pour faire une phrase, dans fx.ts pour faire une chronologie. C'est la même règle, et deux copies finissent toujours par diverger : la convergence est planifiée en tranche 3, quand EventToasts sera de toute façon rouvert pour recaler le sursaut de la gemme. Écrit ici pour que ce soit une dette datée et non un oubli.
  • La main de départ n'est pas une pioche. Elle vient de la mise en place, pas d'un CARD_DRAWN : c'est le serveur qui la distribue avant le premier tour. La distribution une par une (80 ms d'écart, choix de Guillaume) est donc une partition à part, openingDeal(taille, siège), déclenchée à l'arrivée de la première vue — explicitement, plutôt que cachée dans partition.
  • Deux pioches consécutives du même joueur se resserrent à 80 ms au lieu des 120 ms du décalage général : un sort qui fait piocher trois cartes doit se lire comme une distribution, pas comme trois actions distinctes.
  • Le banc d'essai /fx est une route du client, pas une page à part : il partage la scène, le registre d'ancres et la couche, donc ce qu'on y règle est bien ce qu'on verra en partie. Il pose ses propres éléments data-anchor — c'est ce qui permet de le construire avant que les vrais composants les portent.
  • Un seul module existant est touché, et c'est un ajout : game/names.ts expose désormais cardFor (la carte, avec le même repli de version que le nom), parce que le sort révélé a besoin du VISAGE et pas seulement du libellé. cardName en devient une ligne.
  • Aucun composant existant n'est touché en tranche 0. C'est ce qui rend le critère de fin honnête : client:e2e ne peut pas régresser puisqu'il ne voit rien de neuf.
  • Aucune dépendance ajoutée — pas de bibliothèque d'animation. Le besoin est de translater des rectangles sur des durées connues ; c'est ce que le CSS fait le mieux, et une bibliothèque ajouterait un ordonnanceur en JavaScript là où le compositeur du navigateur suffit.

Choix d'exécution tranche 1 (2026-08-08) ​

  • Les ancres réutilisent ce qui existait déjà. Les tuiles et les permanents portent data-entry, les héros data-target-ref — deux attributs posés pour d'autres raisons, relus tels quels. Seuls les piles et l'éventail reçoivent un data-anchor, par une prop anchor que le plateau compose : PileStack et HandFan ne connaissent pas les sièges, et n'ont aucune raison de les apprendre pour placer une animation.
  • Une seule traversée du DOM par relevé, et une seule mesure par élément : le sélecteur combine les trois attributs et répartit les clés. Trois requêtes séparées reliraient la même tuile trois fois.
  • La ruée redémarre par une CLÉ, et c'est indispensable. Une animation CSS déjà jouée ne repart pas sur le même nœud : sans clé, deux attaques de suite avec la même créature ne se verraient qu'une fois. Le numéro de lot fait la clé, et .stack est recréé — jamais .tile, dont le rectangle doit rester stable (D-94).
  • Le point de dépôt est retenu au moment du commit : apply() garde gesture.at quand le geste part de la main. C'est ce qui fait partir l'arrivée d'une créature de là où le doigt l'a lâchée. Le même champ ne sert jamais pour l'adversaire : sa carte part de son éventail.
  • Le garde-fou de la main de départ est le NUMÉRO DE TOUR, et non un journal vide. Le plateau est monté après le mulligan, donc après les premiers événements du tour 1 : un journal vide n'arrive jamais là. Attendre celui-là aurait supprimé la distribution sans que rien ne le signale — le genre de panne muette qu'on ne trouve qu'en la cherchant.
  • Deux relevés d'ancres par lot, l'un par le plateau (pour la ruée, qui vit sur la tuile), l'autre par la couche. C'est deux fois le même travail, et c'est voulu : le relevé est idempotent, chacun des deux reste juste tout seul, et l'alternative — s'appuyer sur l'ordre d'exécution de deux observateurs post — serait un accord tacite entre un parent et son enfant. Le coût est une lecture de mise en page par poussée du serveur.
  • Trois corrections après le premier essai de Guillaume (2026-08-08), toutes dans le périmètre de la tranche — c'est le geste qui était mal raconté, pas une fonctionnalité qui manquait :
    • La carte jouée se volatilisait. Relâcher rendait la carte à l'éventail (clearGesture coupe le suivi du pointeur), puis l'ACK la faisait disparaître, pendant qu'un fantôme partait du point de dépôt : trois mouvements pour un seul geste. Elle reste désormais figée là où le doigt l'a laissée jusqu'à la réponse du serveur. Seulement si elle SUIVAIT le pointeur : un clic ne l'a jamais déplacée, et un sort ciblé glissé sur sa victime est resté dans la main — les figer téléporterait la carte là où elle n'a jamais été. Et la libération est doublée (réponse du serveur et arrivée du lot) parce que les index de la main se décalent : une carte figée sur un index périmé, c'est la voisine qu'on verrait collée au point de dépôt.
    • Ma carte s'en va face visible, à la taille de la main, et rétrécit vers le terrain. Un dos plus petit partant du même point cassait la continuité : on voyait la grande carte disparaître, puis autre chose s'en aller. L'œil ne suit que ce qu'il n'a pas perdu de vue. La sienne reste un dos — il n'y a rien à lire sur une carte qui part.
    • Le dégradé du bas des toasts ne s'allume que s'il y a un débord. Appliqué en permanence sur une colonne de 128 px, il estompait le dernier toast même quand tout tenait : on lisait un défaut d'affichage là où il n'y avait rien à cacher. Mesuré après rendu, pas déduit d'un compte de toasts — qui serait faux dès qu'une phrase passe sur deux lignes.
  • La bande de désignation remonte de 262 à 296 px. Une carte levée monte de 46 px et grandit de 4 % : son bord haut arrivait à 259 px du bas de la scène, soit trois pixels sous la bande, qui paraissait posée dessus. Le survol ne compte pas dans ce calcul — il est coupé pendant une désignation.
  • La ruée est peu lisible quand les deux créatures sont face à face (trajet court, donc élan court). Constaté, laissé tel quel : allonger l'élan ferait sortir l'attaquante de sa ligne, ce qui coûterait plus en lisibilité que ça ne rapporte.
  • Une chose à juger en jeu, pas au banc : la créature adverse apparaît AVANT que sa carte n'ait fini de voler — l'état n'attend pas (D-93). C'est la contrepartie assumée de l'enchaînement court. Si ça gêne, le remède est un fondu d'arrivée en opacité seule (la géométrie doit rester stable), pas un retard de l'état.

Choix d'exécution tranche 2 (2026-08-08) ​

Les trois effets prévus étaient déjà produits par partition depuis la tranche 0 : la pioche adverse, sa carte qui quitte sa main, son sort révélé. La tranche a donc consisté à les regarder — et elle a trouvé un bug de correction et deux incohérences.

  • Un permanent n'émet AUCUN SUMMONED. Le moteur le pousse dans state.permanents sans l'annoncer (reduce.ts) ; seule placeCreature émet l'événement. La partition, qui déduisait « pas d'invocation, donc un sort », envoyait donc le permanent au cimetière après l'avoir révélé au centre — alors qu'il venait de se poser sur le plateau. Corrigé en lisant le TYPE plutôt qu'en déduisant d'une absence, avec une ancre nouvelle : permanents:<joueur>, la rangée, puisqu'aucun événement ne dit quelle case il occupe. Ce n'était pas visible avant la tranche 2 : le préréglage « Motion d'urgence » ne pose son permanent qu'en milieu de partie.
  • Seul un SORT se lit au centre. La règle d'avant était « tout ce qui n'invoque pas », ce qui attrapait les permanents. Une créature et un permanent se posent sur le plateau, où on peut les lire à loisir : les arrêter au milieu de l'écran ferait payer une seconde pour une information déjà acquise. Le centre est réservé à ce qui ne se voit nulle part ailleurs.
  • Une carte jouée s'en va face visible des DEUX côtés. Elle finit sur le terrain ou au cimetière : elle est publique. La règle d'avant — ma face, son dos — n'avait aucun fondement, et laissait un dos voler vers une tuile dont on lisait déjà le nom. Seule la pioche reste un dos : c'est la seule qui entre dans une zone cachée (D-66).
  • Le pouvoir héroïque était invisible. C'est la seule action qui ne passe par aucune carte : on n'en lisait que les conséquences, sans savoir d'où elles venaient. Un halo sur le médaillon (ancre power:<joueur>, posée sur le bouton qui existait déjà), des deux côtés — chez moi c'est une confirmation, en face c'est une information.
  • Une créature dont l'invocation se perd (terrain plein, D-33) part au cimetière face visible, et ne se révèle pas : révéler ce qui n'est jamais arrivé en jeu appuierait sur un raté plutôt que de le raconter.
  • Le banc /fx couvre les trois nouveaux cas ; fx.test.ts passe à 21 scénarios, dont un qui épingle le permanent des deux côtés de la table — c'est le genre de bug qui revient tout seul si personne ne le tient.

Choix d'exécution tranche 3 (2026-08-08) ​

Comme en tranche 2, les effets prévus existaient déjà — chiffres flottants, bandeau de tour et liseré au héros sortent de partition depuis la tranche 0. Ce qui restait, c'était le calage, et c'est là que se trouvait tout l'intérêt de la tranche.

  • Le sursaut de la gemme ne vient plus d'un observateur sur damage. Il partait à l'arrivée du lot — donc AVANT que l'attaquante ne se soit élancée, et de plusieurs centaines de millisecondes quand l'attaque n'était pas la première action du lot. Il porte maintenant le retard du CHIFFRE, c'est-à-dire celui du contact. C'est la synchronisation qui fait le poids d'un coup, pas l'animation : la même image cent trente millisecondes trop tôt ne raconte plus rien.
    • Conséquence assumée : au-delà de huit événements d'un coup, la partition se tait (D-92, c'est une reprise) — donc la gemme aussi. C'est cohérent, on ne rejoue pas une partie en accéléré. L'observateur, lui, aurait sursauté quarante fois.
    • FX.strike (700 ms) doit rester égal à la durée de .gem.struck dans EntityTile. Deux endroits, faute de pouvoir passer une durée à une feuille de style ; c'est écrit des deux côtés.
  • Deux chiffres qui montent du même endroit se séparent de 110 ms. Superposés — un sort qui frappe deux fois, une attaque doublée d'un déclenchement — ils s'additionnent en un trait illisible, et le joueur croit avoir pris 4 au lieu de 2 et 2. Un simple retard suffit : chacun monte et s'efface, on les compte.
  • Le chiffre part du haut de ce qu'il concerne, pas de son milieu : posé au centre d'une tuile, il couvrait l'illustration et le nom pendant qu'on cherchait justement à savoir qui venait d'être touché.
  • La dette de la tranche 0 est payée : game/beats.ts. Le regroupement « une attaque et ce qu'elle entraîne » était écrit deux fois — dans EventToasts pour faire une phrase, dans fx.ts pour faire une chronologie. Une seule règle désormais, testée une seule fois, et elle s'arrête à la prochaine action (attaque, carte jouée, pouvoir) et non au prochain type : ce n'est pas « jusqu'à la prochaine attaque », c'est « tant que c'est encore ce coup-ci ». Deux copies auraient divergé en silence — un récit qui se décale n'échoue pas, il ment.
  • Le mouvement réduit couvre aussi la gemme (D-96) : le coup se marque par un halo qui enfle, sans changement d'échelle. Une gemme devenue muette aurait rendu silencieux ce que la tranche vient réparer.

Budget de durées (valeurs de départ, réglées en tranche 4) ​

EffetDéclencheurDuréeDétail
PiocheCARD_DRAWN320 msvol en dos, retournement à l'atterrissage si elle est à moi
Départ de mainCARD_PLAYED240 msla carte quitte l'éventail face visible, vers sa destination
Pouvoir héroïqueHERO_POWER_USED420 msun halo sur le médaillon — la seule action qui ne passe par aucune carte
Sort révéléCARD_PLAYED (sort adverse)840 ms180 entrée · 420 pose · 240 sortie vers le cimetière
ArrivéeSUMMONED260 ms1,35 → 1,00, dépassement à 0,97 ; anneau de poussière sur 300 ms
RuéeATTACK_DECLARED270 msélan 90 ms · contact 40 ms · retour 140 ms — le contact tombe à t+130 ms
MortDIED300 msternissement, affaissement, glisse de 40 px vers le cimetière
Chiffre flottantDAMAGE_TAKEN · HEALED700 msmontée de 34 px depuis le haut de la cible ; 110 ms d'écart entre deux au même endroit
Sursaut de la gemmele CHIFFRE, pas l'événement700 msFX.strike, à garder égal à .gem.struck dans EntityTile
Bandeau de tourTURN_STARTED700 msbalayage de la médiane
Coup au hérosDAMAGE_TAKEN sur mon siège260 msliseré au bord de la scène, jamais de secousse

Décalage entre deux effets d'un lot : 120 ms, ramené à 40 ms si la partition dépasse 1,5 s. Au-delà de huit événements d'un coup : silence (D-92, D-78).

Pas de secousse d'écran, et ce n'est pas une question de goût : la scène est le repère de toute la géométrie du pointeur (D-88), la déplacer ferait rater les cibles pendant l'effet — et rendrait la scène instable pour le banc e2e, exactement comme une tuile animée à sa racine (D-94).

Choix d'exécution tranche 4 (2026-08-08) ​

  • L'audit du mouvement réduit a trouvé un trou, et il était gros. D-96 dit « l'unique interrupteur » ; il ne couvrait que la couche d'effets. Or le plus grand mouvement de l'écran n'est pas un effet : c'est la carte de la main qui bondit de 118 px et grandit de moitié au survol — une transition, qu'aucune requête média ne coupe d'elle-même. La règle est donc remontée dans styles.css et vaut pour toute l'application, avec deux exceptions ré-autorisées juste après (la couche et le sursaut de la gemme) : réduit ne veut pas dire absent. animation-iteration-count: 1 éteint au passage les pulsations perpétuelles — le halo « peut attaquer », l'anneau de bouclier, le dodelinement du « zzz » — qui sont du mouvement permanent, c'est-à-dire exactement ce qu'on vient couper. Effet de bord bienvenu : le banc e2e y gagne en stabilité, reducedMotion: "reduce" figeant désormais aussi les transitions.
  • Le banc /fx est conservé. Chargé à la demande, il ne coûte rien tant qu'on n'y va pas, et il sert sur le déploiement : c'est là qu'on règle une durée sur la vraie machine du joueur. Le supprimer aurait rendu le prochain réglage plus cher que cette tranche entière.
  • Les durées ne sont pas retouchées, et c'est une décision. Elles se règlent en JOUANT, pas en relisant du CSS. Elles vivent toutes dans FX (game/fx.ts), en un seul endroit, et le banc les rejoue à la demande. Ce que la tranche livre, c'est la possibilité de les régler — pas des valeurs devinées par quelqu'un qui n'a pas la partie sous les yeux.
  • CLAUDE.md réécrit, pas allongé (24,3 → 24,9 Ko, toujours sous le seuil). Trois pièges d'animation ajoutés ; deux fusionnés dans des entrées existantes plutôt qu'empilés à côté, parce qu'ils ont la même cause — « rien n'anime .tile à sa racine » appartient au piège de l'animation qui l'emporte, « on réduit au départ, jamais on n'agrandit à l'arrivée » à celui de la rastérisation du zoom. En face, de l'histoire est descendue ici : les trois interdits d'export du gabarit n'en font plus qu'un (même cause), le récit de la main est resserré à son symptôme, Three.js réduit à sa conclusion.
  • Un piège volontairement laissé au journal : la carte figée à son point de dépôt. Son oubli se voit au premier geste — un piège qui s'auto-signale n'a pas besoin de la place la plus chère du dépôt. Ceux qui montent dans CLAUDE.md sont ceux qui échouent en silence.

Post-mortem de la phase ​

  • Ce que le cadrage avait vu juste : les trois pièces de la tranche 0 — la partition pure, le registre qui fusionne, la couche non cliquable — n'ont pas été retouchées ensuite. Les quatre tranches suivantes ont ajouté des effets, jamais corrigé le socle. C'est le meilleur signe qu'on puisse attendre d'un découpage.
  • Ce qu'il n'avait pas vu : que les tranches 2 et 3 seraient surtout des tranches d'inspection. Les effets sortaient déjà de partition ; ce qui restait était le calage et les bugs que seul un regard trouve. Leçon transposable : quand un socle produit plus qu'on n'en branche, découper par « effet » surestime le reste à FAIRE et sous-estime le reste à VOIR.
  • Les deux vrais bugs n'étaient pas des bugs d'animation. Le permanent qui partait au cimetière venait d'une déduction (« pas d'invocation, donc un sort ») là où il fallait lire une donnée ; et crea_sento, renommé dans le corpus sans mise à jour de decks.ts, a été trouvé par un test écrit en tranche 3 de P3 pour exactement ce cas. Les deux étaient muets : rien ne plantait, ça racontait faux.
  • Les trois retours de Guillaume après le premier essai portaient tous sur la continuité du regard — une carte qui se volatilise, un dégradé permanent, une bande trop basse — aucun sur la mécanique. Un plateau se juge à ce que l'œil peut suivre sans effort, pas à la liste des effets qu'on y a mis.
  • Ce qui reste à juger en jouant, et n'a pas été tranché à sa place : la créature adverse apparaît AVANT que sa carte n'ait fini de voler (l'état n'attend pas, D-93). Remède connu si ça gêne — un fondu d'arrivée en opacité seule, jamais un retard de l'état.

Reporté, et noté pour ne pas le rechercher ​

Presque gratuit une fois le socle posé — c'est un choix de périmètre, pas de difficulté : bouclier brisé (SHIELD_BROKEN, l'anneau qui éclate) · renforcement (BUFFED, la gemme qui pulse en vert — on voit ce qu'on perd, jamais ce qu'on gagne) · le cristal de Ferveur qui se déverrouille · la carte qui se consume à la fatigue · le voile de Provocation à l'arrivée · le fondu de la furtivité qui tombe · le mulligan · le médaillon vaincu qui s'effondre.


P4 — l'ambiance sonore — livrée en une passe (2026-08-10) ​

Décisions : D-107, D-108. Plan de cadrage : docs/plan-sons.md. Verts à la livraison : client 157 tests (120 avant) et client:e2e — le critère de fin de P3, deux navigateurs et serveur tué en route, plus trois épreuves neuves sur le réglage du son.

Aucune dépendance ajoutée : Web Audio est dans le navigateur.

Ce qui a décidé de tout le reste ​

partition() produisait déjà des effets datés en millisecondes. Le son n'est donc pas un nouveau système : c'est une seconde lecture de la même partition. La conséquence pratique est que le branchement tient en une ligne dans playFx — et que trois comportements qu'on aurait dû écrire, tester et maintenir sont tombés gratuitement (silence des reprises, resserrement au-delà du budget, timbre déduit de la destination). Le détail est en D-107.

Corollaire de méthode, transposable : quand un module pur produit des dates, tout ce qui doit arriver « au même moment » doit en dériver, jamais recalculer. Deux calendriers ne restent jamais d'accord.

Ce que les tests ont trouvé, et que la relecture n'aurait pas vu ​

Deux fautes, toutes deux dans les règles de foule, toutes deux muettes :

  1. La fenêtre de dédoublonnage doit AVANCER. Trois pioches tombent à 0, 80 et 160 ms (FX.deal = 80). Mesurée contre le dernier son gardé, la fenêtre de 90 ms laissait passer la troisième — 160 est hors de la fenêtre de 0. Il faut la faire glisser à chaque tentative, gardée ou non, pour recoller une suite. Symptôme : une distribution qui sonne deux fois, ce que personne n'aurait su nommer à l'oreille.
  2. Un multiplicateur local ne se borne pas à 1. Le renfort du coup qui en cachait d'autres (+15 %) était annulé par un Math.min(1, …) : le gain de base valait déjà 1, puisque c'est le gain de TABLE qui est sous l'unité. Un plafond posé au mauvais étage — et invisible, puisque le son sortait quand même.

Ce que la capture a trouvé, et que le code ne disait pas ​

  • 🔊 sort en emoji COULEUR, donc bleu, dans une interface d'or et de parchemin. C'est le piège n° 6 du dépôt pris par l'autre bout : là où ⚔ ☠ 🛡 manquent d'un sélecteur de variante pour devenir des emojis, 🔊 en manquerait d'un pour redevenir du texte. Remède : SoundIcon.vue, un tracé qui hérite de currentColor — comme l'anneau de TurnClock et la flèche d'attaque.
  • La barre de coupure ne doit pas traverser l'icône. Une diagonale de coin à coin passe sur le pavillon, de la même couleur que lui, et les deux formes fusionnent en une tache illisible à 15 px. La croix prend la place des ondes : c'est justement ce qui manque quand c'est coupé.
  • La barre du haut a grandi de 20 px de chaque côté. .brand et .tools doivent garder la MÊME largeur : c'est elle qui centre le chronomètre, le space-between ne répartissant que le reste. Changer une seule des deux aurait décalé l'horloge sans que rien ne le signale.
  • .centered-page > * impose width: min(46rem, 100%). Le réglage flottant de l'accueil devenait une boîte fixe invisible de 46 rem qui interceptait les clics de tout le haut de la page. width: max-content sur la variante flottante.

Ce qui a été gardé comme épreuve, et pourquoi ​

client/e2e/sound.spec.ts n'est pas un banc jetable. Il garde deux choses qu'aucune relecture ne verrait : que Échap reste au geste en cours (le plateau l'écoute sur window ; le panneau ne s'en sort qu'en prenant le focus, sans quoi son .stop ne sert à rien), et que le panneau ne sort pas de la scène — relevé de géométrie, pas jugement à l'œil, puisque la scène est en overflow: clip et mise à l'échelle par zoom.

client/test/audio.test.ts éprouve le lecteur sans navigateur : happy-dom n'a pas de Web Audio et met navigator.webdriver à vrai, ce qui suffirait à réduire tout le module au silence. Le banc lève les deux obstacles avant l'import et pose un AudioContext de contrefaçon. Ce qu'il cherche est précis : aucune rampe exponentielle vers zéro. exponentialRampToValueAtTime(0) lève dans un vrai Web Audio, depuis un gestionnaire d'événement, sur un son particulier — au pire endroit possible, et jamais sur celui qu'on essaie.

Le tenant-lieu synthétisé, et ce qu'il a permis ​

Les 25 timbres de tones.ts ne visent pas la beauté : ils visent d'être distinguables. C'est ce qui a permis de vérifier à l'oreille, sans aucun fichier, que le bon cue part au bon instant — qu'un unique bip répété n'aurait pas prouvé. Ils s'effacent d'eux-mêmes : un mp3 déposé prend le dessus, et le banc /fx affiche en italique ce qui est encore synthétisé.

tools/audio.mjs lit la liste des sons dans client/src/game/sound.ts, par expression régulière, et échoue bruyamment en dessous de 20 identifiants trouvés. C'est assumé : une copie de la liste dans tools/ aurait divergé au premier ajout, et un nom mal orthographié serait passé sans bruit — le pire des deux maux. La liste ne descend pas dans data/ : D-20 interdit d'y faire dépendre la donnée d'un choix de rendu, et un gain n'est pas de la donnée de jeu.

Le dégraissage de CLAUDE.md, et le critère qui l'a guidé ​

Le fichier était pile au seuil (25,0 Ko) avant cette passe ; un sous-système entier l'a poussé à 26,8. Dégraissé dans la foulée à 26,2 Ko — toujours au-dessus du seuil, et c'est assumé : ce qui reste est vrai aujourd'hui, et le seuil avertit sans bloquer.

Le critère appliqué, et il se réutilise : quand le POURQUOI d'un piège est déjà écrit en commentaire à l'endroit exact où il faut lui obéir, CLAUDE.md ne garde que la RÈGLE et le SYMPTÔME TROMPEUR. Le garde-fou reste entier — la règle suffit à arrêter quelqu'un, le symptôme suffit à reconnaître la panne — et le raisonnement se lit là où l'on va de toute façon regarder avant de toucher au code. Cinq pièges y sont passés : les trois interdits d'export (CardRender.vue), zoom et overflow: clip (DuelStage.vue), la plaque de clic de la main (HandFan.vue), l'animation qui l'emporte sur une propriété statique (EntityTile.vue).

C'est le pendant du critère déjà en vigueur ici — « ceux qui montent dans CLAUDE.md sont ceux qui échouent en silence ». Le premier trie ce qui mérite la place la plus chère du dépôt ; le second décide combien de cette place chacun mérite.

Ce qui n'a PAS été coupé, et pourquoi : les corollaires qui ne vivent nulle part ailleurs — « on réduit une carte au départ, jamais on ne l'agrandit à l'arrivée », « aucune secousse d'écran », « rien n'anime .tile à sa racine ». Ceux-là n'ont pas de point d'obéissance unique : ils s'appliquent à du code qui n'est pas encore écrit.

Reste à faire, une fois les fichiers en place ​

  1. La passe de mixage au banc /fx : un curseur par son, puis « Copier la table » et recoller dans game/sound.ts. Elle ne peut pas se faire avant : on ne règle pas le niveau d'un son qu'on n'a pas.
  2. Les bornes de boucle loopStart / loopEnd de MUSIC, à régler au banc — l'encodeur mp3 ajoute du silence en tête et en queue, et Web Audio boucle à l'échantillon près : ce silence s'entend comme un trou à chaque tour. De l'ordre de 25 ms en tête.
  3. Juger en jouant ce qui ne se juge pas autrement : le clic de saisie de carte (card-pick) est-il trop présent sur une partie entière ? L'alerte des 10 secondes (clock-warning) est-elle utile ou anxiogène ?

P4 — les sons eux-mêmes, forgés (2026-08-12) ​

Décision : D-109. Dix-neuf effets sur vingt-cinq sont désormais de vrais fichiers, synthétisés par tools/audio/synth.py. Le jeu sonne pour de bon. Verts : client 157 tests, client:e2e (5 épreuves), site construit.

Ce que la lecture des formes d'onde a trouvé, et l'oreille n'aurait pas nommé ​

Trois fautes, toutes muettes au sens propre : le son sortait, il était simplement faux.

  1. attack-melee ouvrait sur 75 ms de sifflement de lame. Joli isolément — et catastrophique ici : le cue est calé sur FX.contact, donc l'impact tombait 75 ms APRÈS le coup, le chiffre et le sursaut de la gemme. Toute la synchronisation que D-107 rend gratuite était perdue par le fichier lui-même. Règle qui en sort, et qui vaut pour tout son d'impact : le fichier commence à l'impact. Il reste 18 ms de sifflement, en mise en bouche.
  2. card-play-creature était centré à 137 Hz — un « boum » qui avalait la carte. Le corps est remonté au-dessus de 85 Hz (en dessous, un haut-parleur de portable ne rend rien) et raccourci ; claquement et bois reprennent le dessus. Centroïde 137 → 236 Hz.
  3. loudnorm était le mauvais outil pour des one-shots. Il vise une loudness intégrée et y parvient par un gain qui varie dans le temps : il rabote l'attaque. Mesuré : −0,9 à −3,2 dB selon le son, le plus grave étant le plus écrasé — l'inverse exact de l'intention. tools/audio.mjs normalise maintenant les effets en crête (deux passes, volumedetect puis volume), et garde loudnorm pour la musique, qui est son terrain. Le mixage appartient à la table SFX (D-107) : normaliser en loudness, c'était mixer deux fois, dont une en aveugle.

La méthode se réutilise : on ne juge pas un son de synthèse à la description qu'on en a écrite. On trace la forme d'onde et le spectrogramme, on lit le centroïde, l'instant de l'attaque pleine et le facteur de crête, et on compare à l'intention. Trois chiffres et deux images par son.

Ce qui n'a pas été forgé, et pourquoi c'est la moitié de la décision ​

Les six sons de matière — carte qui glisse, paquet qu'on bat, carte saisie, créature posée, permanent posé, sort révélé. La synthèse en donne une imitation crédible, jamais convaincante, et la figer aurait installé un à-peu-près là où dix secondes d'enregistrement font mieux. Ils restent joués par les timbres du navigateur, et le manque est écrit noir sur blanc au banc /fx comme sur la page « Sons ».

La page « Sons » du site ​

site/pages/sons.md + SoundsView.vue, sur le motif de la galerie. Elle ne stocke rien (D-35) : les mp3 sont ceux du client par import.meta.glob — comme card-render/src/art.js atteint data/art/ — et les libellés viennent du champ when ajouté à la table SFX, qui fait foi. Ce champ n'est pas de la décoration : le banc /fx l'affiche aussi en infobulle, et savoir ce qu'on règle est la moitié du travail de mixage.

Un détail d'usage réglé à la capture : la séquence « aux vraies durées » s'ouvrait sur deux secondes de silence, les deux premiers battements étant des sons de matière absents. L'origine glisse donc jusqu'au premier son disponible — les écarts restent exacts, et c'est tout ce qu'on vient écouter.

Une épreuve qui a dû changer avec le monde ​

client/e2e/sound.spec.ts affirmait « aucun mp3 n'est demandé ». Vrai à zéro fichier, faux le jour où les premiers sons sont arrivés. Réécrite sur l'invariant et non sur un décompte : tout mp3 demandé est annoncé par l'inventaire et répond 200. Elle vaut désormais à 0 comme à 25 fichiers. Leçon : une assertion qui compte l'état du dépôt à un instant donné est une assertion qui périme.