# 0007. Architecture et conventions de code - **Statut** : accepté - **Date** : 2026-11-02 - **Décideurs** : Pierre (stockage des parties, nommage, garde-fous), sur proposition de Claude Code ## Contexte Le code va être écrit par des agents et Pierre ne le relit pas en détail. Il doit donc rester simple, lisible par un humain, et protégé par des tests contre les dérives. Le cadrage impose déjà que le moteur soit caché derrière une interface, et demande l'annulation et les branches de partie (« Rejouer d'ici »). ## Décision **1. La simplicité d'abord.** Le code se lit sans connaître de patron de conception. Pas d'abstraction sans un deuxième usage réel, pas de framework sans besoin avéré, pas de couche « au cas où ». Dans le doute, la solution la plus directe. **2. Trois parties, une seule règle de dépendance.** - **Le domaine** : les règles de l'application (partie, match, branches, coach, analyse, parcours). Du code pur : ni écran, ni stockage, ni appel direct au moteur. Il décrit l'interface `Engine` dont il a besoin (coups légaux, classement, videau, évaluation). - **Le moteur** : gnubg en C derrière `engine/include/engine.h`, et un adaptateur par plateforme (WebAssembly dans un Web Worker, ou JNI sur Android) qui implémente l'interface `Engine` du domaine. - **L'application** : écrans, stockage, presse-papiers, assemblage. Les dépendances vont vers le domaine, jamais l'inverse. Aucun écran n'appelle le moteur directement. Ces règles sont vérifiées par des tests ; l'outil qui les vérifie sera choisi avec le socle (PWA ou Android) et signalé avec sa licence. **3. Une partie est un journal d'événements.** Une partie est enregistrée comme la suite de ce qui s'est passé (`GameStarted`, `DiceRolled`, `MovePlayed`, `CubeOffered`, `CubeTaken`, `CubeDropped`, `GameEnded`…), sans jamais modifier un événement passé. Chaque événement pointe vers le précédent : une branche n'est qu'un nouvel événement accroché à un ancien. L'état de la partie se recalcule en rejouant les événements ; annuler revient à repartir de l'événement précédent ; l'export SGF est un parcours de l'arbre. Chaque événement porte un numéro de version de format. Le reste de l'application (réglages, progression, leçons) est stocké simplement, sans journal. **4. Le hasard est injecté.** Le domaine reçoit sa source de dés : les tests sont reproductibles, et l'historique des dés d'une partie est consultable. **5. Nommage.** Tous les identifiants du code sont en anglais (fichiers, types, fonctions, variables, clés JSON). Commentaires, documentation et messages de commit sont en français. Les textes de l'interface sont en français, dans des fichiers de textes séparés du code. En conséquence, l'interface C prévue sous le nom `moteur.h` (ADR 0005) s'appelle `engine.h`, avec le préfixe `engine_`. **6. Le vocabulaire du jeu, pas la cérémonie.** On reprend du DDD le langage commun (un glossaire des termes du jeu, les mêmes dans le code, la doc et l'interface) et le découpage par sous-domaines en dossiers (`game`, `analysis`, `learning`, `prompts`). On n'en reprend pas les patrons tactiques (dépôts, fabriques, objets-valeurs systématiques) tant qu'un besoin concret ne les justifie pas. **7. Tests.** - Domaine : tests unitaires rapides, avec un faux moteur. - Moteur : une même suite de tests de contrat, rejouée sur chaque adaptateur (ligne de commande, WebAssembly, Android), contre les positions de référence de gnubg de bureau. - Bout en bout : quelques scénarios seulement. **8. Notre code C** (hors copie de gnubg) : C11, compilé avec `-Wall -Wextra -Werror`, sans état global ajouté par nous. ## Alternatives écartées - **Event sourcing pour toute l'application, avec projections (CQRS)** : très traçable, mais lourd pour une application hors ligne à un seul utilisateur. - **État classique pour les parties** (position courante et historique à côté) : plus simple au départ, mais les branches et l'annulation deviennent du code au cas par cas. - **Identifiants en français** : cohérents avec la doc, mais écart avec les conventions habituelles du code et des bibliothèques. - **Architecture hexagonale formelle, DDD complet** : beaucoup d'interfaces et de vocabulaire pour une application de cette taille. - **Règles d'architecture seulement documentées** : sans relecture humaine du code, rien ne garantirait qu'elles sont respectées. ## Conséquences - Le format des événements de partie est un contrat durable : le changer demande une migration et un ADR. - La structure des dossiers du domaine et l'outil de vérification des dépendances seront fixés avec le socle, à la fin de l'étape 0. - Les sous-agents reçoivent ces règles dans leurs consignes ; `CLAUDE.md` y renvoie. ## Amendements ### 2026-11-03 — Le stockage des parties est un format durable Avec les parties enregistrées (étape 1b), le schéma IndexedDB (base « ustagammon », version 1 : magasins `games`, `journals`, `settings`) et les clés `localStorage` de l'application (`ustagammon.settings`, `ustagammon.last-board`, et le repli des parties) rejoignent le journal d'événements parmi les formats durables : toute modification passe par une montée de version avec migration testée, jamais par une réécriture qui perdrait les parties des joueurs. ### 2026-11-09 — Version 2 des événements : niveau et match Avec le choix du niveau et les matchs (étape 1c), le format des événements passe à la version 2 (`EVENT_VERSION`, `app/src/domain/game/events.ts`) : - `GameStarted` porte le niveau de l'ordinateur (`level`, un niveau de gnubg) et, pour une partie de match, le match à son début (`match` : longueur, score, partie Crawford) ; - `GameEnded` porte, dans un match, le match après la partie (score, fin du match, partie Crawford suivante), tel que le moteur l'a calculé. Chaque partie d'un match est une partie à part entière, avec son journal et son enregistrement ; la suivante reprend le match que la précédente a laissé. La migration (`migrateJournal`, testée dans `app/tests/game.test.ts`) passe un journal de la version 1 à la version 2 au moment de le reprendre : le niveau, implicite, devient explicite (2 plis, le seul qui existait) ; rien d'autre ne change, une partie de la version 1 étant une partie libre. Le schéma IndexedDB ne change pas (version 1) : le résumé d'une partie gagne un champ facultatif `match`, recalculé à chaque enregistrement, et les réglages (`ustagammon.settings`) quatre champs (`gameKind`, `matchLength`, `level`, `masterAdvice`) qui ont une valeur par défaut quand ils manquent. ### 2026-11-10 — Le conseil du maître rattaché au journal Avec le conseil du maître (étape 1d), le journal d'une partie (`GameJournal`, `app/src/domain/game/events.ts`) gagne un champ facultatif `advice` : pour chaque décision du joueur analysée (coup, jet sans doubler, double, prise ou refus d'un double), l'analyse du moteur à 2 plis, rangée sous l'identifiant de l'événement de la décision (`app/src/domain/game/advice.ts`). Ce n'est pas un événement : l'analyse arrive après coup, d'un second moteur, et ne change pas la partie ; les événements gardent leur version (2). Le bilan de fin de partie (1e) la relira sans recalcul. - Chaque conseil porte sa version (`ADVICE_VERSION`, 1). À la reprise (`migrateJournal`), un conseil d'une autre version, ou rattaché à un événement absent, est oublié : ce n'est qu'une analyse, qui peut être refaite. Un journal enregistré avant l'étape 1d n'a pas de champ `advice` et se reprend tel quel. Testé dans `app/tests/advice.test.ts`. - Ajouter un événement, revenir en arrière ou changer de branche garde les conseils ; un conseil reste attaché à son événement, même hors de la branche courante. - Le schéma IndexedDB ne change pas : le journal est enregistré tel quel. ### 2026-11-10 — L'analyse de partie rattachée au journal Avec le bilan de fin de partie et l'écran d'analyse (étape 1e), le journal d'une partie (`GameJournal`) gagne un second champ facultatif, `review` (`GameReview`, `app/src/domain/game/review.ts`) : le reste de l'analyse de la partie par le moteur, à côté des conseils du maître (`advice`) : - `luck` : la chance de chaque jet (`DiceRolled`, jet d'ouverture compris) pour le camp qui le joue ; - `winChances` : les chances de gagner du joueur après chaque coup (`MovePlayed`), de 0 à 1 ; - `computer` : les décisions de l'ordinateur, analysées comme celles du joueur (même format que les conseils) ; - `unanalysed` : les décisions que le moteur n'analyse pas (videau inaccessible, position de départ), pour ne plus les redemander ; - `version` (`REVIEW_VERSION`, 1) et `ply`, le niveau des analyses (2). Comme les conseils, ce n'est pas un événement : les événements gardent leur version (2). Les analyses qui manquent (dont tous les conseils d'une partie jouée avant l'étape 1d) sont calculées en arrière-plan sur le second moteur, une à une, rangées au fur et à mesure, et l'ensemble s'arrête quand on n'en a plus besoin (`app/src/ui/review-runner.ts`). - À la reprise (`migrateJournal`), une analyse d'une autre version ou mal formée est oubliée ; ses entrées rattachées à un événement absent aussi. Un journal enregistré avant l'étape 1e n'a pas de champ `review` et se reprend tel quel. Testé dans `app/tests/game-analysis.test.ts`. - Une analyse faite en arrière-plan sur une copie du journal s'y range sans toucher aux conseils déjà là (`withAnalysesOf`). - Le schéma IndexedDB ne change pas : le journal est enregistré tel quel. La bibliothèque gagne `saveAnalyses`, qui enregistre le journal analysé d'une partie sans en faire la partie en cours ni changer la date de sa dernière action (l'ordre de « Mes parties »). ### 2026-11-11 — Version 2 des conseils : le classement de l'analyse Le classement des coups d'un conseil (`ranking`) venait d'un second appel au moteur, comme la commande « hint » de gnubg, alors que le coup joué et le meilleur coup venaient de l'analyse de partie. Les deux listes diffèrent : quand le coup joué est une erreur, l'analyse réévalue le meilleur coup et le coup joué au niveau demandé, ce que « hint » ne fait pas quand un seul coup passe ses filtres (ouverture 3-1 jouée 24/23 13/10 : 8/5 6/5 à +0,200 à 2 plis dans l'analyse, +0,204 à 1 pli dans « hint »). Le classement vient désormais de l'analyse même (`engine_analyse_move`, champ `ranking`), comme le panneau d'analyse de gnubg de bureau. - `ADVICE_VERSION` passe à 2 (`app/src/domain/game/advice.ts`). Selon la règle de l'amendement du 2026-11-10, un conseil de la version 1 est oublié à la reprise et recalculé en arrière-plan ; les analyses de l'ordinateur (`review.computer`), du même format, aussi. - Ni les événements (version 2) ni le schéma IndexedDB ne changent. ### 2026-11-12 — La progression dans les leçons Avec les leçons (étape 1g), la progression rejoint les formats durables. Elle est rangée dans le stockage des parties (étape 1b), à côté de la partie en cours : - IndexedDB : une nouvelle clé `lessons` dans le magasin `settings` existant. Le schéma ne change pas (base « ustagammon », version 1, mêmes magasins) : aucune montée de version n'est nécessaire, et une base enregistrée avant l'étape 1g se lit telle quelle, sans progression. - Repli localStorage (et mémoire) : la clé `ustagammon.lessons`. - La valeur, en JSON, porte sa version (`LESSON_PROGRESS_VERSION`, 1) et, par numéro de leçon, la dernière étape vue (`last`), la plus lointaine (`reached`, 99 pour la fin) et les étapes dont l'exercice est réussi (`solved`) (`app/src/domain/learning/lessons.ts`). - `readLessonProgress` relit toute valeur enregistrée : absente ou illisible, la progression est vide ; une leçon mal formée est oubliée sans toucher aux autres ; une version inconnue est lue comme vide plutôt que mal comprise. Toute évolution du format passera par une nouvelle version et sa migration dans cette fonction, testées dans `app/tests/lessons.test.ts` comme ici. - Une leçon est terminée quand tous ses exercices sont réussis ; ce n'est pas enregistré à part, mais déduit des exercices réussis et du texte de la leçon : ajouter un exercice à une leçon la fait repasser « en cours ».