From fea94324afbf1659c35642c3ee014af4e61f5b5a Mon Sep 17 00:00:00 2001 From: Pierre MARTIN Date: Mon, 2 Nov 2026 21:15:37 +0100 Subject: [PATCH 1/8] =?UTF-8?q?Plan=20de=20l'=C3=A9tape=200d.2=20:=20plate?= =?UTF-8?q?au=20jouable=20en=20PWA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- docs/feuille-de-route.md | 2 +- docs/plans/2026-11-02-plateau-pwa.md | 104 +++++++++++++++++++++++++++ 2 files changed, 105 insertions(+), 1 deletion(-) create mode 100644 docs/plans/2026-11-02-plateau-pwa.md diff --git a/docs/feuille-de-route.md b/docs/feuille-de-route.md index a9328c2..b8126dc 100644 --- a/docs/feuille-de-route.md +++ b/docs/feuille-de-route.md @@ -20,7 +20,7 @@ garde-fous d'architecture (0d.1), déploiement sur https://ustagammon.sans.pub. | Sous-étape | Contenu | État | Plan | | --- | --- | --- | --- | -| 0d.2 | Plateau minimal jouable (PWA hors ligne) ; vérifié avec Pierre en suivant `docs/scenarios/jouer-une-partie.feature` | ⏳ | | +| 0d.2 | Plateau minimal jouable (PWA hors ligne) ; vérifié avec Pierre en suivant `docs/scenarios/jouer-une-partie.feature` | 🔄 | `plans/2026-11-02-plateau-pwa.md` | **Critère de fin** : une partie complète jouée sur le Fairphone 4, moins de 2 s par coup de l'IA en niveau standard, tous les tests au vert. diff --git a/docs/plans/2026-11-02-plateau-pwa.md b/docs/plans/2026-11-02-plateau-pwa.md new file mode 100644 index 0000000..3f16689 --- /dev/null +++ b/docs/plans/2026-11-02-plateau-pwa.md @@ -0,0 +1,104 @@ +# Plateau minimal jouable, en PWA hors ligne + +- **Date** : 2026-11-02 +- **Étape** : 0d.2 +- **Statut** : en cours +- **Exécutant** : sous-agent general-purpose, dans un worktree isolé + +## Objectif + +Sur https://ustagammon.sans.pub, un joueur joue une partie complète contre +l'ordinateur sur son téléphone, au toucher. L'application s'installe +(PWA) et fonctionne hors ligne. C'est la fin de l'étape 0 : critère +« une partie complète jouée sur le Fairphone 4, moins de 2 s par coup de l'IA +en niveau standard, tous les tests au vert ». Pas de design soigné : l'objectif +est de valider la boucle de jeu complète, proprement. + +## Consignes + +1. **Interface `app/src/ui/`** (TypeScript, DOM ou SVG, sans framework) : + - un plateau lisible sur un écran de téléphone en portrait (24 points, + barre, pions sortis, dés, videau) ; + - saisie au toucher : toucher une case de départ, puis une case + d'arrivée ; les cases d'arrivée possibles sont mises en évidence (le + domaine expose ce qui est possible à partir des coups légaux du moteur, + sans réimplémenter de règle) ; annuler les demi-coups en cours ; + - boutons : lancer les dés, doubler, prendre, passer, nouvelle partie ; + - pendant le tour de l'ordinateur : un indicateur discret ; le coup de + l'ordinateur s'affiche (sans animation obligatoire) ; + - fin de partie : gagnant, simple, gammon ou backgammon, points ; + - reprise automatique de la partie en cours après fermeture de la page + (le journal d'événements sérialisé dans `localStorage` suffit pour + l'étape 0 ; stockage derrière une petite interface, hors du domaine). +2. **Architecture** (ADR 0007, garde-fous existants) : l'assemblage (domaine, + adaptateur moteur dans un Web Worker, interface, stockage) se fait dans un + point d'entrée dédié (par exemple `app/src/main.ts`), seul autorisé à tout + importer ; `ui/` n'importe toujours pas `engine/`. Le moteur tourne dans un + Web Worker, jamais sur le fil d'affichage. Étendre les règles + dependency-cruiser si besoin. +3. **Textes** : en français, dans un fichier de textes séparé du code + (ADR 0007, point 5) ; les mots du jeu sont ceux du glossaire. +4. **Identité minimale** : couleurs et tailles en variables CSS, thème clair + et sombre (`prefers-color-scheme`), contrastes lisibles (pions clairs et + foncés distincts du plateau). Palette provisoire et sobre inspirée du brief + (bois, ivoire, un accent) : l'identité définitive viendra plus tard. +5. **Aide dans l'application** (ADR 0008, source unique) : le build copie + `docs/glossaire/` et `docs/scenarios/` dans le site ; une page d'aide + simple affiche le scénario « Jouer une partie contre l'ordinateur » et les + fiches du glossaire (au moins le terme et sa phrase « En bref »), lus à + l'exécution depuis ces copies, jamais recopiés à la main. +6. **PWA** : manifeste, icônes provisoires simples (pas de logo : un symbole + neutre), service worker qui met en cache tous les fichiers du site (dont + les 8,7 Mo de données du moteur) pour le hors ligne, et se met à jour + proprement à chaque publication (le commit de `version.json` peut servir de + clé de cache). +7. **Site publié** (`make site`, `docs/deploiement.md`) : l'application à la + racine ; les pages de prototype (test du moteur et mesure) restent + accessibles, par exemple sous `/moteur/` et `/moteur/bench.html`, et la doc + le dit. Ajouter une politique de sécurité du contenu (CSP) dans + `deploy/Caddyfile`, compatible WebAssembly (`'wasm-unsafe-eval'`), et la + vérifier. `tests/test_site.sh` à jour (manifeste, service worker, aide). +8. **Tests** : + - tests unitaires de ce qui est testable hors navigateur (par exemple la + transformation de l'état du domaine en ce que dessine le plateau, les + destinations possibles) ; + - vérification dans un vrai navigateur : une partie jouée par l'interface, + au moins jusqu'à plusieurs coups de chaque joueur, dans Chromium et + Firefox sans interface (image `mcr.microsoft.com/playwright:v1.48.2-noble`, + déjà présente). Cette vérification est faite et rapportée ; ne l'ajouter à + `make test` que si c'est simple et rapide, en le signalant comme nouvelle + dépendance de test (Playwright, Apache-2.0) ; + - les tests existants restent verts. +9. **Guide de test** : compléter `docs/guide-test-etape-0.md` d'une section + « Étape 0d : jouer une partie sur le téléphone » : ouvrir + https://ustagammon.sans.pub, installer l'application, jouer une partie + complète en suivant `docs/scenarios/jouer-une-partie.feature`, vérifier + le hors ligne (mode avion), et ce qu'il faut rapporter (temps des coups de + l'ordinateur ressentis, problèmes rencontrés, captures si besoin). + +Contraintes : +- `CLAUDE.md`, ADR 0004, 0007, 0008, 0009 : simplicité avant tout, pas de + framework, identifiants en anglais, commentaires, docs et textes en français, + tout outil dans Docker, `-j2` ; +- aucune règle du jeu réimplémentée : tout vient du moteur, via le domaine ; +- ne pas modifier `engine/gnubg/` ni les références ; +- ne pas publier (`make deploy-prod`) : la session principale s'en charge ; +- `make lint` et `make test` doivent passer ; commits petits, en français, avec + la ligne `Co-Authored-By: Claude Opus 5.5 ` ; +- ne pas pousser ni créer de merge request ; ne rien simuler. + +Rapport attendu : ce qui a été fait, captures d'écran du plateau (fichiers dans +le dossier de travail temporaire, chemins dans le rapport), résultat de la +partie jouée dans les navigateurs sans interface, taille du site, difficultés, +écarts au plan. + +## Critères de réussite + +- Dans Chromium et Firefox sans interface, une partie se joue par l'interface + (dés, coups au toucher, coups de l'ordinateur, videau) sans erreur. +- Le site fonctionne hors ligne après un premier chargement (service worker). +- `make lint` et `make test` passent. + +## Résultat + +À remplir à la clôture. -- 2.54.0 From 613ff382544c34dec2f2714ef89cb624721c1a55 Mon Sep 17 00:00:00 2001 From: Pierre MARTIN Date: Mon, 2 Nov 2026 21:23:06 +0100 Subject: [PATCH 2/8] =?UTF-8?q?Domaine=20:=20reprise=20d'une=20partie,=20p?= =?UTF-8?q?lateau=20affich=C3=A9,=20demi-coups=20possibles,=20droit=20de?= =?UTF-8?q?=20doubler?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ce dont l'interface a besoin, sans règle réimplémentée : le plateau vu de chaque camp, les demi-coups que le moteur accepte à la suite de ceux déjà saisis, le droit de doubler selon le moteur, et la reprise d'une partie depuis son journal enregistré. Les types du navigateur sont ajoutés à l'application ; le domaine reste vérifié sans eux. Co-Authored-By: Claude Opus 5.5 --- app/src/domain/game/game.ts | 109 ++++++++++++++++++++++++++++-- app/src/domain/tsconfig.json | 1 + app/tests/game.test.ts | 54 +++++++++++++++ app/tests/next-half-moves.test.ts | 65 ++++++++++++++++++ app/tsconfig.json | 2 +- 5 files changed, 225 insertions(+), 6 deletions(-) create mode 100644 app/tests/next-half-moves.test.ts diff --git a/app/src/domain/game/game.ts b/app/src/domain/game/game.ts index 6b2e211..d9f85b2 100644 --- a/app/src/domain/game/game.ts +++ b/app/src/domain/game/game.ts @@ -9,25 +9,25 @@ // l'ordinateur, videau, fin de partie et points. import { CENTERED_CUBE, type Cube } from "./cube.js"; -import type { Engine, EngineCube, Ply } from "./engine.js"; +import type { BoardSide, Engine, EngineCube, Ply } from "./engine.js"; import { appendEvent, currentLine, EMPTY_JOURNAL, + EVENT_VERSION, type GameEvent, type GameJournal, goBackTo, type NewGameEvent, } from "./events.js"; -import { type HalfMove, type PositionId, START_POSITION } from "./position.js"; +import { BAR, type HalfMove, OFF, type PositionId, START_POSITION } from "./position.js"; import { type DiceSource, type Roll, randomDice, rollDice } from "./roll.js"; import { otherSide, type Side } from "./side.js"; import { type GameState, replay } from "./state.js"; /** - * Niveau « standard » de l'ordinateur : 2 plis. C'est le niveau mesuré à - * l'étape 0b (au plus 0,3 s par coup sous Chromium et 2,3 s sous Firefox sur - * le PC de 2011) ; il reste à confirmer sur le téléphone. + * Niveau « standard » de l'ordinateur : 2 plis. Sur le Fairphone 4 (Chrome), + * le meilleur coup à ce niveau prend au plus 348 ms (ADR 0009). */ export const STANDARD_PLY: Ply = 2; @@ -40,6 +40,13 @@ export interface GameSetup { readonly cube?: Cube; } +/** Le plateau vu de chaque camp, chacun avec sa propre numérotation des + * cases (sa case 1 est la plus proche de sa sortie). */ +export interface SideBoards { + readonly player: BoardSide; + readonly computer: BoardSide; +} + export interface GameOptions { /** Source des dés ; le hasard ordinaire par défaut. */ readonly dice?: DiceSource; @@ -85,6 +92,25 @@ export class Game { return game; } + /** + * Reprend une partie enregistrée, à partir de son journal. Erreur si le + * journal est d'un autre format ou incohérent. + */ + static resume(engine: Engine, journal: GameJournal, options: GameOptions = {}): Game { + const first = journal.events[0]; + if (first?.type !== "GameStarted" || journal.head === null) { + throw new Error("journal de partie vide ou mal formé"); + } + if (journal.events.some((event) => event.version !== EVENT_VERSION)) { + throw new Error("journal de partie d'une autre version"); + } + const game = new Game(engine, options); + game.#journal = journal; + // Vérifie que chaque événement de la ligne courante existe. + currentLine(journal); + return game; + } + /** Tous les événements, branches comprises, et l'événement courant. */ get journal(): GameJournal { return this.#journal; @@ -109,6 +135,68 @@ export class Game { return this.#pendingPosition ?? this.state.position; } + /** Le plateau affiché (après les demi-coups déjà saisis), décodé par le + * moteur. */ + async displayedBoard(): Promise { + const state = this.state; + // La position est vue du camp qui a la main (le joueur pendant le jet + // d'ouverture : la position de départ est la même pour les deux). + const onRoll = state.turn ?? "player"; + const board = await this.#engine.board( + this.displayedPosition, + engineCube(state.cube, onRoll), + ); + return onRoll === "player" + ? { player: board.onRoll, computer: board.opponent } + : { player: board.opponent, computer: board.onRoll }; + } + + /** + * Les demi-coups que le joueur peut saisir maintenant, à la suite de ceux + * déjà saisis (liste vide hors de son tour de jeu). Chaque candidat + * déplace un de ses pions de la valeur d'un dé (vers la sortie si le dé + * dépasse la case 1) ; seuls restent ceux que le moteur déclare menant à + * un coup légal. Aucune règle n'est jugée ici. + */ + async nextHalfMoves(): Promise { + const state = this.state; + if (state.turn !== "player" || state.phase !== "moving") { + return []; + } + const roll = rollToPlay(state); + const { player } = await this.displayedBoard(); + const candidates = new Map(); + for (const from of occupiedPoints(player)) { + for (const die of roll) { + const to = Math.max(from - die, OFF); + candidates.set(`${from}/${to}`, { from, to }); + } + } + const moves = [...candidates.values()]; + const checks = await Promise.all( + moves.map((h) => + this.#engine.checkHalfMoves(state.position, roll, [...this.#pending, h]), + ), + ); + return moves.filter((_, i) => checks[i]?.valid === true); + } + + /** Le joueur peut-il doubler maintenant ? (Le moteur dit si le videau le + * permet.) */ + async canDouble(): Promise { + const state = this.state; + if (state.turn !== "player" || state.phase !== "rolling") { + return false; + } + // Le droit de doubler ne dépend pas du niveau : 0 pli suffit. + const decision = await this.#engine.cubeDecision( + state.position, + engineCube(state.cube, "player"), + 0, + ); + return decision.available; + } + // ------------------------------------------------------------- Joueur /** Le joueur lance les dés. S'il ne peut pas jouer, son tour passe. */ @@ -304,6 +392,17 @@ function rollToPlay(state: GameState): Roll { return state.roll; } +/** Les cases d'où un camp a des pions à jouer : la barre, puis 24 à 1. */ +function occupiedPoints(side: BoardSide): number[] { + const points = side.bar > 0 ? [BAR] : []; + for (let point = 24; point >= 1; point--) { + if ((side.points[point - 1] ?? 0) > 0) { + points.push(point); + } + } + return points; +} + /** Le videau vu du camp au trait, pour le moteur. */ function engineCube(cube: Cube, onRoll: Side): EngineCube { if (cube.owner === null) { diff --git a/app/src/domain/tsconfig.json b/app/src/domain/tsconfig.json index 51a14fc..739394d 100644 --- a/app/src/domain/tsconfig.json +++ b/app/src/domain/tsconfig.json @@ -1,6 +1,7 @@ { "extends": "../../tsconfig.json", "compilerOptions": { + "lib": ["ES2023"], "types": [], "noEmit": true }, diff --git a/app/tests/game.test.ts b/app/tests/game.test.ts index 00660db..d7ec808 100644 --- a/app/tests/game.test.ts +++ b/app/tests/game.test.ts @@ -218,3 +218,57 @@ test("lecture et écriture des demi-coups", () => { assert.throws(() => parseHalfMoves("8-5"), /mal formé/); assert.throws(() => parseHalfMoves("30/5"), /case invalide/); }); + +test("une partie se reprend depuis son journal ; un journal d'une autre version est refusé", async () => { + const game = Game.start(fakeEngine(), { dice: fixedDice([3, 1]) }); + await game.playHalfMove({ from: 8, to: 5 }); + await game.playHalfMove({ from: 6, to: 5 }); + const saved = JSON.parse(JSON.stringify(game.journal)); + + const resumed = Game.resume(fakeEngine(), saved); + assert.deepEqual(resumed.state, game.state); + assert.deepEqual(resumed.events, game.events); + + const other = { ...saved, events: saved.events.map((e: object) => ({ ...e, version: 99 })) }; + assert.throws(() => Game.resume(fakeEngine(), other), /version/); + assert.throws(() => Game.resume(fakeEngine(), EMPTY_JOURNAL), /mal formé/); + assert.throws(() => Game.resume(fakeEngine(), { ...saved, head: "inconnu" }), /introuvable/); +}); + +test("le droit de doubler vient du moteur, seulement avant de lancer les dés", async () => { + let available = true; + const engine = fakeEngine({ + cubeDecision: async () => ({ available, double: false, take: true }), + }); + const game = Game.start(engine, { + dice: fixedDice([3, 1]), + setup: { position: "X", firstTurn: "player" }, + }); + assert.equal(await game.canDouble(), true); + available = false; + assert.equal(await game.canDouble(), false); + available = true; + await game.roll(); + assert.equal(game.state.phase, "moving"); + assert.equal(await game.canDouble(), false); +}); + +test("le plateau affiché est donné du point de vue de chaque camp", async () => { + const side = (pipCount: number) => ({ points: [], bar: 0, off: 0, pipCount }); + const engine = fakeEngine({ + board: async () => ({ + onRoll: side(1), + opponent: side(2), + swappedPosition: "SWAPPED", + result: null, + }), + }); + const playerTurn = Game.start(engine, { setup: { position: "X", firstTurn: "player" } }); + const sides = await playerTurn.displayedBoard(); + assert.equal(sides.player.pipCount, 1); + assert.equal(sides.computer.pipCount, 2); + const computerTurn = Game.start(engine, { setup: { position: "X", firstTurn: "computer" } }); + const swapped = await computerTurn.displayedBoard(); + assert.equal(swapped.player.pipCount, 2); + assert.equal(swapped.computer.pipCount, 1); +}); diff --git a/app/tests/next-half-moves.test.ts b/app/tests/next-half-moves.test.ts new file mode 100644 index 0000000..1e8d904 --- /dev/null +++ b/app/tests/next-half-moves.test.ts @@ -0,0 +1,65 @@ +// Ce que l'interface propose au joueur, sur le vrai moteur WebAssembly : les +// demi-coups possibles (mis en évidence quand il touche un pion) et le +// plateau affiché pendant la saisie d'un coup. +// +// Copyright (C) 2026 les auteurs d'Ustagammon. +// Licence : GPLv3 ou ultérieure (voir LICENSE). + +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { Game } from "../src/domain/game/game.js"; +import { formatHalfMoves, type HalfMove } from "../src/domain/game/position.js"; +import { fixedDice } from "../src/domain/game/roll.js"; +import { loadNodeEngine } from "./support/node-engine.js"; + +// Le joueur, au trait, n'a plus que deux pions, sur ses cases 6 et 5 +// (même position que dans les étapes du scénario). +const PLAYER_ALMOST_DONE = "/38AAABQAAAAAA"; + +/** Les demi-coups en notation, triés : « 13/10 13/12… ». */ +function text(halfMoves: readonly HalfMove[]): string { + return halfMoves + .map((h) => formatHalfMoves([h])) + .sort() + .join(" "); +} + +test("au départ avec 3-1 : chaque pion jouable, avec chaque dé", async () => { + const engine = await loadNodeEngine(); + const game = Game.start(engine, { dice: fixedDice([3, 1]) }); + assert.equal(text(await game.nextHalfMoves()), "13/10 24/21 24/23 6/3 6/5 8/5 8/7"); + // 13/12 manque : votre case 12 est tenue par l'ordinateur. Après 8/5, + // il ne reste que le 1. + assert.equal(await game.playHalfMove({ from: 8, to: 5 }), true); + assert.equal(text(await game.nextHalfMoves()), "24/23 5/4 6/5 8/7"); +}); + +test("en fin de partie, les demi-coups vers la sortie viennent du moteur", async () => { + const engine = await loadNodeEngine(); + const game = Game.start(engine, { + dice: fixedDice([6, 5]), + setup: { position: PLAYER_ALMOST_DONE, firstTurn: "player" }, + }); + await game.roll(); + assert.equal(text(await game.nextHalfMoves()), "5/off 6/1 6/off"); +}); + +test("hors du tour de jeu du joueur, rien n'est proposé", async () => { + const engine = await loadNodeEngine(); + const game = Game.start(engine, { dice: fixedDice([1, 3]) }); + assert.equal(game.state.turn, "computer"); + assert.deepEqual(await game.nextHalfMoves(), []); +}); + +test("le plateau affiché suit les demi-coups saisis", async () => { + const engine = await loadNodeEngine(); + const game = Game.start(engine, { dice: fixedDice([3, 1]) }); + const before = await game.displayedBoard(); + assert.equal(before.player.points[7], 3); + assert.equal(before.computer.points[23], 2); + await game.playHalfMove({ from: 8, to: 5 }); + const after = await game.displayedBoard(); + assert.equal(after.player.points[7], 2); + assert.equal(after.player.points[4], 1); + assert.equal(after.player.pipCount, before.player.pipCount - 3); +}); diff --git a/app/tsconfig.json b/app/tsconfig.json index 59a052b..bff4bff 100644 --- a/app/tsconfig.json +++ b/app/tsconfig.json @@ -1,7 +1,7 @@ { "compilerOptions": { "target": "ES2023", - "lib": ["ES2023"], + "lib": ["ES2023", "DOM", "DOM.Iterable"], "module": "NodeNext", "moduleResolution": "NodeNext", "types": ["node"], -- 2.54.0 From a8636b3f839b7163fc48a908de676ffcfa222910 Mon Sep 17 00:00:00 2001 From: Pierre MARTIN Date: Mon, 2 Nov 2026 21:47:12 +0100 Subject: [PATCH 3/8] Plateau jouable en PWA : interface, Web Worker, stockage, service worker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit L'application est à la racine du site : plateau en SVG, saisie au toucher (pion puis case d'arrivée, proposées à partir du moteur), boutons du jet, du videau et de nouvelle partie, coup de l'ordinateur affiché avec son temps, fin de partie, reprise depuis localStorage, aide lue dans les copies de docs/. Le moteur tourne dans le Web Worker existant ; main.ts est le seul assembleur (nouvelles règles dependency-cruiser). Le service worker met tout le site en cache, avec une empreinte du contenu comme version. Les pages de test du moteur passent sous /moteur/. Politique de sécurité du contenu dans le Caddyfile ; make test-browser joue une partie dans Chromium et Firefox sans écran (Playwright, hors de make test). Co-Authored-By: Claude Opus 5.5 --- CREDITS.md | 7 +- Makefile | 63 +++-- app/.dependency-cruiser.cjs | 18 ++ app/src/engine/worker-run.ts | 81 +++++++ app/src/main.ts | 42 ++++ app/src/storage/game-store.ts | 47 ++++ app/src/ui/app.ts | 373 +++++++++++++++++++++++++++++ app/src/ui/board-model.ts | 92 +++++++ app/src/ui/board-view.ts | 358 +++++++++++++++++++++++++++ app/src/ui/help-text.ts | 91 +++++++ app/src/ui/help.ts | 102 ++++++++ app/src/ui/texts.ts | 91 +++++++ app/tests/ui.test.ts | 145 +++++++++++ deploy/Caddyfile | 12 + docs/deploiement.md | 60 ++++- docs/guide-test-etape-0.md | 114 +++++++-- engine-wasm/bench.mjs | 2 +- scripts/make-icons.py | 73 ++++++ scripts/service-worker.sh | 40 ++++ tests/browser.sh | 48 ++++ tests/browser/play.mjs | 215 +++++++++++++++++ tests/test_site.sh | 53 +++- tests/test_web.sh | 13 +- web/app.css | 283 ++++++++++++++++++++++ web/icons/icon-192.png | Bin 0 -> 2696 bytes web/icons/icon-512.png | Bin 0 -> 8278 bytes web/icons/icon.svg | 7 + web/index.html | 69 +++--- web/manifest.webmanifest | 18 ++ web/{ => moteur}/bench-page.mjs | 5 +- web/{ => moteur}/bench.html | 0 web/{ => moteur}/benchmark.mjs | 12 +- web/{ => moteur}/engine-client.mjs | 9 +- web/moteur/index.html | 48 ++++ web/{ => moteur}/page.mjs | 2 +- web/{ => moteur}/style.css | 0 web/sw.js | 61 +++++ 37 files changed, 2548 insertions(+), 106 deletions(-) create mode 100644 app/src/engine/worker-run.ts create mode 100644 app/src/main.ts create mode 100644 app/src/storage/game-store.ts create mode 100644 app/src/ui/app.ts create mode 100644 app/src/ui/board-model.ts create mode 100644 app/src/ui/board-view.ts create mode 100644 app/src/ui/help-text.ts create mode 100644 app/src/ui/help.ts create mode 100644 app/src/ui/texts.ts create mode 100644 app/tests/ui.test.ts create mode 100644 scripts/make-icons.py create mode 100755 scripts/service-worker.sh create mode 100755 tests/browser.sh create mode 100644 tests/browser/play.mjs create mode 100644 web/app.css create mode 100644 web/icons/icon-192.png create mode 100644 web/icons/icon-512.png create mode 100644 web/icons/icon.svg create mode 100644 web/manifest.webmanifest rename web/{ => moteur}/bench-page.mjs (93%) rename web/{ => moteur}/bench.html (100%) rename web/{ => moteur}/benchmark.mjs (96%) rename web/{ => moteur}/engine-client.mjs (87%) create mode 100644 web/moteur/index.html rename web/{ => moteur}/page.mjs (98%) rename web/{ => moteur}/style.css (100%) create mode 100644 web/sw.js diff --git a/CREDITS.md b/CREDITS.md index 745588e..12f8660 100644 --- a/CREDITS.md +++ b/CREDITS.md @@ -41,7 +41,7 @@ Les contenus pédagogiques (leçons, textes) sont sous licence CC BY-SA 4.0. **Python 3** (licence PSF, compatible GPL), qui exécute le comparateur `tests/reference/compare.py`, et **jq** (licence MIT), qui lit les JSON dans `tests/test_reference.sh`. Aucun des deux n'est utilisé sur le PC. - Le serveur HTTP de Python sert aussi la page de test (`make deploy-web`). + Le serveur HTTP de Python sert aussi le site sur le réseau local (`make deploy`). - **Emscripten** (licence MIT et University of Illinois/NCSA), image Docker `emscripten/emsdk:3.1.64`, compile le moteur en WebAssembly (`make engine-wasm`). Le code JavaScript qu'il génère @@ -57,6 +57,11 @@ Les contenus pédagogiques (leçons, textes) sont sous licence CC BY-SA 4.0. `docs/scenarios/` ; **dependency-cruiser** 18.6.0 (MIT), qui vérifie les règles d'architecture. Ces outils et leurs dépendances servent seulement à compiler et tester : rien n'est embarqué dans l'application. +- **Playwright** 1.48.2 (Apache-2.0), image Docker + `mcr.microsoft.com/playwright:v1.48.2-noble` (Chromium, Firefox, WebKit, + sous leurs licences libres) et paquet npm `playwright`, installé dans + `build/browser-tools/` : joue une partie dans des navigateurs sans écran + (`make test-browser`). Outil de test seulement, hors de `make test`. ## Serveur de production diff --git a/Makefile b/Makefile index 1c9e5b3..7746b09 100644 --- a/Makefile +++ b/Makefile @@ -23,12 +23,14 @@ WASM_IMAGE := emscripten/emsdk:3.1.64 # Outils de l'application web (docker/web/) : TypeScript, Biome, Cucumber, # dependency-cruiser. Changer l'étiquette à chaque modification du Dockerfile. WEB_IMAGE := ustagammon/web:1 -# Port de la page de test servie sur le réseau local (make deploy-web). +# Port du site servi sur le réseau local (make deploy). WEB_PORT := 8080 # Serveur statique de production (deploy/Dockerfile) : même version ici. CADDY_IMAGE := caddy:2.8.4-alpine +# Navigateurs sans écran pour make test-browser (Playwright, Apache-2.0). +PLAYWRIGHT_IMAGE := mcr.microsoft.com/playwright:v1.48.2-noble -export SHELLCHECK_IMAGE GHERKIN_IMAGE BUILD_C_IMAGE WASM_IMAGE CADDY_IMAGE WEB_IMAGE +export SHELLCHECK_IMAGE GHERKIN_IMAGE BUILD_C_IMAGE WASM_IMAGE CADDY_IMAGE WEB_IMAGE PLAYWRIGHT_IMAGE # Lance une commande dans la chaîne C, sous l'utilisateur courant pour que # build/ lui appartienne. @@ -36,7 +38,7 @@ DOCKER_C := docker run --rm --user "$$(id -u):$$(id -g)" -v "$(CURDIR):/src" -w DOCKER_WASM := docker run --rm --user "$$(id -u):$$(id -g)" -e HOME=/tmp -v "$(CURDIR):/src" -w /src $(WASM_IMAGE) DOCKER_WEB := docker run --rm --user "$$(id -u):$$(id -g)" -v "$(CURDIR):/src" -w /src $(WEB_IMAGE) -.PHONY: help setup build engine engine-wasm web bench test lint deploy deploy-web site package deploy-prod clean reference-image reference +.PHONY: help setup build engine engine-wasm web bench test test-browser lint deploy deploy-web site package deploy-prod clean reference-image reference help: ## Liste les commandes disponibles @echo "Ustagammon : commandes disponibles" @@ -88,40 +90,51 @@ bench: engine-wasm ## Mesure le moteur WebAssembly sous Node, sur ce PC (rapport test: ## Lance tous les tests @tests/run.sh +# Hors de make test : plusieurs minutes, et une image de 3 Go, récupérée ici +# à la première utilisation plutôt que par make setup. +test-browser: ## Joue une partie dans Chromium et Firefox sans écran (lent) + @docker image inspect $(PLAYWRIGHT_IMAGE) >/dev/null 2>&1 || docker pull $(PLAYWRIGHT_IMAGE) + @tests/browser.sh + lint: ## Vérifie le style et les erreurs courantes @tests/lint.sh -deploy: ## Installe l'application sur le téléphone (pas encore disponible) - @echo "Pas encore disponible : le socle (PWA ou Android) sera choisi à la fin de l'étape 0." - @echo "Page de test du moteur WebAssembly : make deploy-web" - @exit 1 - -# Sert web/ à la racine et build/wasm/ sous /wasm/ avec le serveur HTTP de -# Python (types MIME corrects, dont application/wasm). Les deux dossiers sont -# montés en lecture seule puis copiés dans le conteneur au démarrage : relancer -# après une modification. -# L'adresse affichée est celle du PC sur l'interface de sa route par défaut, -# c'est-à-dire sur le réseau local (Wi-Fi ou câble). -deploy-web: engine-wasm ## Sert la page de test WebAssembly sur le réseau local (Ctrl+C pour arrêter) +# Sert le site assemblé (build/site/, voir make site) avec le serveur HTTP de +# Python. L'adresse affichée est celle du PC sur l'interface de sa route par +# défaut, c'est-à-dire sur le réseau local (Wi-Fi ou câble). En http sur le +# réseau local, le navigateur refuse le service worker : l'application +# fonctionne, mais pas hors ligne (il faut https, comme en production). +deploy: site ## Sert l'application sur le réseau local (Ctrl+C pour arrêter) @ip="$$(ip -4 route get 1.1.1.1 2>/dev/null | awk '{ for (i = 1; i < NF; i++) if ($$i == "src") print $$(i + 1) }')"; \ - echo "Page de test : http://$${ip:-ADRESSE-DU-PC}:$(WEB_PORT)/"; \ - echo "Page de mesure : http://$${ip:-ADRESSE-DU-PC}:$(WEB_PORT)/bench.html"; \ + echo "Application : http://$${ip:-ADRESSE-DU-PC}:$(WEB_PORT)/"; \ + echo "Test du moteur : http://$${ip:-ADRESSE-DU-PC}:$(WEB_PORT)/moteur/"; \ + echo "Page de mesure : http://$${ip:-ADRESSE-DU-PC}:$(WEB_PORT)/moteur/bench.html"; \ echo "À ouvrir depuis le téléphone, sur le même Wi-Fi. Ctrl+C pour arrêter." - @docker run --rm --init -p $(WEB_PORT):8080 \ - -v "$(CURDIR)/web:/web:ro" -v "$(CURDIR)/build/wasm:/wasm:ro" $(BUILD_C_IMAGE) \ - sh -c 'cp -r /web /tmp/site && cp -r /wasm /tmp/site/wasm \ - && exec python3 -m http.server 8080 --directory /tmp/site' + @docker run --rm --init -p $(WEB_PORT):8080 -v "$(CURDIR)/$(BUILD_DIR)/site:/site:ro" \ + $(BUILD_C_IMAGE) python3 -m http.server 8080 --directory /site -# Le site tel qu'il sera publié : web/ à la racine, le moteur sous wasm/, et -# version.json (commit déployé). Reconstruit entièrement à chaque appel. -site: engine-wasm ## Assemble le site publié dans build/site/ +deploy-web: deploy ## Comme make deploy (ancien nom, étape 0b) + +# Le site tel qu'il sera publié, reconstruit entièrement à chaque appel : +# - web/ à la racine : la page de l'application, son style, le manifeste, les +# icônes, le Web Worker du moteur, et les pages de test sous moteur/ ; +# - l'application compilée (build/web/src/) sous app/ ; +# - le moteur sous wasm/ ; +# - les copies du glossaire et des scénarios sous docs/, lues par l'aide de +# l'application (ADR 0008 : jamais commitées ailleurs) ; +# - version.json (commit publié) ; +# - sw.js, complété par la liste de tous ces fichiers (scripts/service-worker.sh). +site: engine-wasm web ## Assemble le site publié dans build/site/ rm -rf $(BUILD_DIR)/site - mkdir -p $(BUILD_DIR)/site/wasm + mkdir -p $(BUILD_DIR)/site/wasm $(BUILD_DIR)/site/docs cp -r web/. $(BUILD_DIR)/site/ + cp -r $(BUILD_DIR)/web/src $(BUILD_DIR)/site/app cp $(BUILD_DIR)/wasm/engine.mjs $(BUILD_DIR)/wasm/engine.wasm $(BUILD_DIR)/wasm/engine.data \ $(BUILD_DIR)/wasm/positions.json $(BUILD_DIR)/site/wasm/ + cp -r docs/glossaire docs/scenarios $(BUILD_DIR)/site/docs/ printf '{"commit": "%s", "dirty": %s}\n' "$$(git rev-parse HEAD)" \ "$$(git diff --quiet HEAD && echo false || echo true)" > $(BUILD_DIR)/site/version.json + scripts/service-worker.sh $(BUILD_DIR)/site # L'archive envoyée à CapRover : captain-definition, Dockerfile, Caddyfile et # le site. CapRover construit l'image (une simple copie) sur le serveur. diff --git a/app/.dependency-cruiser.cjs b/app/.dependency-cruiser.cjs index 3b59596..504316e 100644 --- a/app/.dependency-cruiser.cjs +++ b/app/.dependency-cruiser.cjs @@ -24,6 +24,24 @@ module.exports = { from: { path: "^src/ui/" }, to: { path: "^src/engine/" }, }, + { + name: "only-main-assembles", + comment: + "Seul le point d'entrée (src/main.ts) assemble l'application : hors de " + + "leur propre dossier, le moteur (src/engine), le stockage (src/storage) " + + "et l'interface (src/ui) ne sont importés que par lui, et par les tests.", + severity: "error", + // $1 : le dossier de l'importateur (correspondance de groupe). + from: { path: "^src/([^/]+)/" }, + to: { path: "^src/(engine|storage|ui)/", pathNot: "^src/$1/" }, + }, + { + name: "nothing-imports-main", + comment: "Le point d'entrée n'est importé par personne.", + severity: "error", + from: {}, + to: { path: "^src/main\\.ts$" }, + }, { name: "no-circular", comment: "Pas de dépendance circulaire.", diff --git a/app/src/engine/worker-run.ts b/app/src/engine/worker-run.ts new file mode 100644 index 0000000..3f8193c --- /dev/null +++ b/app/src/engine/worker-run.ts @@ -0,0 +1,81 @@ +// Les commandes du moteur exécutées dans un Web Worker (web/engine-worker.mjs), +// pour que le calcul ne bloque jamais l'affichage. +// +// Copyright (C) 2026 les auteurs d'Ustagammon. +// Licence : GPLv3 ou ultérieure (voir LICENSE). +// +// Messages du Worker (voir engine-worker.mjs) : une fois au chargement +// { type: "ready" } ou { type: "failed", message } ; puis, pour chaque +// commande { id, args } envoyée, { id, result } ou { id, error }. Le Worker +// traite les commandes une à une, dans l'ordre d'arrivée. + +import type { RunCommand } from "./wasm-engine.js"; + +export interface EngineWorker { + /** Résolue quand le moteur est chargé ; rejetée s'il ne peut pas l'être. */ + readonly ready: Promise; + readonly run: RunCommand; +} + +interface Pending { + resolve(result: unknown): void; + reject(error: Error): void; +} + +export function startEngineWorker(url: URL): EngineWorker { + const worker = new Worker(url, { type: "module" }); + const pending = new Map(); + let nextId = 1; + let failure: Error | null = null; + let markReady: () => void = () => {}; + let markFailed: (error: Error) => void = () => {}; + const ready = new Promise((resolve, reject) => { + markReady = resolve; + markFailed = reject; + }); + + // Le moteur est inutilisable : toutes les commandes échouent. + function fail(error: Error): void { + failure = error; + markFailed(error); + for (const { reject } of pending.values()) { + reject(error); + } + pending.clear(); + } + + worker.onmessage = (event: MessageEvent) => { + const message = event.data; + if (message.type === "ready") { + markReady(); + } else if (message.type === "failed") { + fail(new Error(message.message)); + } else { + const request = pending.get(message.id); + pending.delete(message.id); + if (message.error !== undefined) { + request?.reject(new Error(message.error)); + } else { + request?.resolve(message.result); + } + } + }; + // Le Worker lui-même n'a pas démarré (fichier absent, navigateur trop + // ancien pour les Workers en module ES…). + worker.onerror = (event: ErrorEvent) => { + fail(new Error(event.message || "le Web Worker du moteur n'a pas démarré")); + }; + + function run(args: string[]): Promise { + if (failure !== null) { + return Promise.reject(failure); + } + const id = nextId++; + return new Promise((resolve, reject) => { + pending.set(id, { resolve, reject }); + worker.postMessage({ id, args }); + }); + } + + return { ready, run }; +} diff --git a/app/src/main.ts b/app/src/main.ts new file mode 100644 index 0000000..4963c2a --- /dev/null +++ b/app/src/main.ts @@ -0,0 +1,42 @@ +// Point d'entrée de l'application dans le navigateur (index.html) : le seul +// endroit qui assemble le domaine, le moteur, le stockage et l'interface +// (ADR 0007, vérifié par dependency-cruiser). +// +// Copyright (C) 2026 les auteurs d'Ustagammon. +// Licence : GPLv3 ou ultérieure (voir LICENSE). + +import { Game } from "./domain/game/game.js"; +import { createWasmEngine } from "./engine/wasm-engine.js"; +import { startEngineWorker } from "./engine/worker-run.js"; +import { createGameStore } from "./storage/game-store.js"; +import { startApp } from "./ui/app.js"; + +// Ce fichier est servi sous app/ ; le Worker et le service worker sont à la +// racine du site. +const worker = startEngineWorker(new URL("../engine-worker.mjs", import.meta.url)); +const engine = createWasmEngine(worker.run); +const store = createGameStore(window.localStorage); + +startApp({ + engineReady: worker.ready, + newGame: () => Game.start(engine), + savedGame: () => { + const journal = store.load(); + if (journal === null) { + return null; + } + try { + return Game.resume(engine, journal); + } catch { + return null; // partie illisible : on en commence une nouvelle + } + }, + saveGame: (journal) => store.save(journal), + confirm: (question) => window.confirm(question), +}); + +// Hors ligne : le service worker met tout le site en cache. Le navigateur le +// refuse hors https (sauf localhost) : l'application marche alors sans. +if ("serviceWorker" in navigator) { + navigator.serviceWorker.register(new URL("../sw.js", import.meta.url)).catch(() => {}); +} diff --git a/app/src/storage/game-store.ts b/app/src/storage/game-store.ts new file mode 100644 index 0000000..d46c5c9 --- /dev/null +++ b/app/src/storage/game-store.ts @@ -0,0 +1,47 @@ +// L'enregistrement de la partie en cours, pour la reprendre après fermeture +// de la page : son journal d'événements, en JSON, dans un stockage clé-valeur +// (localStorage dans le navigateur). +// +// Copyright (C) 2026 les auteurs d'Ustagammon. +// Licence : GPLv3 ou ultérieure (voir LICENSE). + +import type { GameJournal } from "../domain/game/events.js"; + +/** Ce qu'il faut d'un stockage clé-valeur (localStorage en a davantage). */ +export interface KeyValueStorage { + getItem(key: string): string | null; + setItem(key: string, value: string): void; +} + +export interface GameStore { + /** Le journal enregistré, ou null s'il n'y en a pas de lisible. */ + load(): GameJournal | null; + save(journal: GameJournal): void; +} + +const KEY = "ustagammon.current-game"; + +export function createGameStore(storage: KeyValueStorage): GameStore { + return { + load() { + try { + const journal = JSON.parse(storage.getItem(KEY) ?? "null"); + const valid = + journal !== null && + Array.isArray(journal.events) && + (journal.head === null || typeof journal.head === "string"); + return valid ? journal : null; + } catch { + return null; + } + }, + save(journal) { + try { + storage.setItem(KEY, JSON.stringify(journal)); + } catch { + // Stockage plein ou interdit (navigation privée) : la partie + // continue, elle ne pourra seulement pas être reprise. + } + }, + }; +} diff --git a/app/src/ui/app.ts b/app/src/ui/app.ts new file mode 100644 index 0000000..9cac0d4 --- /dev/null +++ b/app/src/ui/app.ts @@ -0,0 +1,373 @@ +// L'écran de jeu : le plateau, les boutons et les messages, branchés sur une +// partie du domaine. +// +// Copyright (C) 2026 les auteurs d'Ustagammon. +// Licence : GPLv3 ou ultérieure (voir LICENSE). +// +// Tout passe par la partie (Game) : l'interface ne connaît ni le moteur ni le +// stockage, qu'elle reçoit déjà branchés (main.ts). Après chaque action du +// joueur, la partie est enregistrée, puis l'ordinateur joue tant que c'est +// son tour ; le plateau est ensuite redessiné à partir de l'état de la partie. + +import type { GameJournal } from "../domain/game/events.js"; +import type { Game, SideBoards } from "../domain/game/game.js"; +import { formatHalfMoves, type HalfMove } from "../domain/game/position.js"; +import type { Side } from "../domain/game/side.js"; +import { boardModel, moveTargets, type Place, placeOf } from "./board-model.js"; +import { renderBoard, type ShownDie } from "./board-view.js"; +import { showHelp } from "./help.js"; +import { TEXTS } from "./texts.js"; + +export interface AppOptions { + /** Résolue quand le moteur est chargé, rejetée s'il ne peut pas l'être. */ + readonly engineReady: Promise; + /** Une nouvelle partie (le jet d'ouverture est déjà tiré). */ + readonly newGame: () => Game; + /** La partie enregistrée, ou null s'il n'y en a pas. */ + readonly savedGame: () => Game | null; + readonly saveGame: (journal: GameJournal) => void; + readonly confirm: (question: string) => boolean; +} + +function byId(id: string): T { + const element = document.getElementById(id); + if (element === null) { + throw new Error(`élément #${id} absent de la page`); + } + return element as T; +} + +/** Démarre l'écran de jeu dans la page (index.html). */ +export function startApp(options: AppOptions): void { + const ui = { + board: byId("board") as unknown as SVGSVGElement, + info: byId("info"), + prompt: byId("prompt"), + pips: byId("pips"), + roll: byId("roll"), + double: byId("double"), + take: byId("take"), + drop: byId("drop"), + cancel: byId("cancel"), + again: byId("again"), + newGame: byId("new-game"), + helpButton: byId("help-button"), + helpBack: byId("help-back"), + game: byId("game"), + help: byId("help"), + helpContent: byId("help-content"), + }; + ui.board.setAttribute("aria-label", TEXTS.boardLabel); + ui.roll.textContent = TEXTS.roll; + ui.double.textContent = TEXTS.double; + ui.take.textContent = TEXTS.take; + ui.drop.textContent = TEXTS.drop; + ui.cancel.textContent = TEXTS.cancel; + ui.again.textContent = TEXTS.newGame; + ui.newGame.textContent = TEXTS.newGame; + ui.helpButton.textContent = TEXTS.help; + ui.helpBack.textContent = TEXTS.back; + + // État de l'écran, en plus de la partie elle-même. + let game: Game | null = null; + let sides: SideBoards | null = null; + let targets = new Map>(); + let selected: Place | null = null; + let canDouble = false; + let busy = true; + let info = ""; + + // ------------------------------------------------------------ Dessin + + /** Recalcule ce qui demande le moteur, puis redessine. */ + async function refresh(): Promise { + if (game === null) { + return; + } + sides = await game.displayedBoard(); + targets = moveTargets(await game.nextHalfMoves()); + canDouble = await game.canDouble(); + if (selected !== null && !targets.has(selected)) { + selected = null; + } + render(); + } + + function render(): void { + if (game === null || sides === null) { + return; + } + const state = game.state; + const model = boardModel(sides); + renderBoard(ui.board, model, { + dice: shownDice(game), + cube: state.cube, + movable: new Set(targets.keys()), + selected, + targets: new Set(selected === null ? [] : (targets.get(selected)?.keys() ?? [])), + lastMove: lastComputerMove(game), + }); + ui.pips.textContent = TEXTS.pipCounts(model.pipCount.player, model.pipCount.computer); + ui.info.textContent = info; + ui.prompt.textContent = prompt(game, selected !== null, canDouble); + ui.prompt.classList.toggle("thinking", state.turn === "computer" && isPlaying(game)); + + const player = state.turn === "player"; + show(ui.roll, player && state.phase === "rolling"); + show(ui.double, player && state.phase === "rolling"); + ui.double.disabled = busy || !canDouble; + show(ui.cancel, player && state.phase === "moving"); + ui.cancel.disabled = busy || game.pendingHalfMoves.length === 0; + const doubled = state.turn === "computer" && state.phase === "doubled"; + show(ui.take, doubled); + show(ui.drop, doubled); + show(ui.again, state.phase === "over"); + for (const button of [ui.roll, ui.take, ui.drop, ui.again, ui.newGame]) { + button.disabled = busy; + } + // Pour le style et pour les tests dans un navigateur : l'écran + // attend le moteur. + document.body.classList.toggle("busy", busy); + } + + function show(button: HTMLButtonElement, visible: boolean): void { + button.hidden = !visible; + } + + // ----------------------------------------------------------- Actions + + function save(): void { + if (game !== null) { + options.saveGame(game.journal); + } + } + + /** L'ordinateur joue tant que c'est son tour (il s'arrête s'il double). */ + async function computerTurns(): Promise { + while (game !== null && game.state.turn === "computer" && isPlaying(game)) { + await refresh(); + const start = performance.now(); + await game.playComputerTurn(); + info = describeComputerTurn(game, performance.now() - start); + save(); + } + } + + /** Une action du joueur, puis le tour de l'ordinateur. Les touches sont + * ignorées pendant ce temps. */ + async function act(action: (game: Game) => Promise | void): Promise { + if (busy || game === null) { + return; + } + busy = true; + selected = null; + targets = new Map(); + render(); + try { + await action(game); + save(); + await computerTurns(); + } catch (error) { + info = TEXTS.actionFailed(error instanceof Error ? error.message : String(error)); + } finally { + busy = false; + await refresh(); + } + } + + function startNewGame(): void { + game = options.newGame(); + const opening = game.events.findLast((e) => e.type === "DiceRolled"); + info = + opening?.type === "DiceRolled" ? TEXTS.opening(opening.roll[0], opening.roll[1]) : ""; + } + + ui.roll.onclick = () => + act(async (g) => { + info = ""; + await g.roll(); + const last = g.events.at(-1); + if (last?.type === "MovePlayed" && last.halfMoves.length === 0) { + info = TEXTS.youCannotMove(rollBefore(g)); + } + }); + + ui.double.onclick = () => + act(async (g) => { + await g.double(); + info = g.state.phase === "over" ? "" : TEXTS.computerTakes(g.state.cube.value); + }); + + ui.take.onclick = () => + act((g) => { + g.take(); + info = TEXTS.youTook(g.state.cube.value); + }); + + ui.drop.onclick = () => + act((g) => { + g.drop(); + info = ""; + }); + + ui.cancel.onclick = () => { + if (!busy && game !== null) { + game.cancelHalfMoves(); + selected = null; + void refresh(); + } + }; + + const newGame = () => { + if (busy) { + return; + } + const inProgress = game !== null && game.state.phase !== "over"; + if (inProgress && !options.confirm(TEXTS.confirmNewGame)) { + return; + } + void act(() => startNewGame()); + }; + ui.newGame.onclick = newGame; + ui.again.onclick = newGame; + + // Toucher le plateau : un pion jouable, puis une de ses cases d'arrivée. + ui.board.addEventListener("click", (event) => { + const target = event.target as Element | null; + const place = target?.closest("[data-place]")?.getAttribute("data-place") as Place | null; + if (busy || place == null) { + return; + } + const halfMove = selected === null ? undefined : targets.get(selected)?.get(place); + if (halfMove !== undefined) { + void act(async (g) => { + info = ""; + await g.playHalfMove(halfMove); + }); + return; + } + selected = targets.has(place) && place !== selected ? place : null; + render(); + }); + + ui.helpButton.onclick = () => { + ui.game.hidden = true; + ui.help.hidden = false; + window.scrollTo(0, 0); + void showHelp(ui.helpContent); + }; + ui.helpBack.onclick = () => { + ui.help.hidden = true; + ui.game.hidden = false; + }; + + // ---------------------------------------------------------- Démarrage + + ui.prompt.textContent = TEXTS.loading; + for (const button of [ui.roll, ui.double, ui.take, ui.drop, ui.cancel, ui.again]) { + button.hidden = true; + } + ui.newGame.disabled = true; + options.engineReady.then( + async () => { + game = options.savedGame(); + if (game === null) { + startNewGame(); + save(); + } + // Comme après une action du joueur : l'ordinateur joue si c'est + // à lui (partie nouvelle ou reprise pendant son tour). + try { + await computerTurns(); + } finally { + busy = false; + await refresh(); + } + }, + (error: unknown) => { + ui.prompt.textContent = TEXTS.engineFailed( + error instanceof Error ? error.message : String(error), + ); + }, + ); +} + +// ------------------------------------------------- Lecture de la partie + +/** La partie attend-elle encore un coup ou un jet (ni double, ni fin) ? */ +function isPlaying(game: Game): boolean { + const phase = game.state.phase; + return phase === "rolling" || phase === "moving"; +} + +/** Le jet du dernier DiceRolled. */ +function rollBefore(game: Game): readonly number[] { + const rolled = game.events.findLast((e) => e.type === "DiceRolled"); + return rolled?.type === "DiceRolled" ? rolled.roll : []; +} + +/** Les dés à montrer : le dernier jet, de la couleur de qui l'a lancé + * (au jet d'ouverture, un dé chacun). */ +function shownDice(game: Game): ShownDie[] { + const rolled = game.events.findLast((e) => e.type === "DiceRolled"); + if (rolled?.type !== "DiceRolled") { + return []; + } + const sides: Side[] = + rolled.side === null ? ["player", "computer"] : [rolled.side, rolled.side]; + return rolled.roll.map((value, i) => ({ value, side: sides[i] ?? "player" })); +} + +/** Les cases de départ et d'arrivée du dernier coup, s'il est de + * l'ordinateur. */ +function lastComputerMove(game: Game): Set { + const played = game.events.findLast((e) => e.type === "MovePlayed"); + const places = new Set(); + if (played?.type === "MovePlayed" && played.side === "computer") { + for (const h of played.halfMoves) { + places.add(placeOf("computer", h.from)); + places.add(placeOf("computer", h.to)); + } + } + return places; +} + +/** Ce qu'a fait l'ordinateur pendant son tour. */ +function describeComputerTurn(game: Game, ms: number): string { + const last = game.events.at(-1); + if (last?.type === "CubeOffered") { + return ""; + } + const played = game.events.findLast((e) => e.type === "MovePlayed"); + if (played?.type !== "MovePlayed") { + return ""; + } + const dice = rollBefore(game); + if (played.halfMoves.length === 0) { + return TEXTS.computerCannotMove(dice); + } + return TEXTS.computerMoved(dice, formatHalfMoves(played.halfMoves), ms); +} + +/** Ce que le joueur doit faire maintenant, ou le résultat. */ +function prompt(game: Game, selecting: boolean, canDouble: boolean): string { + const state = game.state; + if (state.phase === "over" && state.result !== null) { + const { winner, kind, points } = state.result; + const dropped = game.events.find((e) => e.type === "CubeDropped"); + if (dropped !== undefined) { + return winner === "player" ? TEXTS.computerDropped(points) : TEXTS.youDropped(points); + } + return winner === "player" ? TEXTS.youWin(kind, points) : TEXTS.youLose(kind, points); + } + if (state.turn === "computer") { + return state.phase === "doubled" ? TEXTS.computerDoubles : TEXTS.thinking; + } + if (state.phase === "rolling") { + return canDouble ? TEXTS.yourRoll : TEXTS.yourRollNoDouble; + } + if (state.phase === "moving" && state.roll !== null) { + return selecting ? TEXTS.pickTarget : TEXTS.yourMove(state.roll); + } + return ""; +} diff --git a/app/src/ui/board-model.ts b/app/src/ui/board-model.ts new file mode 100644 index 0000000..dbd19f6 --- /dev/null +++ b/app/src/ui/board-model.ts @@ -0,0 +1,92 @@ +// Ce que dessine le plateau, calculé sans le navigateur (testé sous Node). +// +// Copyright (C) 2026 les auteurs d'Ustagammon. +// Licence : GPLv3 ou ultérieure (voir LICENSE). +// +// Le plateau est toujours dessiné du point de vue du joueur : ses cases 1 à 6 +// en bas à droite, 7 à 12 en bas à gauche, 13 à 18 en haut à gauche, 19 à 24 +// en haut à droite ; ses pions vont de 24 vers 1, puis sortent à droite, en +// bas. La case 1 de l'ordinateur est la case 24 du joueur. + +import type { SideBoards } from "../domain/game/game.js"; +import { BAR, type HalfMove, OFF } from "../domain/game/position.js"; +import type { Side } from "../domain/game/side.js"; + +/** + * Un endroit du plateau où l'on peut toucher : une case (« p1 » à « p24 », + * numérotée du point de vue du joueur), la barre ou la sortie d'un camp. + */ +export type Place = `p${number}` | `bar-${Side}` | `off-${Side}`; + +/** L'endroit désigné par une case d'un demi-coup de side, dans sa propre + * numérotation (BAR, OFF ou 1 à 24). */ +export function placeOf(side: Side, point: number): Place { + if (point === BAR) { + return `bar-${side}`; + } + if (point === OFF) { + return `off-${side}`; + } + return side === "player" ? `p${point}` : `p${25 - point}`; +} + +/** Une case du plateau, et les pions de chaque camp qui s'y trouvent. */ +export interface PointModel { + readonly place: Place; + /** Numéro de la case pour le joueur, 1 à 24. */ + readonly number: number; + /** Le camp qui l'occupe (null : vide) et son nombre de pions. */ + readonly owner: Side | null; + readonly count: number; +} + +export interface BoardModel { + /** Les cases 1 à 24 du joueur, dans l'ordre. */ + readonly points: readonly PointModel[]; + readonly bar: Readonly>; + readonly off: Readonly>; + readonly pipCount: Readonly>; +} + +/** Le plateau à dessiner, à partir du plateau vu de chaque camp. */ +export function boardModel(sides: SideBoards): BoardModel { + const points: PointModel[] = []; + for (let number = 1; number <= 24; number++) { + const player = sides.player.points[number - 1] ?? 0; + const computer = sides.computer.points[24 - number] ?? 0; + // Une case n'a jamais de pions des deux camps à la fois. + const owner = player > 0 ? "player" : computer > 0 ? "computer" : null; + points.push({ place: `p${number}`, number, owner, count: player || computer }); + } + return { + points, + bar: { player: sides.player.bar, computer: sides.computer.bar }, + off: { player: sides.player.off, computer: sides.computer.off }, + pipCount: { player: sides.player.pipCount, computer: sides.computer.pipCount }, + }; +} + +/** Les cases de départ des demi-coups possibles, et pour chacune ses cases + * d'arrivée : ce que l'interface met en évidence. */ +export function moveTargets(halfMoves: readonly HalfMove[]): Map> { + const targets = new Map>(); + for (const halfMove of halfMoves) { + const from = placeOf("player", halfMove.from); + let tos = targets.get(from); + if (tos === undefined) { + tos = new Map(); + targets.set(from, tos); + } + tos.set(placeOf("player", halfMove.to), halfMove); + } + return targets; +} + +/** Où se trouve une case sur le dessin : rangée du haut ou du bas, et + * colonne de 0 (à gauche) à 11 (à droite), sans compter la barre. */ +export function pointSlot(number: number): { top: boolean; column: number } { + if (number <= 12) { + return { top: false, column: 12 - number }; + } + return { top: true, column: number - 13 }; +} diff --git a/app/src/ui/board-view.ts b/app/src/ui/board-view.ts new file mode 100644 index 0000000..ce28bac --- /dev/null +++ b/app/src/ui/board-view.ts @@ -0,0 +1,358 @@ +// Le dessin du plateau, en SVG. +// +// Copyright (C) 2026 les auteurs d'Ustagammon. +// Licence : GPLv3 ou ultérieure (voir LICENSE). +// +// Le plateau est redessiné en entier à chaque changement : c'est rapide, et +// il n'y a aucun état caché dans le dessin. Les couleurs viennent des +// variables CSS (app.css), par des classes : aucun style dans le SVG. Chaque +// endroit qu'on peut toucher est un rectangle transparent, posé par-dessus +// le reste, qui porte son nom dans data-place. + +import type { Cube } from "../domain/game/cube.js"; +import type { Side } from "../domain/game/side.js"; +import { type BoardModel, type Place, pointSlot } from "./board-model.js"; + +/** Un dé affiché, de la couleur du camp qui l'a lancé. */ +export interface ShownDie { + readonly value: number; + readonly side: Side; +} + +/** Ce qui s'ajoute aux pions : dés, videau, mises en évidence. */ +export interface BoardDecor { + readonly dice: readonly ShownDie[]; + readonly cube: Cube; + /** Les pions que le joueur peut déplacer. */ + readonly movable: ReadonlySet; + /** Le pion touché, dont on montre les cases d'arrivée. */ + readonly selected: Place | null; + readonly targets: ReadonlySet; + /** Les cases de départ et d'arrivée du dernier coup de l'ordinateur. */ + readonly lastMove: ReadonlySet; +} + +// Géométrie, en unités du viewBox (le SVG s'adapte à la largeur de l'écran). +const WIDTH = 640; +const HEIGHT = 520; +const FRAME = 8; +const POINT_WIDTH = 44; +const HALF_WIDTH = 6 * POINT_WIDTH; +const BAR_LEFT = FRAME + HALF_WIDTH; +const BAR_WIDTH = 40; +const RIGHT_HALF = BAR_LEFT + BAR_WIDTH; +const TRAY_LEFT = RIGHT_HALF + HALF_WIDTH + FRAME; +const TRAY_WIDTH = 48; +const POINT_HEIGHT = 190; +const MIDDLE = HEIGHT / 2; +const RADIUS = 20; +const STEP = 42; // d'un pion au suivant dans une pile +const MAX_STACK = 5; // au-delà, un nombre sur le dernier pion +const OFF_SLAB = 12; // épaisseur d'un pion sorti +const DIE = 36; +const CUBE = 34; + +const SVG_NS = "http://www.w3.org/2000/svg"; + +type Attributes = Record; + +function add(parent: Element, name: string, attributes: Attributes, text?: string): SVGElement { + const element = document.createElementNS(SVG_NS, name); + for (const [key, value] of Object.entries(attributes)) { + element.setAttribute(key, String(value)); + } + if (text !== undefined) { + element.textContent = text; + } + parent.append(element); + return element; +} + +/** Abscisse du bord gauche d'une colonne (0 à 11). */ +function columnLeft(column: number): number { + return column < 6 ? FRAME + column * POINT_WIDTH : RIGHT_HALF + (column - 6) * POINT_WIDTH; +} + +/** Centre du i-ième pion d'une pile, depuis le bord du plateau. */ +function stackY(top: boolean, index: number): number { + const offset = RADIUS + 1 + index * STEP; + return top ? FRAME + offset : HEIGHT - FRAME - offset; +} + +/** Redessine tout le plateau dans svg. */ +export function renderBoard(svg: SVGSVGElement, board: BoardModel, decor: BoardDecor): void { + svg.replaceChildren(); + svg.setAttribute("viewBox", `0 0 ${WIDTH} ${HEIGHT}`); + drawFrame(svg); + const hits = add(svg, "g", {}); + for (const point of board.points) { + drawPoint(svg, hits, board, point.number, decor); + } + drawBar(svg, hits, board, decor); + drawTray(svg, hits, board, decor); + drawDice(svg, decor.dice); + drawCube(svg, decor.cube); + // Les zones à toucher passent devant tout le reste. + svg.append(hits); +} + +function drawFrame(svg: SVGSVGElement): void { + add(svg, "rect", { class: "board-frame", x: 0, y: 0, width: WIDTH, height: HEIGHT, rx: 6 }); + for (const left of [FRAME, RIGHT_HALF]) { + add(svg, "rect", { + class: "board-bed", + x: left, + y: FRAME, + width: HALF_WIDTH, + height: HEIGHT - 2 * FRAME, + }); + } + add(svg, "rect", { + class: "board-tray", + x: TRAY_LEFT, + y: FRAME, + width: TRAY_WIDTH, + height: HEIGHT - 2 * FRAME, + }); +} + +function drawPoint( + svg: SVGSVGElement, + hits: Element, + board: BoardModel, + number: number, + decor: BoardDecor, +): void { + const point = board.points[number - 1]; + if (point === undefined) { + return; + } + const { top, column } = pointSlot(number); + const left = columnLeft(column); + const center = left + POINT_WIDTH / 2; + const base = top ? FRAME : HEIGHT - FRAME; + const tip = top ? FRAME + POINT_HEIGHT : HEIGHT - FRAME - POINT_HEIGHT; + add(svg, "polygon", { + class: column % 2 === 0 ? "point-a" : "point-b", + points: `${left},${base} ${left + POINT_WIDTH},${base} ${center},${tip}`, + }); + add( + svg, + "text", + { class: "point-number", x: center, y: top ? MIDDLE - 26 : MIDDLE + 34 }, + String(number), + ); + if (decor.targets.has(point.place)) { + add(svg, "rect", { + class: "target-zone", + x: left + 2, + y: top ? FRAME : MIDDLE, + width: POINT_WIDTH - 4, + height: HEIGHT / 2 - FRAME, + rx: 4, + }); + } + if (point.owner !== null) { + const y = (i: number) => stackY(top, i); + drawStack(svg, point.owner, point.count, center, y, point.place, decor); + } + if (decor.lastMove.has(point.place)) { + add(svg, "circle", { + class: "last-move", + cx: center, + cy: top ? MIDDLE - 12 : MIDDLE + 12, + r: 5, + }); + } + addHit(hits, point.place, decor, { + x: left, + y: top ? 0 : MIDDLE, + width: POINT_WIDTH, + height: MIDDLE, + }); +} + +/** Une pile de pions, le i-ième centré en (cx, y(i)) ; le dernier porte le + * nombre total s'il y en a trop pour les montrer tous. */ +function drawStack( + svg: SVGSVGElement, + side: Side, + count: number, + cx: number, + y: (index: number) => number, + place: Place, + decor: BoardDecor, +): void { + const shown = Math.min(count, MAX_STACK); + for (let i = 0; i < shown; i++) { + const last = i === shown - 1; + const classes = [`checker checker-${side}`]; + if (last && decor.selected === place) { + classes.push("selected"); + } else if (last && decor.movable.has(place)) { + classes.push("movable"); + } + const cy = y(i); + add(svg, "circle", { class: classes.join(" "), cx, cy, r: RADIUS }); + if (last && count > MAX_STACK) { + add( + svg, + "text", + { class: `checker-count count-${side}`, x: cx, y: cy + 6 }, + String(count), + ); + } + } +} + +function drawBar(svg: SVGSVGElement, hits: Element, board: BoardModel, decor: BoardDecor): void { + add(svg, "rect", { + class: "board-bar", + x: BAR_LEFT, + y: 0, + width: BAR_WIDTH, + height: HEIGHT, + }); + const cx = BAR_LEFT + BAR_WIDTH / 2; + // Les pions du joueur sur la barre entrent en haut à droite : on les met + // dans la moitié haute, en partant du milieu ; ceux de l'ordinateur en bas. + for (const side of ["player", "computer"] as const) { + const up = side === "player"; + const y = (i: number) => + up ? MIDDLE - RADIUS - 4 - i * STEP : MIDDLE + RADIUS + 4 + i * STEP; + drawStack(svg, side, board.bar[side], cx, y, `bar-${side}`, decor); + } + addHit(hits, "bar-player", decor, { x: BAR_LEFT, y: 0, width: BAR_WIDTH, height: MIDDLE }); +} + +function drawTray(svg: SVGSVGElement, hits: Element, board: BoardModel, decor: BoardDecor): void { + // Pions sortis, vus de profil : l'ordinateur en haut, le joueur en bas. + for (const side of ["player", "computer"] as const) { + for (let i = 0; i < board.off[side]; i++) { + const y = + side === "computer" ? FRAME + i * OFF_SLAB : HEIGHT - FRAME - (i + 1) * OFF_SLAB; + add(svg, "rect", { + class: `checker checker-${side}`, + x: TRAY_LEFT + 4, + y: y + 1, + width: TRAY_WIDTH - 8, + height: OFF_SLAB - 2, + rx: 3, + }); + } + } + if (decor.targets.has("off-player")) { + add(svg, "rect", { + class: "target-zone", + x: TRAY_LEFT + 2, + y: MIDDLE, + width: TRAY_WIDTH - 4, + height: MIDDLE - FRAME, + rx: 4, + }); + } + if (decor.lastMove.has("off-computer")) { + add(svg, "circle", { + class: "last-move", + cx: TRAY_LEFT + TRAY_WIDTH / 2, + cy: MIDDLE - 70, + r: 5, + }); + } + addHit(hits, "off-player", decor, { + x: TRAY_LEFT, + y: MIDDLE, + width: TRAY_WIDTH, + height: MIDDLE, + }); +} + +// Position des points d'un dé, en fractions de son côté. +const PIPS: Record = { + 1: [[0.5, 0.5]], + 2: [ + [0.25, 0.25], + [0.75, 0.75], + ], + 3: [ + [0.25, 0.25], + [0.5, 0.5], + [0.75, 0.75], + ], + 4: [ + [0.25, 0.25], + [0.75, 0.25], + [0.25, 0.75], + [0.75, 0.75], + ], + 5: [ + [0.25, 0.25], + [0.75, 0.25], + [0.5, 0.5], + [0.25, 0.75], + [0.75, 0.75], + ], + 6: [ + [0.25, 0.25], + [0.75, 0.25], + [0.25, 0.5], + [0.75, 0.5], + [0.25, 0.75], + [0.75, 0.75], + ], +}; + +/** Les dés, au milieu de la moitié droite. */ +function drawDice(svg: SVGSVGElement, dice: readonly ShownDie[]): void { + const center = RIGHT_HALF + HALF_WIDTH / 2; + const gap = 8; + const total = dice.length * DIE + (dice.length - 1) * gap; + dice.forEach((die, i) => { + const x = center - total / 2 + i * (DIE + gap); + const y = MIDDLE - DIE / 2; + const group = add(svg, "g", { class: "die", "data-value": die.value }); + add(group, "rect", { class: `die-${die.side}`, x, y, width: DIE, height: DIE, rx: 6 }); + for (const [px, py] of PIPS[die.value] ?? []) { + add(group, "circle", { + class: `pip-${die.side}`, + cx: x + px * DIE, + cy: y + py * DIE, + r: 3.5, + }); + } + }); +} + +/** Le videau, dans le plateau de sortie : au milieu s'il est au centre, + * sinon du côté de son propriétaire. Au centre, il montre 64. */ +function drawCube(svg: SVGSVGElement, cube: Cube): void { + const shift = cube.owner === "computer" ? -48 : cube.owner === "player" ? 48 : 0; + const x = TRAY_LEFT + (TRAY_WIDTH - CUBE) / 2; + const y = MIDDLE + shift - CUBE / 2; + const group = add(svg, "g", { class: "cube", "data-owner": cube.owner ?? "center" }); + add(group, "rect", { class: "cube-face", x, y, width: CUBE, height: CUBE, rx: 4 }); + add( + group, + "text", + { class: "cube-value", x: x + CUBE / 2, y: y + CUBE / 2 + 6 }, + String(cube.owner === null ? 64 : cube.value), + ); +} + +/** Une zone à toucher. Elle porte son état (can-move, can-land) pour les + * tests dans un navigateur. */ +function addHit( + hits: Element, + place: Place, + decor: BoardDecor, + box: { x: number; y: number; width: number; height: number }, +): void { + const classes = ["hit"]; + if (decor.movable.has(place)) { + classes.push("can-move"); + } + if (decor.targets.has(place)) { + classes.push("can-land"); + } + add(hits, "rect", { class: classes.join(" "), "data-place": place, ...box }); +} diff --git a/app/src/ui/help-text.ts b/app/src/ui/help-text.ts new file mode 100644 index 0000000..4b4ccc7 --- /dev/null +++ b/app/src/ui/help-text.ts @@ -0,0 +1,91 @@ +// Lecture des textes de l'aide : les scénarios (docs/scenarios/) et les +// fiches du glossaire (docs/glossaire/), copiés tels quels dans le site par +// make site (ADR 0008 : une seule source). Fonctions pures, testées sous Node. +// +// Copyright (C) 2026 les auteurs d'Ustagammon. +// Licence : GPLv3 ou ultérieure (voir LICENSE). + +/** Un scénario, mis en forme comme une page d'aide. */ +export interface HelpFeature { + readonly title: string; + /** Paragraphes d'introduction. */ + readonly description: readonly string[]; + readonly scenarios: readonly HelpScenario[]; +} + +export interface HelpScenario { + readonly title: string; + readonly description: readonly string[]; + /** Les étapes, mot-clé compris : « Quand vous prenez ». */ + readonly steps: readonly string[]; +} + +const STEP = /^(Étant donnée?s?|Etant donnée?s?|Soit|Quand|Lorsque|Alors|Donc|Et|Mais)\s/; +const SCENARIO = /^(Scénario|Exemple|Contexte)\s*:\s*(.*)$/; +const FEATURE = /^Fonctionnalité\s*:\s*(.*)$/; + +/** + * Lit un fichier Gherkin en français (fichiers .feature). Les lignes de texte + * libre consécutives forment un paragraphe ; les commentaires (#) sont + * ignorés. + */ +export function parseFeature(text: string): HelpFeature { + let title = ""; + const description: string[] = []; + const scenarios: { title: string; description: string[]; steps: string[] }[] = []; + let paragraph: string[] = []; + + // Le paragraphe en cours rejoint la description de la fonctionnalité ou + // du dernier scénario. + const endParagraph = () => { + if (paragraph.length > 0) { + (scenarios.at(-1)?.description ?? description).push(paragraph.join(" ")); + paragraph = []; + } + }; + + for (const raw of text.split("\n")) { + const line = raw.trim(); + const feature = FEATURE.exec(line); + const scenario = SCENARIO.exec(line); + if (line === "" || line.startsWith("#")) { + endParagraph(); + } else if (feature) { + endParagraph(); + title = feature[1] ?? ""; + } else if (scenario) { + endParagraph(); + scenarios.push({ title: scenario[2] ?? "", description: [], steps: [] }); + } else if (STEP.test(line) && scenarios.length > 0) { + endParagraph(); + scenarios.at(-1)?.steps.push(line); + } else { + paragraph.push(line); + } + } + endParagraph(); + return { title, description, scenarios }; +} + +/** Les fiches citées par l'index du glossaire (README.md), dans l'ordre : + * les liens « [Terme](fichier.md) » de la section « Termes ». */ +export function parseGlossaryIndex(readme: string): string[] { + const section = readme.split(/^## Termes$/m)[1] ?? ""; + return [...section.matchAll(/\]\(([a-z0-9-]+\.md)\)/g)].map((match) => match[1] ?? ""); +} + +/** Une fiche du glossaire : le terme et sa phrase « En bref ». */ +export interface GlossaryEntry { + readonly term: string; + readonly brief: string; +} + +/** Lit une fiche du glossaire ; null si elle n'a pas la forme attendue. */ +export function parseGlossaryEntry(text: string): GlossaryEntry | null { + const term = /^term: (.+)$/m.exec(text)?.[1]?.trim(); + const brief = /^\*\*En bref\*\* : (.+)$/m.exec(text)?.[1]?.trim(); + if (term === undefined || brief === undefined) { + return null; + } + return { term, brief: brief.replaceAll("**", "") }; +} diff --git a/app/src/ui/help.ts b/app/src/ui/help.ts new file mode 100644 index 0000000..e064764 --- /dev/null +++ b/app/src/ui/help.ts @@ -0,0 +1,102 @@ +// La page d'aide : le scénario « Jouer une partie contre l'ordinateur » et le +// glossaire, lus à l'exécution dans les copies de docs/ faites par make site +// (ADR 0008). Rien n'est recopié dans le code. +// +// Copyright (C) 2026 les auteurs d'Ustagammon. +// Licence : GPLv3 ou ultérieure (voir LICENSE). + +import { + type GlossaryEntry, + type HelpFeature, + parseFeature, + parseGlossaryEntry, + parseGlossaryIndex, +} from "./help-text.js"; +import { TEXTS } from "./texts.js"; + +/** Les scénarios montrés dans l'aide, dans l'ordre. */ +const FEATURES = ["docs/scenarios/jouer-une-partie.feature"]; +const GLOSSARY = "docs/glossaire/"; + +async function fetchText(url: string): Promise { + const response = await fetch(url); + if (!response.ok) { + throw new Error(`${url} : ${response.status}`); + } + return response.text(); +} + +async function loadGlossary(): Promise { + const files = parseGlossaryIndex(await fetchText(`${GLOSSARY}README.md`)); + const texts = await Promise.all(files.map((file) => fetchText(GLOSSARY + file))); + return texts.map(parseGlossaryEntry).filter((entry) => entry !== null); +} + +function element( + parent: HTMLElement, + tag: K, + text?: string, +): HTMLElementTagNameMap[K] { + const child = document.createElement(tag); + if (text !== undefined) { + child.textContent = text; + } + parent.append(child); + return child; +} + +function renderFeature(parent: HTMLElement, feature: HelpFeature): void { + element(parent, "h2", feature.title); + for (const paragraph of feature.description) { + element(parent, "p", paragraph); + } + for (const scenario of feature.scenarios) { + element(parent, "h3", scenario.title); + for (const paragraph of scenario.description) { + element(parent, "p", paragraph); + } + const steps = element(parent, "ul"); + steps.className = "steps"; + for (const step of scenario.steps) { + element(steps, "li", step); + } + } +} + +function renderGlossary(parent: HTMLElement, entries: readonly GlossaryEntry[]): void { + element(parent, "h2", TEXTS.glossaryTitle); + const list = element(parent, "dl"); + list.className = "glossary"; + for (const entry of entries) { + element(list, "dt", entry.term); + element(list, "dd", entry.brief); + } +} + +/** Remplit container avec l'aide (une seule fois ; les appels suivants ne + * font rien). */ +export async function showHelp(container: HTMLElement): Promise { + if (container.dataset.loaded === "true") { + return; + } + container.replaceChildren(); + const status = element(container, "p", TEXTS.helpLoading); + try { + const [features, glossary] = await Promise.all([ + Promise.all(FEATURES.map(async (url) => parseFeature(await fetchText(url)))), + loadGlossary(), + ]); + status.remove(); + for (const feature of features) { + renderFeature(container, feature); + } + renderGlossary(container, glossary); + const about = element(container, "p", `${TEXTS.about} `); + about.className = "about"; + const link = element(about, "a", TEXTS.engineTestLink); + link.href = "moteur/"; + container.dataset.loaded = "true"; + } catch { + status.textContent = TEXTS.helpFailed; + } +} diff --git a/app/src/ui/texts.ts b/app/src/ui/texts.ts new file mode 100644 index 0000000..5a177df --- /dev/null +++ b/app/src/ui/texts.ts @@ -0,0 +1,91 @@ +// Les textes de l'interface (ADR 0007, point 5), en français, avec les mots +// du glossaire (docs/glossaire/). Le code n'écrit aucun texte lui-même : il +// prend tout ici. +// +// Copyright (C) 2026 les auteurs d'Ustagammon. +// Licence : GPLv3 ou ultérieure (voir LICENSE). + +import type { ResultKind } from "../domain/game/engine.js"; + +/** Un jet écrit « 6-4 ». */ +function roll(dice: readonly number[]): string { + return dice.join("-"); +} + +const RESULT_KIND: Record = { + single: "la partie simple", + gammon: "un gammon", + backgammon: "un backgammon", +}; + +function points(n: number): string { + return n > 1 ? `${n} points` : `${n} point`; +} + +/** Une durée en secondes, avec une décimale : « 0,3 s ». */ +function seconds(ms: number): string { + return `${(ms / 1000).toFixed(1).replace(".", ",")} s`; +} + +export const TEXTS = { + title: "Ustagammon", + boardLabel: "Plateau de backgammon", + + // Boutons + roll: "Lancer les dés", + double: "Doubler", + take: "Prendre", + drop: "Passer", + cancel: "Annuler", + newGame: "Nouvelle partie", + help: "Aide", + back: "Retour à la partie", + + // Chargement + loading: "Chargement du moteur…", + engineFailed: (message: string) => `Le moteur n'a pas pu se charger : ${message}`, + actionFailed: (message: string) => `Erreur : ${message}`, + confirmNewGame: "Abandonner la partie en cours et en commencer une nouvelle ?", + + // Déroulement + opening: (player: number, computer: number) => + `Jet d'ouverture : vous ${player}, l'ordinateur ${computer}. ${ + player > computer ? "Vous commencez." : "L'ordinateur commence." + }`, + yourRoll: "À vous : doublez ou lancez les dés.", + yourRollNoDouble: "À vous : lancez les dés.", + yourMove: (dice: readonly number[]) => + `Jouez ${roll(dice)} : touchez un pion, puis sa case d'arrivée.`, + pickTarget: "Touchez une case d'arrivée en surbrillance, ou un autre pion.", + youCannotMove: (dice: readonly number[]) => + `Vous ne pouvez pas jouer ${roll(dice)} : votre tour passe.`, + thinking: "L'ordinateur réfléchit…", + computerMoved: (dice: readonly number[], move: string, ms: number) => + `L'ordinateur a joué ${roll(dice)} : ${move} (${seconds(ms)}).`, + computerCannotMove: (dice: readonly number[]) => + `L'ordinateur ne peut pas jouer ${roll(dice)}.`, + computerDoubles: "L'ordinateur vous propose de doubler : prenez ou passez.", + computerTakes: (value: number) => + `L'ordinateur prend : la partie vaut maintenant ${points(value)}.`, + youTook: (value: number) => + `Vous prenez : la partie vaut maintenant ${points(value)}, et le videau est chez vous.`, + + // Fin de partie + youWin: (kind: ResultKind, n: number) => `Vous gagnez ${RESULT_KIND[kind]} : ${points(n)}.`, + youLose: (kind: ResultKind, n: number) => + `L'ordinateur gagne ${RESULT_KIND[kind]} : ${points(n)} pour lui.`, + computerDropped: (n: number) => `L'ordinateur passe : vous gagnez ${points(n)}.`, + youDropped: (n: number) => `Vous passez : l'ordinateur gagne ${points(n)}.`, + + // Sous le plateau + pipCounts: (player: number, computer: number) => + `Compte de points : vous ${player}, l'ordinateur ${computer}`, + + // Aide + helpTitle: "Aide", + glossaryTitle: "Glossaire", + helpLoading: "Chargement de l'aide…", + helpFailed: "L'aide n'a pas pu se charger.", + about: "Ustagammon est un logiciel libre (GPLv3). L'ordinateur joue avec GNU Backgammon.", + engineTestLink: "Test du moteur", +} as const; diff --git a/app/tests/ui.test.ts b/app/tests/ui.test.ts new file mode 100644 index 0000000..cfc1218 --- /dev/null +++ b/app/tests/ui.test.ts @@ -0,0 +1,145 @@ +// Tests de ce qui, dans l'interface et le stockage, se vérifie sans +// navigateur : le plateau à dessiner, les mises en évidence, la lecture de +// l'aide, l'enregistrement de la partie. +// +// Copyright (C) 2026 les auteurs d'Ustagammon. +// Licence : GPLv3 ou ultérieure (voir LICENSE). + +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { test } from "node:test"; +import type { BoardSide } from "../src/domain/game/engine.js"; +import { appendEvent, EMPTY_JOURNAL } from "../src/domain/game/events.js"; +import { START_POSITION } from "../src/domain/game/position.js"; +import { createGameStore } from "../src/storage/game-store.js"; +import { boardModel, moveTargets, placeOf, pointSlot } from "../src/ui/board-model.js"; +import { parseFeature, parseGlossaryEntry, parseGlossaryIndex } from "../src/ui/help-text.js"; +import { TEXTS } from "../src/ui/texts.js"; + +// Compilé dans build/web/tests/ : la racine du dépôt est trois dossiers plus haut. +const root = new URL("../../../", import.meta.url); +const read = (path: string) => readFileSync(new URL(path, root), "utf8"); + +function side(points: Record, extra: Partial = {}): BoardSide { + const all = Array.from({ length: 24 }, (_, i) => points[i + 1] ?? 0); + return { points: all, bar: 0, off: 0, pipCount: 0, ...extra }; +} + +test("le plateau est vu du joueur : la case q de l'ordinateur est sa case 25 - q", () => { + const model = boardModel({ + player: side({ 6: 5, 8: 3 }, { bar: 1, pipCount: 100 }), + computer: side({ 6: 5, 24: 2 }, { off: 3, pipCount: 90 }), + }); + assert.equal(model.points.length, 24); + assert.deepEqual(model.points[5], { place: "p6", number: 6, owner: "player", count: 5 }); + assert.deepEqual(model.points[18], { place: "p19", number: 19, owner: "computer", count: 5 }); + assert.deepEqual(model.points[0], { place: "p1", number: 1, owner: "computer", count: 2 }); + assert.deepEqual(model.points[1], { place: "p2", number: 2, owner: null, count: 0 }); + assert.deepEqual(model.bar, { player: 1, computer: 0 }); + assert.deepEqual(model.off, { player: 0, computer: 3 }); + assert.deepEqual(model.pipCount, { player: 100, computer: 90 }); +}); + +test("cases, barre et sortie de chaque camp", () => { + assert.equal(placeOf("player", 8), "p8"); + assert.equal(placeOf("computer", 8), "p17"); + assert.equal(placeOf("player", 25), "bar-player"); + assert.equal(placeOf("computer", 0), "off-computer"); +}); + +test("disposition : 1 à 12 en bas de droite à gauche, 13 à 24 en haut de gauche à droite", () => { + assert.deepEqual(pointSlot(1), { top: false, column: 11 }); + assert.deepEqual(pointSlot(7), { top: false, column: 5 }); + assert.deepEqual(pointSlot(12), { top: false, column: 0 }); + assert.deepEqual(pointSlot(13), { top: true, column: 0 }); + assert.deepEqual(pointSlot(24), { top: true, column: 11 }); +}); + +test("les demi-coups possibles donnent les pions jouables et leurs cases d'arrivée", () => { + const targets = moveTargets([ + { from: 8, to: 5 }, + { from: 8, to: 7 }, + { from: 25, to: 22 }, + { from: 3, to: 0 }, + ]); + assert.deepEqual([...targets.keys()], ["p8", "bar-player", "p3"]); + assert.deepEqual([...(targets.get("p8")?.keys() ?? [])], ["p5", "p7"]); + assert.deepEqual(targets.get("p3")?.get("off-player"), { from: 3, to: 0 }); +}); + +test("l'aide lit le scénario tel qu'il est écrit dans docs/scenarios/", () => { + const feature = parseFeature(read("docs/scenarios/jouer-une-partie.feature")); + assert.equal(feature.title, "Jouer une partie contre l'ordinateur"); + assert.match(feature.description[0] ?? "", /^Vous jouez une partie complète/); + assert.equal(feature.scenarios[0]?.title, "Commencer une partie"); + assert.deepEqual(feature.scenarios[0]?.steps, [ + "Quand vous commencez une nouvelle partie", + "Alors les pions sont en position de départ", + "Et le premier jet de dés désigne qui commence", + ]); + const refused = feature.scenarios.find((s) => s.title === "Un coup interdit est refusé"); + assert.match(refused?.description[0] ?? "", /^La case 12 est tenue/); + assert.equal(refused?.steps[0], "Étant donné que c'est à vous de jouer avec le jet 3-1"); + // Chaque scénario a des étapes : aucune n'a été prise pour du texte libre. + for (const scenario of feature.scenarios) { + assert.ok(scenario.steps.length >= 2, scenario.title); + } +}); + +test("l'aide lit chaque fiche de l'index du glossaire", () => { + const files = parseGlossaryIndex(read("docs/glossaire/README.md")); + assert.ok(files.includes("videau.md")); + assert.ok(!files.includes("README.md")); + for (const file of files) { + const entry = parseGlossaryEntry(read(`docs/glossaire/${file}`)); + assert.ok(entry !== null, file); + assert.ok(entry.brief.length > 10, file); + } + assert.deepEqual(parseGlossaryEntry(read("docs/glossaire/videau.md")), { + term: "Videau", + brief: "le dé à doubler, marqué 2, 4, 8, 16, 32 et 64 : il indique la valeur actuelle de la partie.", + }); + assert.equal(parseGlossaryEntry("pas une fiche"), null); +}); + +test("la partie enregistrée se relit ; un enregistrement illisible est ignoré", () => { + const data = new Map(); + const storage = { + getItem: (key: string) => data.get(key) ?? null, + setItem: (key: string, value: string) => void data.set(key, value), + }; + const store = createGameStore(storage); + assert.equal(store.load(), null); + const journal = appendEvent(EMPTY_JOURNAL, { + type: "GameStarted", + position: START_POSITION, + cube: { value: 1, owner: null }, + firstTurn: null, + }); + store.save(journal); + assert.deepEqual(store.load(), journal); + for (const key of data.keys()) { + data.set(key, "{pas du JSON"); + } + assert.equal(store.load(), null); + // Un stockage plein ou interdit ne casse pas la partie. + const full = createGameStore({ + getItem: () => null, + setItem: () => { + throw new Error("plein"); + }, + }); + assert.doesNotThrow(() => full.save(journal)); +}); + +test("les textes de fin de partie emploient les mots du glossaire", () => { + assert.equal(TEXTS.youWin("gammon", 2), "Vous gagnez un gammon : 2 points."); + assert.equal( + TEXTS.youLose("single", 1), + "L'ordinateur gagne la partie simple : 1 point pour lui.", + ); + assert.equal( + TEXTS.computerMoved([6, 4], "24/18 13/9", 312), + "L'ordinateur a joué 6-4 : 24/18 13/9 (0,3 s).", + ); +}); diff --git a/deploy/Caddyfile b/deploy/Caddyfile index 4d4e338..d533f28 100644 --- a/deploy/Caddyfile +++ b/deploy/Caddyfile @@ -30,10 +30,22 @@ header X-Content-Type-Options "nosniff" header Referrer-Policy "no-referrer" + # Politique de sécurité du contenu : tout vient du site lui-même, sans + # script ni style en ligne. 'wasm-unsafe-eval' autorise la compilation du + # moteur WebAssembly (et rien d'autre : ni eval ni new Function). L'en-tête + # s'applique aussi au Web Worker du moteur et au service worker. + header Content-Security-Policy "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self'; img-src 'self'; connect-src 'self'; worker-src 'self'; manifest-src 'self'; object-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'" + # Données du moteur (Emscripten) : extension inconnue de Caddy, qui # devinerait text/xml d'après le début du fichier. @engine_data path *.data header @engine_data Content-Type "application/octet-stream" + # Textes de l'aide (copies de docs/), lus par l'application. + @markdown path *.md + header @markdown Content-Type "text/markdown; charset=utf-8" + @gherkin path *.feature + header @gherkin Content-Type "text/plain; charset=utf-8" + file_server } diff --git a/docs/deploiement.md b/docs/deploiement.md index 35bb54f..51f3e6e 100644 --- a/docs/deploiement.md +++ b/docs/deploiement.md @@ -1,7 +1,9 @@ # Déploiement Ustagammon est un site statique (PWA, ADR 0009) : des fichiers HTML, -JavaScript, le moteur en WebAssembly et ses données. Il est publié sur +JavaScript, le moteur en WebAssembly et ses données. L'application est à la +racine ; les pages de test du moteur de l'étape 0b restent publiées sous +`/moteur/` (test du moteur) et `/moteur/bench.html` (mesure). Il est publié sur CapRover, machine `sans-pub` (https://captain.cloud.sans.pub), app `ustagammon`, sur https://ustagammon.sans.pub. @@ -46,31 +48,67 @@ make deploy-prod`. ## Ce qui est publié -- `make site` assemble `build/site/` : le contenu de `web/` à la racine, le - moteur (`engine.mjs`, `engine.wasm`, `engine.data`, `positions.json`) sous - `wasm/`, et `version.json` avec le commit publié. +- `make site` assemble `build/site/` (9 Mo) : + - le contenu de `web/` à la racine : la page de l'application + (`index.html`, `app.css`), le manifeste (`manifest.webmanifest`), les + icônes (`icons/`), le service worker (`sw.js`), le Web Worker du moteur + (`engine-worker.mjs`, `engine-wasm.mjs`) et les pages de test sous + `moteur/` ; + - l'application TypeScript compilée (`build/web/src/`) sous `app/` ; + - le moteur (`engine.mjs`, `engine.wasm`, `engine.data`, `positions.json`) + sous `wasm/` ; + - des copies de `docs/glossaire/` et `docs/scenarios/` sous `docs/`, lues + par l'aide de l'application (ADR 0008) ; + - `version.json` avec le commit publié ; + - enfin, `scripts/service-worker.sh` écrit en tête de `sw.js` la liste de + tous ces fichiers et une empreinte de leur contenu. - `make package` ajoute `deploy/` et produit `build/ustagammon.tar` (environ - 9 Mo). + 9 Mo, 7,3 Mo transférés grâce à la compression). - `tests/test_site.sh` (dans `make test`) construit l'image de production en - local, la lance et vérifie ce que reçoit un navigateur : pages, types MIME - (`application/wasm` pour le moteur), compression gzip des données, en-têtes, - et commit de `version.json`. + local, la lance et vérifie ce que reçoit un navigateur : application, pages + de test, aide, manifeste et icônes, liste du service worker, types MIME + (`application/wasm` pour le moteur), compression gzip des données, + en-têtes dont la politique de sécurité, et commit de `version.json`. +- `make test-browser` (hors de `make test`, plusieurs minutes) joue une + partie complète par l'interface dans Chromium et Firefox sans écran, sur + la même image, puis vérifie la reprise, l'aide et le hors ligne. + +**Hors ligne et mises à jour.** Le service worker met tous les fichiers du +site en cache à la première visite (environ 9 Mo), puis sert tout depuis ce +cache, avec ou sans réseau. À chaque publication, l'empreinte écrite dans +`sw.js` change dès qu'un fichier a changé : le navigateur, qui revérifie +`sw.js` à chaque ouverture, installe la nouvelle version (tout le site est +retéléchargé), qui prend la main et efface l'ancien cache. La page déjà +ouverte continue sur l'ancienne version ; le lancement suivant utilise la +nouvelle. Une publication sans changement de fichier garde le même `sw.js` : +rien n'est retéléchargé. Le service worker exige https (ou `localhost`) : +avec `make deploy`, en http sur le réseau local, l'application fonctionne, +mais pas hors ligne. Réglages du serveur (`deploy/Caddyfile`) : compression zstd ou gzip, y compris pour les données du moteur ; `Cache-Control: no-cache` (les noms de fichiers ne changent pas d'une version à l'autre, le navigateur revalide chaque fichier) ; -`X-Content-Type-Options: nosniff` ; `Referrer-Policy: no-referrer`. +`X-Content-Type-Options: nosniff` ; `Referrer-Policy: no-referrer` ; et une +politique de sécurité du contenu (`Content-Security-Policy`) : tout vient du +site lui-même, aucun script ni style en ligne, aucun `eval`, seule la +compilation du WebAssembly est permise (`'wasm-unsafe-eval'`). Elle est +vérifiée par `tests/test_site.sh` (en-tête présent) et par +`make test-browser` (aucune violation signalée par Chromium ni Firefox +pendant une partie). ## Vérifier ```bash curl -s https://ustagammon.sans.pub/version.json # commit publié curl -sI https://ustagammon.sans.pub/wasm/engine.wasm # application/wasm +curl -sI https://ustagammon.sans.pub/ | grep -i content-security # CSP +curl -s https://ustagammon.sans.pub/sw.js | head -2 # version du cache ssh sans.pub 'sudo docker service ps srv-captain--ustagammon --format "{{.Image}} {{.CurrentState}}" | head -2' ``` -Puis ouvrir le site sur le téléphone : la page de test doit afficher « Moteur -prêt ». +Puis ouvrir le site sur le téléphone : le plateau s'affiche et une consigne +remplace « Chargement du moteur… ». La page de test du moteur +(https://ustagammon.sans.pub/moteur/) doit afficher « Moteur prêt ». ## Retour arrière diff --git a/docs/guide-test-etape-0.md b/docs/guide-test-etape-0.md index 08ea57c..48caa7a 100644 --- a/docs/guide-test-etape-0.md +++ b/docs/guide-test-etape-0.md @@ -1,38 +1,124 @@ -# Guide de test : le moteur sur le Fairphone 4 (étape 0b) +# Guide de test : Ustagammon sur le Fairphone 4 (étape 0) Document temporaire de l'étape 0 : il sera supprimé avec `docs/etape-0.md`. +Deux tests sur le téléphone : + +- **Étape 0d : jouer une partie** (section suivante), sur le site publié ; +- **Étape 0b : mesurer le moteur** (déjà fait le 2026-11-02), sur une page servie par + le PC. + +## Étape 0d : jouer une partie sur le téléphone + +Le but : installer l'application sur le Fairphone 4, jouer une partie +complète contre l'ordinateur, vérifier qu'elle marche sans réseau, et +rapporter ce que vous avez constaté. Comptez une demi-heure. + +### Ouvrir et installer l'application + +1. Sur le Fairphone, ouvrez Chrome et allez sur https://ustagammon.sans.pub. +2. Attendez que le plateau s'affiche et que le message « Chargement du + moteur… » laisse place à une consigne (par exemple « À vous : lancez les + dés. » ou « L'ordinateur réfléchit… »). La première fois, le moteur se + télécharge (environ 7 Mo) : quelques secondes en Wi-Fi. +3. Installez l'application : menu de Chrome (les trois points), puis + « Installer l'application » ou « Ajouter à l'écran d'accueil ». Une icône + (deux pions sur du bois) apparaît sur l'écran d'accueil. +4. Fermez Chrome et ouvrez Ustagammon depuis cette icône : l'application + s'ouvre en plein écran, sans barre d'adresse. + +### Jouer une partie complète + +Suivez le scénario « Jouer une partie contre l'ordinateur » +(`docs/scenarios/jouer-une-partie.feature`), qui est aussi l'aide de +l'application : bouton « Aide », en haut. + +1. Une partie commence d'elle-même au premier lancement, par le jet + d'ouverture (un dé chacun) : le message dit qui commence. « Nouvelle + partie », en haut, en recommence une. +2. Quand c'est à vous : touchez « Lancer les dés » (ou « Doubler », pour + proposer de doubler avant de lancer). +3. Pour jouer : touchez un pion entouré de vert, puis l'une des cases + d'arrivée en surbrillance ; recommencez pour chaque dé. Pour sortir un + pion, touchez le bac à droite, en bas. « Annuler » remet les pions du coup + en cours à leur place. +4. Le coup est envoyé dès qu'il est complet ; l'ordinateur joue ensuite, et + le message dit son coup et son temps de calcul, par exemple + « L'ordinateur a joué 6-4 : 24/18 13/9 (0,3 s) ». Les petits points + orange marquent ses cases de départ et d'arrivée (dans sa numérotation : + sa case 24 est votre case 1). +5. Si l'ordinateur vous propose de doubler : « Prendre » ou « Passer ». + Essayez au moins une fois de doubler vous-même. +6. En fin de partie, le message donne le gagnant, la sorte de victoire + (simple, gammon ou backgammon) et les points. + +En cours de partie, fermez l'application (balayez-la dans la liste des +applications ouvertes), puis rouvrez-la : la partie doit reprendre là où vous +en étiez. + +### Vérifier le hors ligne + +1. Après avoir ouvert l'application au moins une fois (attendez une + vingtaine de secondes après le premier chargement, le temps que tout soit + mis de côté), fermez-la. +2. Passez le téléphone en mode avion. +3. Rouvrez Ustagammon depuis son icône : le plateau, l'ordinateur et l'aide + doivent fonctionner comme avant. +4. Repassez en mode normal. + +### Ce qu'il faut rapporter + +Dans la conversation avec Claude Code : + +- les **temps des coups de l'ordinateur** : ceux affichés dans les messages + (le plus long que vous avez vu), et votre ressenti (attente gênante ou + non) ; +- si la partie est allée **jusqu'au bout**, et son résultat ; +- si la **reprise** après fermeture et le **mode avion** ont marché ; +- tout ce qui gêne : plateau trop petit, case difficile à toucher, texte + incompréhensible, bouton qui ne répond pas, couleurs peu lisibles (thème + clair ou sombre du téléphone) ; +- une **capture d'écran** (bouton marche + volume bas) de tout problème. + +Si vous le pouvez, refaites l'essai dans Firefox sur le téléphone (sans +l'installer) et dites si les temps de l'ordinateur sont nettement plus longs. + +## Étape 0b : mesurer le moteur + Le but : ouvrir sur le téléphone une page web où le moteur de backgammon calcule, puis lancer une mesure et coller son rapport dans la conversation. Comptez un quart d'heure. -## 1. Préparer le PC +### 1. Préparer le PC 1. Ouvrez un terminal dans le dossier du projet (`~/Code/ustagammon`). 2. Si ce n'est pas déjà fait, tapez `make setup` puis Entrée. Cela prépare les outils. La première fois, c'est long ; ensuite, c'est immédiat. 3. Le PC et le téléphone doivent être sur **le même Wi-Fi** (la même box). -## 2. Lancer la page +### 2. Lancer la page -1. Tapez `make deploy-web` puis Entrée. +1. Tapez `make deploy` puis Entrée. 2. La première fois, le moteur se compile : quelques minutes. 3. Le terminal affiche deux adresses, par exemple : ``` - Page de test : http://192.168.1.21:8080/ - Page de mesure : http://192.168.1.21:8080/bench.html + Application : http://192.168.1.21:8080/ + Test du moteur : http://192.168.1.21:8080/moteur/ + Page de mesure : http://192.168.1.21:8080/moteur/bench.html ``` Vos chiffres peuvent être différents : notez ceux de votre écran. 4. Laissez ce terminal ouvert pendant tout le test. Pour arrêter la page à la fin : Ctrl+C dans le terminal. -## 3. Essayer un meilleur coup +### 3. Essayer un meilleur coup 1. Sur le Fairphone, ouvrez le navigateur (Chrome ou Firefox). -2. Tapez l'adresse de la **page de test** dans la barre d'adresse, exactement - comme affichée (avec `http://` et `:8080`). +2. Tapez l'adresse du **test du moteur** dans la barre d'adresse, exactement + comme affichée (avec `http://` et `:8080`). Les pages de test sont aussi + publiées : https://ustagammon.sans.pub/moteur/ et + https://ustagammon.sans.pub/moteur/bench.html. 3. Attendez le message « Moteur prêt, chargé en … ms ». 4. Dans « Exemple », choisissez une position, par exemple « middlegame-1 ». 5. Dans « Niveau », choisissez « fort (2 plis) ». @@ -40,7 +126,7 @@ Comptez un quart d'heure. 7. La page affiche le coup conseillé et le **temps de calcul**. Notez ce temps si vous voulez, il sera aussi dans le rapport. -## 4. Lancer la mesure +### 4. Lancer la mesure 1. En bas de la page, touchez « Page de mesure » (ou tapez l'adresse de la page de mesure). @@ -50,7 +136,7 @@ Comptez un quart d'heure. 4. Un tableau résume les résultats : « OK » si le seuil est respecté, « dépassé » sinon. -## 5. Envoyer le rapport +### 5. Envoyer le rapport 1. Touchez « Copier les résultats ». 2. Si la page dit « Rapport copié », c'est bon. @@ -65,7 +151,7 @@ sont stables. Si les deux navigateurs sont installés, faites la mesure dans Chrome **et** dans Firefox : sur le PC, Firefox s'est montré nettement plus lent, et le rapport indique le navigateur utilisé. -## Si la page ne s'ouvre pas +### Si la page ne s'ouvre pas - **Vérifiez l'adresse** : `http://` (et non `https://`), les chiffres exacts, et `:8080` à la fin. @@ -73,7 +159,7 @@ lent, et le rapport indique le navigateur utilisé. pas sur la 4G ni sur un Wi-Fi invité. - **Vérifiez que la page tourne** : sur le PC, ouvrez `http://localhost:8080/` dans un navigateur. Si elle ne s'ouvre pas non - plus, relancez `make deploy-web` et lisez le message d'erreur. + plus, relancez `make deploy` et lisez le message d'erreur. - **Le pare-feu du PC** peut bloquer le téléphone. S'il est actif (`sudo ufw status` affiche « Status: active »), autorisez le port le temps du test : `sudo ufw allow 8080/tcp`, puis à la fin `sudo ufw delete allow @@ -82,4 +168,4 @@ lent, et le rapport indique le navigateur utilisé. collez-le dans la conversation. Essayez aussi l'autre navigateur (Chrome ou Firefox), à jour. - **Le port 8080 est déjà pris** : un autre programme l'utilise. Lancez - `make deploy-web WEB_PORT=8081` et utilisez `:8081` dans l'adresse. + `make deploy WEB_PORT=8081` et utilisez `:8081` dans l'adresse. diff --git a/engine-wasm/bench.mjs b/engine-wasm/bench.mjs index dc2b53c..f45b59c 100644 --- a/engine-wasm/bench.mjs +++ b/engine-wasm/bench.mjs @@ -10,7 +10,7 @@ import os from "node:os"; import { fileURLToPath } from "node:url"; import { gzipSync } from "node:zlib"; import createEngineModule from "../build/wasm/engine.mjs"; -import { DOWNLOADED_FILES, formatReport, measureTimes } from "../web/benchmark.mjs"; +import { DOWNLOADED_FILES, formatReport, measureTimes } from "../web/moteur/benchmark.mjs"; import { commands, loadEngine } from "../web/engine-wasm.mjs"; const root = new URL("../", import.meta.url); diff --git a/scripts/make-icons.py b/scripts/make-icons.py new file mode 100644 index 0000000..bd42c57 --- /dev/null +++ b/scripts/make-icons.py @@ -0,0 +1,73 @@ +#!/usr/bin/env python3 +# Produit les icônes PNG de l'application (web/icons/icon-192.png et +# icon-512.png) à partir des formes de web/icons/icon.svg : un fond de bois +# et deux pions. Icônes provisoires, en attendant l'identité définitive. +# +# Python seul, sans bibliothèque : chaque pixel est échantillonné 4 × 4 fois +# pour adoucir les bords. À relancer seulement si les formes changent, dans +# la chaîne C (Python 3) : +# docker run --rm --user "$(id -u):$(id -g)" -v "$PWD:/src" -w /src \ +# ustagammon/build-c:2 python3 scripts/make-icons.py +# +# Copyright (C) 2026 les auteurs d'Ustagammon. +# Licence : GPLv3 ou ultérieure (voir LICENSE). + +import struct +import zlib + +BACKGROUND = (0x6B, 0x44, 0x26) +# Pions dessinés dans l'ordre : (centre x, centre y, rayon, remplissage, +# bord), en unités d'une icône de 512 pixels ; bord de 10 unités. +CHECKERS = [ + (300, 300, 120, (0x2B, 0x22, 0x1C), (0x0D, 0x0A, 0x08)), + (212, 212, 120, (0xFB, 0xF6, 0xEA), (0x7D, 0x6A, 0x52)), +] +EDGE = 10 +SAMPLES = 4 + + +def color_at(x, y): + """Couleur d'un point de l'icône, en unités de 512.""" + color = BACKGROUND + for cx, cy, r, fill, edge in CHECKERS: + d2 = (x - cx) ** 2 + (y - cy) ** 2 + if d2 <= (r + EDGE / 2) ** 2: + color = fill if d2 <= (r - EDGE / 2) ** 2 else edge + return color + + +def render(size): + scale = 512 / size + rows = [] + for py in range(size): + row = bytearray([0]) # filtre PNG « aucun » + for px in range(size): + total = [0, 0, 0] + for sy in range(SAMPLES): + for sx in range(SAMPLES): + x = (px + (sx + 0.5) / SAMPLES) * scale + y = (py + (sy + 0.5) / SAMPLES) * scale + for i, c in enumerate(color_at(x, y)): + total[i] += c + row += bytes(round(t / SAMPLES**2) for t in total) + rows.append(bytes(row)) + return b"".join(rows) + + +def png(size, pixels): + def chunk(kind, data): + body = kind + data + return struct.pack(">I", len(data)) + body + struct.pack(">I", zlib.crc32(body)) + + header = struct.pack(">IIBBBBB", size, size, 8, 2, 0, 0, 0) # RVB, 8 bits + return ( + b"\x89PNG\r\n\x1a\n" + + chunk(b"IHDR", header) + + chunk(b"IDAT", zlib.compress(pixels, 9)) + + chunk(b"IEND", b"") + ) + + +for size in (192, 512): + with open(f"web/icons/icon-{size}.png", "wb") as f: + f.write(png(size, render(size))) diff --git a/scripts/service-worker.sh b/scripts/service-worker.sh new file mode 100755 index 0000000..10d77c5 --- /dev/null +++ b/scripts/service-worker.sh @@ -0,0 +1,40 @@ +#!/usr/bin/env bash +# Complète le service worker d'un site assemblé (make site) : en tête de +# /sw.js (copie de web/sw.js), écrit la liste de tous les fichiers du +# site, à mettre en cache pour le hors ligne, et la version du site, une +# empreinte de leur contenu. +# +# Le navigateur compare sw.js octet par octet à chaque visite : le moindre +# fichier modifié change l'empreinte, donc sw.js, ce qui déclenche la mise à +# jour du cache. Un site identique garde le même sw.js : rien à +# retélécharger. +# +# Usage : scripts/service-worker.sh build/site + +set -euo pipefail + +site="${1:?usage : scripts/service-worker.sh }" +cd "$site" +[ -f sw.js ] || { echo "$site/sw.js absent" >&2; exit 1; } + +# Tous les fichiers sauf sw.js, triés pour que l'empreinte soit stable. +mapfile -t files < <(find . -type f ! -path ./sw.js | sed 's|^\./||' | LC_ALL=C sort) +version="$(sha256sum "${files[@]}" | sha256sum | cut -c1-16)" + +{ + echo "// Généré par scripts/service-worker.sh (make site) : ne pas modifier." + echo "const VERSION = \"$version\";" + echo "const FILES = [" + for f in "${files[@]}"; do + # Une page index.html est aussi demandée sous le nom de son dossier. + case "$f" in + index.html) echo ' "./",' ;; + */index.html) echo " \"${f%index.html}\"," ;; + esac + echo " \"$f\"," + done + echo "];" + echo + cat sw.js +} >sw.js.tmp +mv sw.js.tmp sw.js diff --git a/tests/browser.sh b/tests/browser.sh new file mode 100755 index 0000000..35b926f --- /dev/null +++ b/tests/browser.sh @@ -0,0 +1,48 @@ +#!/usr/bin/env bash +# Joue une partie complète par l'interface dans Chromium et Firefox sans +# écran (tests/browser/play.mjs), sur le site servi par l'image de production +# construite en local, comme tests/test_site.sh. Les captures d'écran vont +# dans build/browser-shots/. +# +# Outils : l'image de Playwright (navigateurs) et le paquet playwright, +# installé une fois par npm dans build/browser-tools/ (réseau nécessaire la +# première fois). Lent (plusieurs minutes) : lancé par make test-browser, +# pas par make test. + +set -euo pipefail +cd "$(dirname "$0")/.." + +image="${PLAYWRIGHT_IMAGE:?lancer via make test-browser}" +browsers="${BROWSERS:-chromium firefox}" + +unset MAKEFLAGS MFLAGS MAKELEVEL +make -s package >/dev/null + +site_image="ustagammon/site-test:local" +docker build -q -t "$site_image" build/deploy >/dev/null +container="$(docker run -d --rm -p 127.0.0.1::80 "$site_image")" +trap 'docker stop "$container" >/dev/null' EXIT +port="$(docker port "$container" 80/tcp | head -n1 | sed 's/.*://')" + +# Le paquet playwright, à la version des navigateurs de l'image. +tools=build/browser-tools +version="${image##*:v}" +version="${version%%-*}" +if [ ! -f "$tools/node_modules/playwright/package.json" ]; then + mkdir -p "$tools" + docker run --rm --user "$(id -u):$(id -g)" -e HOME=/tmp -v "$PWD/$tools:/tools" -w /tools \ + "$image" npm install --no-audit --no-fund --save-exact "playwright@$version" >/dev/null +fi + +rm -rf build/browser-shots +mkdir -p build/browser-shots +cp tests/browser/play.mjs "$tools/" +status=0 +for browser in $browsers; do + # Réseau de l'hôte : le site est sur 127.0.0.1. Le service worker + # demande un contexte sûr, ce qu'est localhost. + docker run --rm --init --ipc=host --network host --user "$(id -u):$(id -g)" -e HOME=/tmp \ + -v "$PWD/$tools:/tools" -v "$PWD/build/browser-shots:/shots" -w /tools "$image" \ + node play.mjs "http://localhost:$port/" "$browser" /shots || status=1 +done +exit "$status" diff --git a/tests/browser/play.mjs b/tests/browser/play.mjs new file mode 100644 index 0000000..34f7f71 --- /dev/null +++ b/tests/browser/play.mjs @@ -0,0 +1,215 @@ +// play.mjs -- joue une partie complète par l'interface, dans un vrai +// navigateur sans écran (Playwright), sur le site servi par l'image de +// production : dés, coups au toucher, coups de l'ordinateur, videau, reprise +// après rechargement, aide, puis hors ligne (service worker). +// +// Copyright (C) 2026 les auteurs d'Ustagammon. +// Licence : GPLv3 ou ultérieure (voir LICENSE). +// +// Usage : node play.mjs +// Lancé par tests/browser.sh, dans l'image de Playwright. Sort en erreur au +// premier problème, ou si la page signale une erreur (dont une violation de +// la politique de sécurité du contenu). + +import { chromium, firefox } from "playwright"; + +const [base, browserName, shots] = process.argv.slice(2); +const browserType = { chromium, firefox }[browserName]; +if (!base || !browserType || !shots) { + console.error("usage : node play.mjs "); + process.exit(2); +} + +const browser = await browserType.launch(); +const context = await browser.newContext({ + viewport: { width: 360, height: 740 }, + deviceScaleFactor: 2, + locale: "fr-FR", +}); +const page = await context.newPage(); +const problems = []; +page.on("pageerror", (error) => problems.push(`erreur de la page : ${error.message}`)); +page.on("console", (message) => { + if (message.type() === "error") { + problems.push(`console : ${message.text()}`); + } +}); +const log = (text) => console.log(`[${browserName}] ${text}`); +const shot = (name) => page.screenshot({ path: `${shots}/${browserName}-${name}.png` }); + +function check(condition, message) { + if (!condition) { + throw new Error(message); + } +} + +/** Les coups joués de chaque camp, d'après le journal enregistré. */ +async function savedJournal() { + return page.evaluate(() => JSON.parse(localStorage.getItem("ustagammon.current-game"))); +} + +function countMoves(journal, side) { + return journal.events.filter((e) => e.type === "MovePlayed" && e.side === side).length; +} + +/** + * Attend que le joueur ait quelque chose à faire, et dit quoi. (Pas de + * page.waitForFunction : Playwright y passe par eval, que la politique de + * sécurité du site interdit, à raison.) + */ +async function waitForPlayer() { + for (let i = 0; i < 600; i++) { + const todo = await page.evaluate(() => { + if (document.body.classList.contains("busy")) { + return null; + } + const usable = (id) => { + const b = document.getElementById(id); + return !b.hidden && !b.disabled; + }; + for (const id of ["again", "take", "roll"]) { + if (usable(id)) { + return id; + } + } + return document.querySelector(".hit.can-move") ? "move" : null; + }); + if (todo !== null) { + return todo; + } + await new Promise((resolve) => setTimeout(resolve, 100)); + } + throw new Error("rien à faire pour le joueur depuis 60 s"); +} + +const start = Date.now(); +await page.goto(base); +const first = await waitForPlayer(); +log(`moteur chargé, partie prête en ${Date.now() - start} ms (${first})`); +await shot("1-debut"); + +let doubled = false; +let selectionShot = false; +let reloaded = false; +let computerMs = []; +for (let actions = 0; ; actions++) { + check(actions < 800, "la partie ne finit pas"); + const todo = await waitForPlayer(); + const info = await page.textContent("#info"); + const timing = /\((\d+),(\d) s\)/.exec(info ?? ""); + if (timing) { + computerMs.push(Number(timing[1]) * 1000 + Number(timing[2]) * 100); + } + if (todo === "again") { + break; + } + if (todo === "take") { + log("l'ordinateur double : vous prenez"); + await shot("videau-propose"); + await page.click("#take"); + } else if (todo === "roll") { + if (!doubled && (await page.isEnabled("#double"))) { + doubled = true; + log("vous doublez"); + await page.click("#double"); + await waitForPlayer(); + const answer = (await page.textContent("#info")) || (await page.textContent("#prompt")); + log(`réponse : ${answer}`); + continue; + } + await page.click("#roll"); + } else { + await page.click(".hit.can-move"); + await page.waitForSelector(".hit.can-land"); + if (!selectionShot) { + selectionShot = true; + await shot("2-pion-touche"); + } + await page.click(".hit.can-land"); + } + + // Une fois, au milieu de la partie : fermer et rouvrir la page. + const journal = await savedJournal(); + if (!reloaded && countMoves(journal, "computer") >= 3 && countMoves(journal, "player") >= 3) { + reloaded = true; + await waitForPlayer(); + await shot("3-milieu-de-partie"); + const before = await savedJournal(); + const pips = await page.textContent("#pips"); + await page.reload(); + await waitForPlayer(); + const after = await savedJournal(); + check(after.head === before.head, "la partie n'a pas été reprise après rechargement"); + check((await page.textContent("#pips")) === pips, "plateau différent après rechargement"); + log(`partie reprise après rechargement (événement ${after.head})`); + } +} + +const journal = await savedJournal(); +const result = await page.textContent("#prompt"); +log( + `partie finie : ${result} (${countMoves(journal, "player")} coups du joueur, ` + + `${countMoves(journal, "computer")} de l'ordinateur, ${journal.events.length} événements)`, +); +check(countMoves(journal, "player") >= 3 && countMoves(journal, "computer") >= 3, "trop peu de coups"); +check(reloaded, "la reprise après rechargement n'a pas été essayée"); +if (computerMs.length > 0) { + log(`temps affichés des tours de l'ordinateur : max ${Math.max(...computerMs)} ms, ` + + `${computerMs.length} tours`); +} +await shot("4-fin-de-partie"); + +// Aide : scénario et glossaire lus dans les copies de docs/. +await page.click("#help-button"); +await page.waitForSelector(".glossary dt"); +const terms = await page.locator(".glossary dt").count(); +const scenarios = await page.locator("#help-content h3").count(); +check(terms >= 20 && scenarios >= 5, `aide incomplète : ${terms} termes, ${scenarios} scénarios`); +log(`aide : ${scenarios} scénarios, ${terms} termes du glossaire`); +await shot("5-aide"); +await page.click("#help-back"); + +// Thème sombre. +await page.emulateMedia({ colorScheme: "dark" }); +await page.click("#again"); +await waitForPlayer(); +await shot("6-sombre"); +await page.emulateMedia({ colorScheme: "light" }); + +// Hors ligne : une fois le cache du service worker rempli, la page se +// recharge sans réseau, et le moteur répond. +const cached = await page.evaluate(async () => { + await navigator.serviceWorker.ready; + for (let i = 0; i < 600; i++) { + const names = await caches.keys(); + if (names.length === 1) { + const cache = await caches.open(names[0]); + const keys = await cache.keys(); + if (keys.some((r) => r.url.endsWith("/wasm/engine.data"))) { + return keys.length; + } + } + await new Promise((resolve) => setTimeout(resolve, 100)); + } + return 0; +}); +check(cached > 0, "le service worker n'a pas mis le site en cache"); +await context.setOffline(true); +await page.reload(); +const offline = await waitForPlayer(); +if (offline === "roll") { + await page.click("#roll"); + await waitForPlayer(); +} +await page.click("#help-button"); +await page.waitForSelector(".glossary dt"); +await page.click("#help-back"); +log(`hors ligne : page rechargée depuis le cache (${cached} adresses), moteur et aide disponibles`); +await shot("7-hors-ligne"); + +await browser.close(); +if (problems.length > 0) { + console.error(problems.join("\n")); + process.exit(1); +} +log("aucune erreur dans la page"); diff --git a/tests/test_site.sh b/tests/test_site.sh index 42565cb..e394931 100755 --- a/tests/test_site.sh +++ b/tests/test_site.sh @@ -1,7 +1,8 @@ #!/usr/bin/env bash # Vérifie l'image de production (deploy/) avec le site assemblé (make package) : # construit l'image en local, la lance, et contrôle ce que reçoit un -# navigateur (pages, types MIME du moteur, compression, version). +# navigateur (application, pages de test du moteur, aide, manifeste, service +# worker, types MIME, compression, politique de sécurité, version). set -euo pipefail cd "$(dirname "$0")/.." @@ -37,12 +38,60 @@ check() { } check "" "text/html" -check "bench.html" "text/html" +check "app.css" "text/css" +check "app/main.js" "text/javascript" +check "app/ui/app.js" "text/javascript" +check "app/domain/game/game.js" "text/javascript" +check "manifest.webmanifest" "application/manifest\+json" +check "icons/icon-192.png" "image/png" +check "icons/icon-512.png" "image/png" +check "sw.js" "text/javascript" check "engine-worker.mjs" "text/javascript" check "wasm/engine.mjs" "text/javascript" check "wasm/engine.wasm" "application/wasm" check "wasm/engine.data" "application/octet-stream" check "version.json" "application/json" +# Pages de test du moteur (étape 0b). +check "moteur/" "text/html" +check "moteur/bench.html" "text/html" +check "moteur/engine-client.mjs" "text/javascript" +# Aide : copies du glossaire et des scénarios (ADR 0008). +check "docs/scenarios/jouer-une-partie.feature" "text/plain" +check "docs/glossaire/README.md" "text/markdown" +check "docs/glossaire/videau.md" "text/markdown" +cmp -s <(curl -s "$base/docs/glossaire/videau.md") docs/glossaire/videau.md \ + || report "/docs/glossaire/videau.md diffère de la source" + +# Le manifeste désigne des icônes servies, et l'application à la racine. +manifest="$(curl -s "$base/manifest.webmanifest")" +grep -q '"start_url": "./"' <<<"$manifest" || report "manifeste : start_url absent" +while read -r icon; do + curl -sf -o /dev/null "$base/$icon" || report "manifeste : icône $icon introuvable" +done < <(grep -o '"src": "[^"]*"' <<<"$manifest" | sed 's/"src": "\(.*\)"/\1/') + +# Le service worker met en cache chaque fichier du site, avec une version. +sw="$(curl -s "$base/sw.js")" +grep -qE '^const VERSION = "[0-9a-f]{16}";' <<<"$sw" || report "sw.js : version absente" +for file in ./ index.html app/main.js wasm/engine.data wasm/engine.wasm moteur/ \ + docs/glossaire/videau.md version.json; do + grep -qF " \"$file\"," <<<"$sw" || report "sw.js : $file absent de la liste à mettre en cache" +done +listed="$(grep -c '^ "' <<<"$sw")" +present="$(find build/site -type f ! -name sw.js | wc -l)" +# Chaque fichier, plus « ./ » et « moteur/ » pour les deux index.html. +[ "$listed" -eq "$((present + 2))" ] \ + || report "sw.js liste $listed adresses pour $present fichiers (plus 2 dossiers)" +grep -o '^ "[^"]*"' <<<"$sw" | tr -d ' "' | while read -r file; do + curl -sf -o /dev/null "$base/$file" || echo "sw.js : $file introuvable sur le site" +done | grep . && report "sw.js liste des fichiers introuvables" + +# Politique de sécurité du contenu : WebAssembly permis, rien en ligne. +csp="$(curl -s -D - -o /dev/null "$base/" | grep -i '^Content-Security-Policy:')" +grep -q "script-src 'self' 'wasm-unsafe-eval';" <<<"$csp" \ + || report "CSP absente ou sans 'wasm-unsafe-eval'" +grep -q "unsafe-inline" <<<"$csp" && report "CSP : unsafe-inline ne doit pas être permis" +curl -s -D - -o /dev/null "$base/engine-worker.mjs" | grep -qi '^Content-Security-Policy:' \ + || report "CSP absente du Web Worker du moteur" curl -s -H "Accept-Encoding: gzip" -D - -o /dev/null "$base/wasm/engine.data" \ | grep -qi "^Content-Encoding: gzip" || report "/wasm/engine.data : pas de compression gzip" diff --git a/tests/test_web.sh b/tests/test_web.sh index 64baee1..023d9cc 100644 --- a/tests/test_web.sh +++ b/tests/test_web.sh @@ -46,8 +46,13 @@ web app depcruise --config .dependency-cruiser.cjs src tests \ # doit être signalée par dependency-cruiser. fixture=build/web-violations rm -rf "$fixture" -mkdir -p "$fixture/src/domain" "$fixture/src/engine" "$fixture/src/ui" +mkdir -p "$fixture/src/domain" "$fixture/src/engine" "$fixture/src/ui" "$fixture/src/storage" cp app/tsconfig.json "$fixture/" +echo 'export const main = 1;' >"$fixture/src/main.ts" +echo 'export const store = 1;' >"$fixture/src/storage/store.ts" +echo 'import { store } from "../storage/store.js"; export const f = store;' >"$fixture/src/ui/uses-storage.ts" +echo 'import { e } from "../ui/loop-b.js"; export const g = e;' >"$fixture/src/storage/uses-ui.ts" +echo 'import { main } from "../main.js"; export const h = main;' >"$fixture/src/ui/uses-main.ts" echo 'import { engine } from "../engine/adapter.js"; export const a = engine;' >"$fixture/src/domain/uses-engine.ts" echo 'import { readFileSync } from "node:fs"; export const b = readFileSync;' >"$fixture/src/domain/uses-node.ts" echo 'export const engine = 1;' >"$fixture/src/engine/adapter.ts" @@ -56,10 +61,14 @@ echo 'import { e } from "./loop-b.js"; export const d = e;' >"$fixture/src/ui/lo echo 'import { d } from "./loop-a.js"; export const e = d;' >"$fixture/src/ui/loop-b.ts" output="$(web "$fixture" depcruise --config /src/app/.dependency-cruiser.cjs src 2>&1)" \ && report "dependency-cruiser accepte des violations volontaires" -for rule in domain-is-pure ui-does-not-call-engine no-circular; do +for rule in domain-is-pure ui-does-not-call-engine only-main-assembles nothing-imports-main no-circular; do grep -q "$rule" <<<"$output" || report "violation volontaire de $rule non détectée" done grep -q 'uses-node' <<<"$output" || report "import de Node dans le domaine non détecté" +for file in uses-storage uses-ui; do + grep -q "only-main-assembles: src/[a-z]*/$file" <<<"$output" \ + || report "assemblage hors de main.ts non détecté ($file)" +done rm -rf "$fixture" [ "$errors" -eq 0 ] diff --git a/web/app.css b/web/app.css new file mode 100644 index 0000000..da3f52d --- /dev/null +++ b/web/app.css @@ -0,0 +1,283 @@ +/* Style de l'application. Palette provisoire et sobre (bois, ivoire, un + * accent vert), en attendant l'identité définitive : toutes les couleurs et + * tailles sont des variables, redéfinies pour le thème sombre. */ + +:root { + --text: #2b211a; + --muted: #6b5d50; + --background: #f7f2e8; + --accent: #2e6b4f; + --on-accent: #ffffff; + --border: #cbbfae; + --error: #a3261b; + + --frame: #6b4426; + --bed: #e6cfa6; + --bar: #5c3a20; + --tray: #57371e; + --point-a: #9a6439; + --point-b: #c79b69; + --point-number: #f3e7d0; + --checker-player: #fbf6ea; + --checker-player-edge: #7d6a52; + --checker-computer: #2b221c; + --checker-computer-edge: #0d0a08; + --count-player: #2b211a; + --count-computer: #fbf6ea; + --die-player: #fbf6ea; + --pip-player: #2b211a; + --die-computer: #2b221c; + --pip-computer: #fbf6ea; + --cube: #fbf6ea; + --cube-text: #2b211a; + --highlight: #2e6b4f; + --target: rgba(46, 107, 79, 0.35); + --last-move: #d9822b; + + --font-size: 17px; + --button-height: 48px; + --radius: 8px; + --page-width: 40rem; +} + +@media (prefers-color-scheme: dark) { + :root { + --text: #ece4d6; + --muted: #b3a796; + --background: #1b1714; + --accent: #6fc3a0; + --on-accent: #10221a; + --border: #4d443b; + --error: #f08a7e; + + --frame: #3a2616; + --bed: #6d5136; + --bar: #2f1f12; + --tray: #2c1d11; + --point-a: #4a3120; + --point-b: #8b6943; + --point-number: #d9cbb4; + --checker-player: #efe6d2; + --checker-player-edge: #8e7c64; + --checker-computer: #17110d; + --checker-computer-edge: #a8957d; + --highlight: #7fd6b0; + --target: rgba(127, 214, 176, 0.35); + --last-move: #f0a050; + } +} + +* { + box-sizing: border-box; +} + +body { + margin: 0 auto; + max-width: var(--page-width); + padding: 0 0.5rem 1rem; + font: var(--font-size) / 1.45 system-ui, sans-serif; + color: var(--text); + background: var(--background); +} + +[hidden] { + display: none !important; +} + +.topbar { + display: flex; + gap: 0.5rem; + align-items: center; + padding: 0.5rem 0; +} + +.topbar h1 { + flex: 1; + margin: 0; + font-size: 1.25rem; +} + +button { + min-height: var(--button-height); + padding: 0 1rem; + font: inherit; + font-weight: 600; + color: var(--on-accent); + background: var(--accent); + border: 1px solid var(--accent); + border-radius: var(--radius); +} + +button.secondary { + color: var(--text); + background: transparent; + border-color: var(--border); +} + +.topbar button { + min-height: 40px; + padding: 0 0.75rem; + font-size: 0.9rem; +} + +button:disabled { + opacity: 0.45; +} + +.actions { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; +} + +.actions button { + flex: 1; +} + +.pips { + margin: 0.25rem 0 0; + font-size: 0.85rem; + color: var(--muted); + text-align: center; +} + +.info, +.prompt { + min-height: 1.45em; + margin: 0.4rem 0; +} + +.info { + color: var(--muted); +} + +.prompt { + font-weight: 600; +} + +/* L'ordinateur réfléchit : trois points qui apparaissent l'un après l'autre. */ +.prompt.thinking::after { + content: ""; + animation: thinking 1.2s steps(4, end) infinite; +} + +@keyframes thinking { + 0% { content: ""; } + 25% { content: "."; } + 50% { content: ".."; } + 75% { content: "..."; } +} + +/* ------------------------------------------------------------ Plateau */ + +.board { + display: block; + width: 100%; + height: auto; + aspect-ratio: 640 / 520; + user-select: none; + -webkit-tap-highlight-color: transparent; +} + +.board-frame { fill: var(--frame); } +.board-bed { fill: var(--bed); } +.board-bar { fill: var(--bar); } +.board-tray { fill: var(--tray); } +.point-a { fill: var(--point-a); } +.point-b { fill: var(--point-b); } + +.point-number { + fill: var(--muted); + font-size: 13px; + text-anchor: middle; +} + +.checker { + stroke-width: 2; +} + +.checker-player { + fill: var(--checker-player); + stroke: var(--checker-player-edge); +} + +.checker-computer { + fill: var(--checker-computer); + stroke: var(--checker-computer-edge); +} + +.checker.movable { + stroke: var(--highlight); + stroke-width: 4; +} + +.checker.selected { + stroke: var(--highlight); + stroke-width: 7; +} + +.checker-count { + font-size: 18px; + font-weight: 700; + text-anchor: middle; +} + +.count-player { fill: var(--count-player); } +.count-computer { fill: var(--count-computer); } + +.target-zone { fill: var(--target); } +.last-move { fill: var(--last-move); } + +.die-player { fill: var(--die-player); stroke: var(--checker-player-edge); } +.die-computer { fill: var(--die-computer); stroke: var(--checker-computer-edge); } +.pip-player { fill: var(--pip-player); } +.pip-computer { fill: var(--pip-computer); } + +.cube-face { fill: var(--cube); stroke: var(--checker-player-edge); stroke-width: 2; } + +.cube-value { + fill: var(--cube-text); + font-size: 17px; + font-weight: 700; + text-anchor: middle; +} + +.hit { + fill: transparent; + cursor: pointer; +} + +/* --------------------------------------------------------------- Aide */ + +.help h2 { + margin-top: 1.5rem; + font-size: 1.2rem; +} + +.help h3 { + margin-bottom: 0.25rem; + font-size: 1rem; +} + +.steps { + padding-left: 1.25rem; +} + +.glossary dt { + margin-top: 0.6rem; + font-weight: 600; +} + +.glossary dd { + margin: 0; +} + +.about { + margin-top: 2rem; + font-size: 0.85rem; + color: var(--muted); +} + +.about a { + color: var(--accent); +} diff --git a/web/icons/icon-192.png b/web/icons/icon-192.png new file mode 100644 index 0000000000000000000000000000000000000000..82f3f1b009da6130ad16fbc531b28984a7807d92 GIT binary patch literal 2696 zcmaKuXEfYdAIJY>vdZX$=rbZnkVH4bD5EDbgC$r&h?c<;AzH|YF3RXJjFxy9y#$M3 zNuqZXQNm;pCCVt#GGSMCpS;-T?0H_?d(Zb*?z!il@4e>}Yl1RhWfout0D#rVP}l5q z{r&r3q(7Z!+h`^Lz>GH1)iw{9-XNgecr1mw53F?JW3x50&!^&apN~dJt={Z#^xMH)Qdj)*#k^7{vp9?bWzD6=E z><+HGJoH~`RiTDZg8MgTR8ra7$p;77Hzcb(E-qCnwP~0&%4|K^{=^B2Ku%_l zsdS!%wBR%9qD9`y+oU(l7b^%wTCz;hkFadRp^NeUAs?zW z(g$&`3D7gZtg3xlYNp_X1?~&-T1ZC2tk;GNT8))oK-_jX3xSH)+a{V1sfc2LFjqF5K2NP@63>|KGp+d zSW;DI!K8OLB#>SgJ5LYV(9v+mn3vYQBWj-p9O-znhVGPYIK%)`Y&a7rx%>_EG+YWu z4d5<@#sJPg(0X^vMaO*a>?0QN6;$rBLWlf^*Hpmq#;d4$kDp{brNgK(4}o5Q-6&#Z=O znA9Fgk1?&TGi@_DH|LpT1e`Eu-hCUUtZ`v<@PzW|pm;pr;7dfj)jf?Oi?y?)qu!b{ zz)WC#y7qh?cLfzTdT(;s$FlpSvz!LeE{|rU^i9gzcqX2KoL)dYysq|*vG)NoX!FCz znIOg13dxPF3#50`g@M!QRPgv{5UT31bi|aT;M}Fc`#IpQ!}Zp@>ZqU=dTdmSP0rbs zI5gGxrqe^2Hbt6STRkjr?ZWV}xLT9xMx;eIozF?@wqPbNSzR%oAJRl9Dd13G!pUzi zBc_`3?gdppKJwtg%Q zin`sgV#gz`dntqv%=-0BUi=;cRAOf9Vgm;k%Z4lQ(OsSqO$m+{6<*$nH)Rau0U=CQ12#S^g9By!3!Ue?$q*ADztlU88v zs;!7b8Vw6NIG2ki!V9OzlnXk_wqpn7`s%9V-s;~><1w;gD2w#;xt3Mx+JM{DYL?>) zdG4Ad3Ry6x2`N4jA$R(xx1pj&w%x^QLgU3!f0?`t#i>7Twm==azi48Tl%b7oyGy_? zdHFs#a*D*77jyVzEK8Q}!B6JWlevuCi>7pc({|2<{rau>s?5c64AUkZiNY&{tn=2^ zIUt7Z*rwlnnU#(PYB(1xYXe+M5F!#XUPVDXoJDKnq?hCsfN%up0=QdS>BA zAX@dq|KcNzQv(htN=#l6^s8@xEU@f7w4kkDdjuw1e1%WxMFXDUrcP#Hn~R{7&HdR= z5$*c;Z12uKAvj1XL*+8tOAAIrKW6E>$HoD|^l5+*1>dghKq!BpgT=Jt8@zq5mab7Y zGY-`$sfX%qkY=N#W Sso0$oCSat8(yc_^dGaS?wEO%3 literal 0 HcmV?d00001 diff --git a/web/icons/icon-512.png b/web/icons/icon-512.png new file mode 100644 index 0000000000000000000000000000000000000000..febd9a4afc8a37d79d757be6785d32f3d93fab96 GIT binary patch literal 8278 zcmc(EXIN8Ro9;>o5PI(-!2*ah>7tMz2ns4u>CzM_Qbp+{2~q^4y;u+n1X18sk*0J4 zB27T4f^-GxCDI`w$!wfC^Idb!H*=kt^W)@C_Fj9HXVtr`cVdzC~qw+cVztm)%GCrnWk zgOJS?>13uO=ch8Qd)R{`&}b~SHKFLTWv} z({~NG=F!v3bgBHc&$n8O0Q6@!w$ZJzcVQ%pncc7C-C_KGydVL#?yCm}FhBqR1BC+s zF91jY5PxF*zfb;uEDj9{25bxdx0~G?(Ek|Z|B>)tb>>6R&M#8=1@y|-jEta8DQ>d8 zHfg=>cKnv|b4npY`~Cs?$2N2_&SmT91!3zX4hHXv;6&;Dfg^D&x9J`Ew6TYgQw3=?A);C_0=3JsXKuLThu(3t0PN&-omARhGt={$-NnT(AXcRP*D$FHoE74HK;8N4O#cT{ zKgNmHNjhzUX)gC>Ya%b4;Lx{Ymw_Jo;$?O|VQb-t-ZP)+mrTx|UUG0YiYPitt;G#bS5?y@+Rm z3FwUG7f4VwI%Yxa+iNT?C_iqslv>J}hWCY}9Ed~b zJJ0d~|Dxb!r?KN%&{ENq+j0nB3~`H|NLgSv2ETh(FHl+{^7%XIj}oRM1mn=f3;SU8 zwWg#HS=#9Kz|2JyIgQ|Q6#lg%4#{@6|LPpSZ>Le$-VD`4Z;Qt#V2qMc;4epgY0fNxYaFC%rB^^RB@q*pkV`yqxVRGiu?4c(SHs3=5y#6(L+!$|4 z^0pQJ`WV(9%?G0ND8n-vttXnU12{1zETS}p&W;lQYrJrmh>G&N`W|qTX(k=9jVaP8!Tl74(|P zQM0v9>-!b&G)n^y^Q|5FtINW;xi5Ea+IhV`#RmfXT z^scAzs5c*xgv_>{)yq>nx^Iv@a)K~yPDC9gqNm*2`arXOIrrb6v-tVVj zeO3-m6e(v&nxZ#$v$u5bOq8ti62R=*5~IK~J<-xU+qu-82Zki*jnhxerQ@FOUz7&0 zuFhe6UPSl!m_SQybo=!GpKXOLb-F z^;~^yi!O{*00rnn3-}?jaa^^d8HzpQkweJWMf7a=Yx-5qh@G6vnTc zGJOD;+=LF;CG?k2WZb=eEfE;@)J9%Gow}cMQ79IHnfjKc!@zbfW9Iewz^Dm&d#ZWj z&R=o3v%Y-uL{S9hLwg;CetG6LT!Hx@NT^oD_f0E5sXn5hUo3&mF7 z;RDRIv6ys~eOTN6*L(rhe9jM(Yokpv_(7@U?liHDlv;{ng zHqBwc6TqAP!WNoN@;v?lqnF49^N8dHF8z$hjBNc<%-+lKnwA&rEg1oS3*f;JNgC!0+|a7CEuS9$ z)Yv+dAt^D#b7AMnL4a$xl9$!RxVyQ*JX9N@`kBx;+x2Ab@9tiz)XGBeWFLJ;l#iU@#{b1}0Y^ z&th-GGV31d(Z>`Cx?mHs?!frD#HUw|wZr9!E**rT+u_?g5l@rY;7){^+pf)U;BC(m zd~5vcBCM$7l#|05;0}Aqgh(Q@`OSZ)*T9M{srm;G$Q(lfTc5dyGnYu{JzR_xomWum ze!#Ikk>T6~$8aJEk*Rty6j~NZvk~j&7oY3+X%s;aD~ zt+u9(P}`dTzy8rBiep1W#G}n6e6c`W5?^ z&wgZD49qzWxO=mu>ejz^LjPV+maIbq%SNV;tyLPX(FlSJuL;5Zs&rS6=j|h~)bEup zm$lYTsh!Y#&BqVC-@r6Vx{^;3lVL^tOp`gEVmzZyvLgwb{;*q|hq%&kCx2zn8LPQs zN=~Yj;qt9Ht%Z1j;@>dZc7NK{nB2Jb56*|(m11OLpicEzn*((c*E2xWfZd#E^&bP) z@sV)T#vB7rFEIh|Weh=NdC+fu=iL}Pw!W`17S5d0dpXB4mYDzuuB@1nD6duW#*50l zs6`P0pz#pL|5A_mK38`3ja$~TD&R*C>*(pc*ChL=I?pm=)&e635g5-h_)9W#yJ!1% zN{!m7(lA+0Z8!#CCYex2guYPaRCF~wI`|+0DJ}($rocne5egg}gsNah&)*No9T4@& z`O^oDscQ~3!6gYmu1Ma=#v05r!8DdwF22RcSuw?TXKMr|ut5ye8%cnNF<&P@?71rw zoXL$U@s)T4+iNZij>_O}`=)=U1qnj|;$c{7>jyV$79>27p1u15`1hk_Bs z_Nf!a>;lA`{Q03i4GDlsRSemEuSpA7>QSa=(g(plHnxoQ=`*eS-3XPq*|WHFUe=bT7Wu>OL7J%>k0;!l-Nt(j~qOrzsb&>61f7<^8ecS#9G z*%^~O4^7N5%xP#GOb_RuLW$eM%^BidRBy2>g$K@nz8Ab_%&_94tnc{_W40Ew`Cghm z19B}7cXE!5XH~araUJWkv3qeum{TheRBXU;gX+Mq1{_JrV|94#~o<(C<6I4($Tbfb$_0lw5J$< zN4}RA^FjWmS8FGFrwe1C+tf7p;6Cc;ypV@4ls6DvYV2I`3GHvIzm9 zWmfJzL@ti4if>n?Re5*q|KSsgNa8M7;Y9&?JC^NFR#{>``A;hpuGD}c+ZNaE{cr>( zrX5r@bXV-Zsb~#aaiK+bAtZg+bemO@=WOXqDW>CEVbESqo$6EJfx(Fl?`|gO-@wA$ zCqwH)kh~yb6XF}8r@E54dGCpsmEBl3ODvEw=_Cx+3N#;sVb5Mhm15N0>3P|T1RYJ} ze1&YiwhVYZ&vsQfF1LfBUd3dnWgI&w3Jr}siw1|`!D;-EId~Fu*lA|F$nP95zhxAD z(%+NQSW>+HRVPDzN^m=&EpnRYf>;{@!{=KUjG}!WN>=e8F^-;GyVr7MACQ!S=jIu4 zxxk5OwnRwjh!JKBE0C-ObXjt<(*|)b85I$kAHvA8>}UL1;(oc`9GF{hygZ@)VTmy^ zLCFexS&j_ACIY$T^1{p_X@blnhND9;j@O8xFoUpW7X}a4nM(xEd8gyA^fU6Fp1%s6 zqT+v|Iaz&z<~bcF>Jhwa`WWXwOh8~-_7I#IV<7==*uqnWf(@0M4yWVJmsRi9)nEEO zM@tcfN**@-IIsV=#m3AJ1%@kHu_qS5up>Q3J42AfBjt?3I{$DY)ObOp(cOO076e8g zQgUY*Z8x|K&p%;A3mE6Js(>=H$0fE;I0O;8h_6liRCy1Ho_FGnnTcm?t$tYF^uZ}ui}oA<~2Goj(jIm3)aeervwE(^`B9dwe1y$}Lm6e2V$$!|W2Bf8HI zXF*K0Ff(H4W>&dubS?%?MC|GPmNcw@zPZ*;>H5$}=hm{y$hhyqwz~;f+$W_O&PH%} z5d8%CqUy8Mt1O<9+wHmQF2Fe*%yxSeC7@pC?-p-9y@p@@qnol;bsS_0yx zQ4PrNHtd2<*n6;-X0)KURP~+9Z8)bGC{i9F{qlGAC8G6Fx%3m7;g&m>Eu~XqdErE~ zkp2a(obNXrZ}G#ZRF&Ee7+*Lx9pN*LO!H zL$Gpw5dJHbOS z^MoAJtm}H#Q(dQhh3-cQwN7xN5=R46-uI^!R2ViYDZ`XV-wcP)R;A*!KJ=k*G0zUd z{Y-u!aL_a90-R{{Z^_15NGOl}fizC6#1JW=b1|n5#{WW$D5hK2 zF%{L}z5ZC;yod?2`!urqx(L`)zXD&sA8oi$oJ(G_om+P*EVP8&f)Uys%kZ7KlE2Ei zCwTr^;K}PualHvAOw{IBq1ku0KLy49z{T^R2a@WzJK>O3&>*Jz#wu2qeSM)BO${yV zwMP&DYQZM0@=;6ydtZ1n+GaJNB^6Ggrra?00wnl)p_sYa)XVy*8 zkPS$hn3t%AL&~+d`14C_fPh~7j=&`CJRECYsWjDIza`3@Msy;GzY`vVslh2QWp;!{`euTvQo=DLcyu_<~(BWLo%Ui4w2Kbz$6z)7k zeMfRqEVMCrkL~$4M?9I181wCUXzga{ll|4KfAQ8Gz+K#}+I|)Cbam$W!ro4t_09h) z!1H~i0A!xT2SXz0wS*Gc=H&Kv|E0LAI)^&h28m*lRV2&KK6 zd=w(#z126p&J64ch_OD0yE@7|;69&8Y${kNa+PW+uEnD0+)O)#Mrh*nVbWXgwP+Q9G8_e_3l4<@0_rkP3x&jJ;(vh{xym zq)1bt>GH~#rPNv`L%R-AH=MA~HPGwaj#1O(Ce5*-f-5Ea_;QPLA~zJGPV8bMgnN?d z9#(We)%yYy#!Wo_L1yV|Xj{MrRx{Ar59#M5c}ANh#g8#^n4$ryygU27x5i4B_iQ$O z4b6^W2B*)NCFOR)mokUE*0af6KYC{H&*0EDpIE0G_hh}zlhG6<J6vWuR_BS1txGkbN}dGV-xZ-&$c8ctyMjFizz6phVyW4P_T`*;s)KL|1a zec8KcCHrwl7=|q(Qh~X*V#<*Ql^?D0_j02QDC}?GGU{8}PUlU3M=k`!Auia}nMdcF z4~8C5s$BQHv{MI&#{Wig@}{6lG6Iu%!v3%uze>tic3`r$OZqbBc)TDo|Lj^8Z~7bZ zTR;q|zT?fFBlEOwkuC(*v-kUZ(NcfL(=%yrD3<}&Lf1Vs%Ka#8Jz^8Bbn#mjoL~YG zi?hdh1C;3TrhQxH;169vf>0{2ld}qKh_bNh2q04ej7H8z%t)0p-~`)~2yMl*{aMu6 zy-(G53CUsbX95^yD!qky4obqNCw=_scBSQiF_EVQb#Owa30iKNF>`%Dyk^$H?#!Ik zlPE_7s0|Es^=zS*C^|1BIr<+|%1%zVu}dJOKz{IWsmserk0KU*|K82K$su36Xi_|! zpclN;x|w`9Tjox_&#J6aT?H~ifEj2gvT5)c2HZ!RpqW8{BbOzgq0mEgKaLrEvF1v4 z3%T;Cm9+F`@>j|SXcX-`7QcUAS(tBCY18?AwDIsvm@IJb7<1CSBll`1B?5}o=mJ8w zW>fkf4B{Yg_c+}Jc>=;r!Cig$me@{tXd1t~L*DWbZMs0cv~v*Tf7pq-ELk2qCu9+zPhTk>#=hLxW__vj>5QG9_#(7m*a+Z<|G?9F4SHK;sdFl zA<^*5R=7+ayC%lC{(0bYG91X;2s;d_y~X6uU&^whhAHl_|1+Q`I<>^r^y+^uyZYR) zeQ7o$;x>8yhLrAp1n{rE{I=d7+oOMm(;vM)Ge2J%I@F@43(DXg2)0T(dQ#7s{b2c*8u{3QDko;{WG9m`8oXaFd%+ZJ9U%? z0ohl>vMocp>n#$A<%3@o>yAsoK){HqWrb1x##wBCMONR=_c17Yvc@TUCC-ui2H~9Iq0oM|)}W;RwKK!ueK^`@D~6JazVovQT6DJrObG4^Pwf8`J@C z?03D2sFdWGj-Ks(MdlUNiKa$V`JgVXbq?Sj&e^g2T+YQCH6?eac*6ji@s@%ho&(aj z)4MOW16EAeG^aXG3`j{m&xSfK@hC1Rna4`YZmcwT&-VMzAvb}iO|=jZ${=X4_>ma- zkxwdFI1@5~(8_(Z{>c8glm@R9K!^-OJsvDB?h(49b5Co(@3#FY|srhjC16x zlf&8HO7Zn!g@KJwlem9{z6;aI*IF<5+c_lfT{hm&c*cvC#}TW;{*#+%*#G|ma@xFcrOvj91;IL4Y383HE(-#ciopQSKFM{vAy8r+H literal 0 HcmV?d00001 diff --git a/web/icons/icon.svg b/web/icons/icon.svg new file mode 100644 index 0000000..d4902fc --- /dev/null +++ b/web/icons/icon.svg @@ -0,0 +1,7 @@ + + + + + + diff --git a/web/index.html b/web/index.html index 26aaad9..63ad803 100644 --- a/web/index.html +++ b/web/index.html @@ -3,46 +3,41 @@ -Ustagammon : test du moteur - - + + +Ustagammon + + + + -
-

Test du moteur

-

Le moteur GNU Backgammon, compilé en WebAssembly, calcule dans le navigateur.

-

Chargement du moteur…

+ +
+

Ustagammon

+ + +
-
- - - -
- - - -
- -
- - - -

Page de mesure

+
+ +

+

+

+
+ + + + + + +
+
+ + diff --git a/web/manifest.webmanifest b/web/manifest.webmanifest new file mode 100644 index 0000000..3c45106 --- /dev/null +++ b/web/manifest.webmanifest @@ -0,0 +1,18 @@ +{ + "name": "Ustagammon", + "short_name": "Ustagammon", + "description": "Jouer au backgammon contre l'ordinateur, hors ligne.", + "lang": "fr", + "start_url": "./", + "scope": "./", + "display": "standalone", + "orientation": "portrait", + "background_color": "#f7f2e8", + "theme_color": "#6b4426", + "icons": [ + { "src": "icons/icon-192.png", "sizes": "192x192", "type": "image/png" }, + { "src": "icons/icon-512.png", "sizes": "512x512", "type": "image/png" }, + { "src": "icons/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }, + { "src": "icons/icon.svg", "sizes": "any", "type": "image/svg+xml" } + ] +} diff --git a/web/bench-page.mjs b/web/moteur/bench-page.mjs similarity index 93% rename from web/bench-page.mjs rename to web/moteur/bench-page.mjs index eab399b..f6a47c1 100644 --- a/web/bench-page.mjs +++ b/web/moteur/bench-page.mjs @@ -12,8 +12,9 @@ const $ = (id) => document.getElementById(id); // Taille d'un fichier du site, brute et compressée gzip (comme le ferait un // serveur qui compresse ses réponses). +// name est relatif à la racine du site, un dossier au-dessus de cette page. async function fileSize(name) { - const blob = await (await fetch(name, { cache: "no-store" })).blob(); + const blob = await (await fetch("../" + name, { cache: "no-store" })).blob(); const gzipped = blob.stream().pipeThrough(new CompressionStream("gzip")); const gzipBytes = (await new Response(gzipped).arrayBuffer()).byteLength; return { name, bytes: blob.size, gzipBytes }; @@ -37,7 +38,7 @@ async function measure() { await engine.ready; const loadMs = performance.now() - start; - const { positions } = await (await fetch("wasm/positions.json")).json(); + const { positions } = await (await fetch("../wasm/positions.json")).json(); let memoryBytes = 0; const times = await measureTimes({ bestMove: (...args) => engine.bestMove(...args).then(keepMemory), diff --git a/web/bench.html b/web/moteur/bench.html similarity index 100% rename from web/bench.html rename to web/moteur/bench.html diff --git a/web/benchmark.mjs b/web/moteur/benchmark.mjs similarity index 96% rename from web/benchmark.mjs rename to web/moteur/benchmark.mjs index ee0aa86..b9c3348 100644 --- a/web/benchmark.mjs +++ b/web/moteur/benchmark.mjs @@ -20,13 +20,13 @@ export const TIME_THRESHOLDS = [ // Taille totale à télécharger, compressée : moins de 40 Mo. export const DOWNLOAD_LIMIT_BYTES = 40e6; -// Fichiers téléchargés pour faire tourner le moteur dans la page, chemins -// relatifs à la racine du site (web/ ; wasm/ est build/wasm/). +// Fichiers téléchargés pour faire tourner le moteur dans la page de test, +// chemins relatifs à la racine du site (web/ ; wasm/ est build/wasm/). export const DOWNLOADED_FILES = [ - "index.html", - "style.css", - "page.mjs", - "engine-client.mjs", + "moteur/index.html", + "moteur/style.css", + "moteur/page.mjs", + "moteur/engine-client.mjs", "engine-worker.mjs", "engine-wasm.mjs", "wasm/engine.mjs", diff --git a/web/engine-client.mjs b/web/moteur/engine-client.mjs similarity index 87% rename from web/engine-client.mjs rename to web/moteur/engine-client.mjs index d25459c..f8ddfc5 100644 --- a/web/engine-client.mjs +++ b/web/moteur/engine-client.mjs @@ -1,5 +1,6 @@ -// engine-client.mjs -- le moteur vu depuis la page : démarre le Web Worker -// (engine-worker.mjs) et lui envoie les commandes. +// engine-client.mjs -- le moteur vu depuis les pages de test : démarre le +// Web Worker (../engine-worker.mjs, partagé avec l'application) et lui envoie +// les commandes. // // Copyright (C) 2026 les auteurs d'Ustagammon. // Licence : GPLv3 ou ultérieure (voir LICENSE). @@ -8,10 +9,10 @@ // result est l'objet JSON d'engine-cli, ms le temps de calcul mesuré dans le // Worker. La promesse est rejetée si le moteur signale une erreur. -import { commands } from "./engine-wasm.mjs"; +import { commands } from "../engine-wasm.mjs"; export function startEngine() { - const worker = new Worker(new URL("engine-worker.mjs", import.meta.url), { type: "module" }); + const worker = new Worker(new URL("../engine-worker.mjs", import.meta.url), { type: "module" }); const pending = new Map(); let nextId = 1; let resolveReady, rejectReady; diff --git a/web/moteur/index.html b/web/moteur/index.html new file mode 100644 index 0000000..614357b --- /dev/null +++ b/web/moteur/index.html @@ -0,0 +1,48 @@ + + + + + +Ustagammon : test du moteur + + + + +
+

Test du moteur

+

Le moteur GNU Backgammon, compilé en WebAssembly, calcule dans le navigateur.

+

Chargement du moteur…

+ +
+ + + +
+ + + +
+ +
+ + + +

Page de mesure · L'application

+
+ + diff --git a/web/page.mjs b/web/moteur/page.mjs similarity index 98% rename from web/page.mjs rename to web/moteur/page.mjs index a4a325c..a51c175 100644 --- a/web/page.mjs +++ b/web/moteur/page.mjs @@ -21,7 +21,7 @@ engine.ready.then( ); // Exemples : les positions de référence avec des dés. -fetch("wasm/positions.json") +fetch("../wasm/positions.json") .then((response) => response.json()) .then(({ positions }) => { for (const p of positions.filter((p) => p.type === "move")) { diff --git a/web/style.css b/web/moteur/style.css similarity index 100% rename from web/style.css rename to web/moteur/style.css diff --git a/web/sw.js b/web/sw.js new file mode 100644 index 0000000..823d22f --- /dev/null +++ b/web/sw.js @@ -0,0 +1,61 @@ +// sw.js -- service worker d'Ustagammon : l'application hors ligne. +// +// Copyright (C) 2026 les auteurs d'Ustagammon. +// Licence : GPLv3 ou ultérieure (voir LICENSE). +// +// À l'installation, tous les fichiers du site (dont les 8,7 Mo de données du +// moteur) sont mis en cache ; ensuite, chaque fichier est servi depuis ce +// cache, sans réseau. VERSION et FILES sont écrits en tête de ce fichier par +// scripts/service-worker.sh au moment de make site. +// +// Mise à jour : à chaque publication, VERSION change, donc ce fichier. Le +// navigateur installe alors la nouvelle version, qui télécharge tout dans un +// nouveau cache, prend la main tout de suite et efface l'ancien cache. La +// page déjà ouverte a chargé tous ses fichiers au démarrage : elle continue +// sans rien remarquer, et le prochain lancement utilise la nouvelle version. + +/* global VERSION, FILES */ + +const CACHE = `ustagammon-${VERSION}`; + +self.addEventListener("install", (event) => { + event.waitUntil( + (async () => { + const cache = await caches.open(CACHE); + // no-cache : revalider auprès du serveur, jamais une copie + // périmée du cache HTTP du navigateur. + await cache.addAll(FILES.map((file) => new Request(file, { cache: "no-cache" }))); + await self.skipWaiting(); + })(), + ); +}); + +self.addEventListener("activate", (event) => { + event.waitUntil( + (async () => { + for (const name of await caches.keys()) { + if (name !== CACHE) { + await caches.delete(name); + } + } + await self.clients.claim(); + })(), + ); +}); + +self.addEventListener("fetch", (event) => { + const request = event.request; + if (request.method !== "GET" || new URL(request.url).origin !== self.location.origin) { + return; + } + event.respondWith( + (async () => { + const cached = await caches.match(request, { + cacheName: CACHE, + ignoreSearch: true, + ignoreVary: true, + }); + return cached ?? fetch(request); + })(), + ); +}); -- 2.54.0 From ae1ea547822ecbf1cdb8cc1e6b89be91b6acc222 Mon Sep 17 00:00:00 2001 From: Pierre MARTIN Date: Tue, 3 Nov 2026 00:27:24 +0100 Subject: [PATCH 4/8] =?UTF-8?q?Moteur=20WebAssembly=20:=20remettre=20la=20?= =?UTF-8?q?pile=20en=20place=20apr=C3=A8s=20chaque=20commande?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit callMain (Emscripten) copie les arguments sur la pile du WebAssembly (64 Ko) sans les retirer. Pendant une partie dans le navigateur, le même moteur reçoit des centaines de commandes : vers la 540e, la pile débordait et corrompait la mémoire du moteur (moteur muet dans Firefox, onglet planté dans Chromium, « memory access out of bounds » sous Node). Repéré par make test-browser. stackSave et stackRestore sont exportés et encadrent chaque commande ; un test en envoie 3000 à la suite. Co-Authored-By: Claude Opus 5.5 --- app/tests/engine-stack.test.ts | 37 ++++++++++++++++++++++++++++++++++ engine-wasm/wasm.mk | 8 ++++++-- web/engine-wasm.mjs | 13 ++++++++++-- 3 files changed, 54 insertions(+), 4 deletions(-) create mode 100644 app/tests/engine-stack.test.ts diff --git a/app/tests/engine-stack.test.ts b/app/tests/engine-stack.test.ts new file mode 100644 index 0000000..189cf44 --- /dev/null +++ b/app/tests/engine-stack.test.ts @@ -0,0 +1,37 @@ +// Le moteur WebAssembly répond indéfiniment : une partie en navigateur +// envoie des centaines de commandes au même moteur chargé une fois. +// +// Copyright (C) 2026 les auteurs d'Ustagammon. +// Licence : GPLv3 ou ultérieure (voir LICENSE). +// +// Régression : callMain (Emscripten) laisse ses arguments sur la pile du +// WebAssembly (64 Ko). Sans remise en place après chaque commande +// (web/engine-wasm.mjs), la pile débordait vers la 500e commande : erreur +// « memory access out of bounds » sous Node, moteur muet dans Firefox, +// onglet planté dans Chromium. + +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { fileURLToPath } from "node:url"; + +// Compilé dans build/web/tests/ : la racine du dépôt est trois dossiers plus +// haut. +const root = new URL("../../../", import.meta.url); + +interface RawEngine { + runText(args: string[]): { text: string; exitCode: number }; +} + +test("le moteur répond à 3000 commandes à la suite", async () => { + const module = await import(new URL("build/wasm/engine.mjs", root).href); + const wrapper = await import(new URL("web/engine-wasm.mjs", root).href); + const engine: RawEngine = await wrapper.loadEngine( + module.default, + fileURLToPath(new URL("build/wasm/", root)), + ); + for (let i = 0; i < 3000; i++) { + const { text, exitCode } = engine.runText(["position", "4HPwATDgc/ABMA", "AQEAAAAAAAAA"]); + assert.equal(exitCode, 0, `commande ${i}`); + assert.match(text, /"pip_count": 167/, `commande ${i}`); + } +}); diff --git a/engine-wasm/wasm.mk b/engine-wasm/wasm.mk index 478333e..59b7a2f 100644 --- a/engine-wasm/wasm.mk +++ b/engine-wasm/wasm.mk @@ -33,14 +33,18 @@ ENGINE_INCLUDES := -I$(COMPAT) -I$(COMPAT)/glib-shim -I$(GNUBG) -I$(GNUBG)/lib - # - main n'est pas lancé au chargement, et le programme ne s'arrête pas à sa # sortie : le moteur reste chargé entre deux commandes ; # - mémoire extensible : les bases de fin de partie occupent plusieurs -# mégaoctets. +# mégaoctets ; +# - stackSave et stackRestore : callMain laisse ses arguments sur la pile +# (64 Ko) ; sans restauration après chaque commande, elle déborde au bout +# de quelques centaines de commandes et corrompt la mémoire du moteur +# (voir web/engine-wasm.mjs). LINK_FLAGS := -O3 \ -sMODULARIZE -sEXPORT_ES6 -sEXPORT_NAME=createEngineModule \ -sENVIRONMENT=web,worker,node \ -sINVOKE_RUN=0 -sEXIT_RUNTIME=0 \ -sALLOW_MEMORY_GROWTH \ -sEXPORTED_FUNCTIONS=_main,_engine_init \ - -sEXPORTED_RUNTIME_METHODS=callMain,ccall,HEAPU8 \ + -sEXPORTED_RUNTIME_METHODS=callMain,ccall,HEAPU8,stackSave,stackRestore \ --preload-file $(BUILD)/data@/data DATA := $(BUILD)/data/gnubg.wd $(BUILD)/data/gnubg_os0.bd $(BUILD)/data/gnubg_ts0.bd \ diff --git a/web/engine-wasm.mjs b/web/engine-wasm.mjs index 1b0fca7..3430d27 100644 --- a/web/engine-wasm.mjs +++ b/web/engine-wasm.mjs @@ -36,10 +36,19 @@ export async function loadEngine(createEngineModule, wasmDir) { throw new Error("le moteur n'a pas pu charger ses données (code " + status + ")"); } + // callMain copie les arguments sur la pile du WebAssembly sans jamais + // les retirer : on remet la pile en place après chaque commande, sinon + // elle déborde au bout de quelques centaines de commandes (environ 540 + // pendant une partie) et le moteur plante ou ne répond plus. function runText(args) { output = []; - const exitCode = module.callMain(args.map(String)); - return { text: output.join("\n"), exitCode }; + const stack = module.stackSave(); + try { + const exitCode = module.callMain(args.map(String)); + return { text: output.join("\n"), exitCode }; + } finally { + module.stackRestore(stack); + } } function run(args) { -- 2.54.0 From 9cc0e0ccd5e1811c7dfa82431fdbd1f5a60fcedd Mon Sep 17 00:00:00 2001 From: Pierre MARTIN Date: Tue, 3 Nov 2026 00:41:27 +0100 Subject: [PATCH 5/8] =?UTF-8?q?Service=20worker=20enregistr=C3=A9=20une=20?= =?UTF-8?q?fois=20le=20moteur=20charg=C3=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dans Chromium, l'installation du service worker échouait (« Cache.add() encountered a network error » sur wasm/engine.data) quand le Worker du moteur téléchargeait les mêmes 8,7 Mo au même moment : l'application n'était alors pas disponible hors ligne. Repéré par make test-browser, reproduit dans quatre essais sur cinq sans ce changement, jamais avec. Évite aussi de télécharger deux fois ces données à la première visite. Co-Authored-By: Claude Opus 5.5 --- app/src/main.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/app/src/main.ts b/app/src/main.ts index 4963c2a..d71f8df 100644 --- a/app/src/main.ts +++ b/app/src/main.ts @@ -37,6 +37,12 @@ startApp({ // Hors ligne : le service worker met tout le site en cache. Le navigateur le // refuse hors https (sauf localhost) : l'application marche alors sans. +// Il est enregistré une fois le moteur chargé : si le Worker et le service +// worker téléchargent les données du moteur (8,7 Mo) en même temps, Chromium +// fait échouer l'installation (« Cache.add() encountered a network error »), +// et l'application n'est pas disponible hors ligne. Cela évite aussi de +// télécharger deux fois ces données à la première visite. if ("serviceWorker" in navigator) { - navigator.serviceWorker.register(new URL("../sw.js", import.meta.url)).catch(() => {}); + const sw = new URL("../sw.js", import.meta.url); + worker.ready.then(() => navigator.serviceWorker.register(sw)).catch(() => {}); } -- 2.54.0 From 2c43a0a69f807a4826483fb3074c7b7a2286f609 Mon Sep 17 00:00:00 2001 From: Pierre MARTIN Date: Tue, 3 Nov 2026 00:46:48 +0100 Subject: [PATCH 6/8] =?UTF-8?q?Test=20dans=20les=20navigateurs=20:=20CSP?= =?UTF-8?q?=20v=C3=A9rifi=C3=A9e,=20diagnostic=20des=20blocages?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit make test-browser vérifie maintenant la politique de sécurité dans Chromium et Firefox : aucune violation pendant toute la partie, et un script en ligne ajouté exprès est bloqué. Un blocage est diagnostiqué (étape, capture, dernière commande du moteur, journal de la partie) au lieu d'attendre sans fin. Firefox sous Playwright refuse toute navigation hors ligne : on y vérifie le cache et le contrôle par le service worker. Co-Authored-By: Claude Opus 5.5 --- docs/deploiement.md | 11 +++- tests/browser.sh | 2 +- tests/browser/play.mjs | 125 +++++++++++++++++++++++++++++++++++++++-- 3 files changed, 128 insertions(+), 10 deletions(-) diff --git a/docs/deploiement.md b/docs/deploiement.md index 51f3e6e..ea30cc3 100644 --- a/docs/deploiement.md +++ b/docs/deploiement.md @@ -71,7 +71,11 @@ make deploy-prod`. en-têtes dont la politique de sécurité, et commit de `version.json`. - `make test-browser` (hors de `make test`, plusieurs minutes) joue une partie complète par l'interface dans Chromium et Firefox sans écran, sur - la même image, puis vérifie la reprise, l'aide et le hors ligne. + la même image, puis vérifie la reprise, l'aide, le hors ligne (Chromium + seulement : Firefox sous Playwright refuse toute navigation « hors + ligne », on y vérifie que le cache est complet et que la page rechargée + est servie par le service worker) et la politique de sécurité. Captures + d'écran dans `build/browser-shots/`. **Hors ligne et mises à jour.** Le service worker met tous les fichiers du site en cache à la première visite (environ 9 Mo), puis sert tout depuis ce @@ -93,8 +97,9 @@ politique de sécurité du contenu (`Content-Security-Policy`) : tout vient du site lui-même, aucun script ni style en ligne, aucun `eval`, seule la compilation du WebAssembly est permise (`'wasm-unsafe-eval'`). Elle est vérifiée par `tests/test_site.sh` (en-tête présent) et par -`make test-browser` (aucune violation signalée par Chromium ni Firefox -pendant une partie). +`make test-browser` : aucune violation signalée par Chromium ni Firefox +pendant toute une partie (moteur, Web Worker, service worker, aide), et un +script en ligne ajouté exprès à la page est bien bloqué. ## Vérifier diff --git a/tests/browser.sh b/tests/browser.sh index 35b926f..580baaf 100755 --- a/tests/browser.sh +++ b/tests/browser.sh @@ -42,7 +42,7 @@ for browser in $browsers; do # Réseau de l'hôte : le site est sur 127.0.0.1. Le service worker # demande un contexte sûr, ce qu'est localhost. docker run --rm --init --ipc=host --network host --user "$(id -u):$(id -g)" -e HOME=/tmp \ - -v "$PWD/$tools:/tools" -v "$PWD/build/browser-shots:/shots" -w /tools "$image" \ + -e TRACE -v "$PWD/$tools:/tools" -v "$PWD/build/browser-shots:/shots" -w /tools "$image" \ node play.mjs "http://localhost:$port/" "$browser" /shots || status=1 done exit "$status" diff --git a/tests/browser/play.mjs b/tests/browser/play.mjs index 34f7f71..75a6b78 100644 --- a/tests/browser/play.mjs +++ b/tests/browser/play.mjs @@ -26,17 +26,51 @@ const context = await browser.newContext({ deviceScaleFactor: 2, locale: "fr-FR", }); +// Pour le diagnostic d'un blocage : la dernière commande envoyée au moteur +// (Web Worker), et si elle a reçu sa réponse ; et, pour vérifier la +// politique de sécurité du contenu, chaque violation signalée par la page. +await context.addInitScript(() => { + window.__engine = { sent: 0, received: 0, last: null }; + const post = Worker.prototype.postMessage; + Worker.prototype.postMessage = function (message, ...rest) { + window.__engine.sent++; + window.__engine.last = message?.args ?? null; + if (!this.__counted) { + this.__counted = true; + this.addEventListener("message", () => window.__engine.received++); + } + return post.call(this, message, ...rest); + }; + window.__csp = []; + document.addEventListener("securitypolicyviolation", (e) => + window.__csp.push(`${e.effectiveDirective} ${e.blockedURI}`), + ); +}); const page = await context.newPage(); const problems = []; page.on("pageerror", (error) => problems.push(`erreur de la page : ${error.message}`)); +page.on("crash", () => { + console.error(`[${browserName}] la page a planté`); + process.exit(1); +}); +// Vrai pendant le contrôle final de la politique de sécurité, qui provoque +// exprès une violation. +let cspProbe = false; page.on("console", (message) => { - if (message.type() === "error") { + if (message.type() === "error" && !(cspProbe && /Content.Security.Policy/i.test(message.text()))) { problems.push(`console : ${message.text()}`); } }); const log = (text) => console.log(`[${browserName}] ${text}`); const shot = (name) => page.screenshot({ path: `${shots}/${browserName}-${name}.png` }); +/** Relève les violations de la CSP signalées par la page (avant chaque + * rechargement, qui les efface). */ +const violations = []; +async function collectViolations() { + violations.push(...(await page.evaluate(() => window.__csp))); +} + function check(condition, message) { if (!condition) { throw new Error(message); @@ -79,9 +113,40 @@ async function waitForPlayer() { } await new Promise((resolve) => setTimeout(resolve, 100)); } - throw new Error("rien à faire pour le joueur depuis 60 s"); + await shot("bloque").catch(() => {}); + const screen = await page + .evaluate(() => { + const text = (id) => document.getElementById(id)?.textContent; + const busy = document.body.classList.contains("busy") ? "occupé" : "libre"; + const engine = window.__engine; + return ( + `${busy} ; « ${text("info")} » ; « ${text("prompt")} » ; moteur : ` + + `${engine.sent} commandes, ${engine.received} réponses, dernière ` + + `${JSON.stringify(engine.last)} ; journal ` + + localStorage.getItem("ustagammon.current-game") + ); + }) + .catch((error) => `page illisible (${error.message})`); + throw new Error(`rien à faire pour le joueur depuis 60 s : ${screen}`); } +// Garde-fou : une étape bloquée plus de deux minutes arrête tout, avec une +// capture et l'état de l'écran. +let lastStep = Date.now(); +let stepName = "chargement"; +function step(name) { + lastStep = Date.now(); + stepName = name; +} +const watchdog = setInterval(async () => { + if (Date.now() - lastStep > 120_000) { + clearInterval(watchdog); + console.error(`[${browserName}] bloqué depuis 2 minutes (étape : ${stepName})`); + await shot("bloque").catch(() => {}); + process.exit(1); + } +}, 5_000); + const start = Date.now(); await page.goto(base); const first = await waitForPlayer(); @@ -95,6 +160,10 @@ let computerMs = []; for (let actions = 0; ; actions++) { check(actions < 800, "la partie ne finit pas"); const todo = await waitForPlayer(); + step(`partie, action ${actions}`); + if (process.env.TRACE) { + log(`${todo} : ${await page.textContent("#info")} / ${await page.textContent("#prompt")}`); + } const info = await page.textContent("#info"); const timing = /\((\d+),(\d) s\)/.exec(info ?? ""); if (timing) { @@ -136,6 +205,7 @@ for (let actions = 0; ; actions++) { await shot("3-milieu-de-partie"); const before = await savedJournal(); const pips = await page.textContent("#pips"); + await collectViolations(); await page.reload(); await waitForPlayer(); const after = await savedJournal(); @@ -159,6 +229,7 @@ if (computerMs.length > 0) { } await shot("4-fin-de-partie"); +step("aide"); // Aide : scénario et glossaire lus dans les copies de docs/. await page.click("#help-button"); await page.waitForSelector(".glossary dt"); @@ -169,6 +240,7 @@ log(`aide : ${scenarios} scénarios, ${terms} termes du glossaire`); await shot("5-aide"); await page.click("#help-back"); +step("thème sombre"); // Thème sombre. await page.emulateMedia({ colorScheme: "dark" }); await page.click("#again"); @@ -176,16 +248,19 @@ await waitForPlayer(); await shot("6-sombre"); await page.emulateMedia({ colorScheme: "light" }); +step("mise en cache"); // Hors ligne : une fois le cache du service worker rempli, la page se // recharge sans réseau, et le moteur répond. const cached = await page.evaluate(async () => { - await navigator.serviceWorker.ready; + // Le service worker est enregistré une fois le moteur chargé ; s'il ne + // s'installe pas, l'attente s'arrête au bout d'une minute. for (let i = 0; i < 600; i++) { const names = await caches.keys(); if (names.length === 1) { const cache = await caches.open(names[0]); const keys = await cache.keys(); - if (keys.some((r) => r.url.endsWith("/wasm/engine.data"))) { + const active = (await navigator.serviceWorker.getRegistration())?.active; + if (active && keys.some((r) => r.url.endsWith("/wasm/engine.data"))) { return keys.length; } } @@ -194,8 +269,19 @@ const cached = await page.evaluate(async () => { return 0; }); check(cached > 0, "le service worker n'a pas mis le site en cache"); -await context.setOffline(true); +// Firefox sous Playwright refuse toute navigation une fois le contexte +// « hors ligne », même servie par le service worker (NS_ERROR_OFFLINE) : on +// y vérifie seulement que la page rechargée est contrôlée par le service +// worker, dont le cache est complet. +const offlineMode = browserName === "chromium"; +if (offlineMode) { + await context.setOffline(true); +} +step("rechargement hors ligne"); +await collectViolations(); await page.reload(); +const controlled = await page.evaluate(() => navigator.serviceWorker.controller !== null); +check(controlled, "la page rechargée n'est pas contrôlée par le service worker"); const offline = await waitForPlayer(); if (offline === "roll") { await page.click("#roll"); @@ -204,9 +290,36 @@ if (offline === "roll") { await page.click("#help-button"); await page.waitForSelector(".glossary dt"); await page.click("#help-back"); -log(`hors ligne : page rechargée depuis le cache (${cached} adresses), moteur et aide disponibles`); +log( + offlineMode + ? `hors ligne : page rechargée sans réseau depuis le cache (${cached} adresses), moteur et aide disponibles` + : `cache du service worker rempli (${cached} adresses), page rechargée sous son contrôle ` + + "(le hors ligne lui-même n'est pas simulable dans Firefox sous Playwright)", +); await shot("7-hors-ligne"); +step("CSP"); +// Politique de sécurité du contenu : aucune violation pendant toute la +// partie (moteur WebAssembly, Web Worker, service worker, aide compris) ; et +// elle est bien appliquée : un script en ligne ajouté à la page est bloqué. +await collectViolations(); +check(violations.length === 0, `violations de la CSP : ${violations.join(", ")}`); +cspProbe = true; +const probe = await page.evaluate(async () => { + const script = document.createElement("script"); + script.textContent = "window.__inline = true;"; + document.head.append(script); + await new Promise((resolve) => setTimeout(resolve, 300)); + return { ran: window.__inline === true, violations: window.__csp }; +}); +check(!probe.ran, "CSP non appliquée : un script en ligne a été exécuté"); +check( + probe.violations.some((v) => v.startsWith("script-src")), + "CSP : le script en ligne bloqué n'a pas été signalé", +); +log("CSP : aucune violation pendant la partie, script en ligne bloqué"); + +clearInterval(watchdog); await browser.close(); if (problems.length > 0) { console.error(problems.join("\n")); -- 2.54.0 From e3b31f326984d5eb3573d8b57941ddb25da62b11 Mon Sep 17 00:00:00 2001 From: Pierre MARTIN Date: Tue, 3 Nov 2026 00:46:48 +0100 Subject: [PATCH 7/8] =?UTF-8?q?=C3=89tape=200d.2=20:=20guide=20de=20test,?= =?UTF-8?q?=20aide=20document=C3=A9e,=20r=C3=A9sultat=20du=20plan?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- docs/guide-test-etape-0.md | 4 +- docs/plans/2026-11-02-plateau-pwa.md | 57 +++++++++++++++++++++++++++- docs/scenarios/README.md | 5 +++ 3 files changed, 62 insertions(+), 4 deletions(-) diff --git a/docs/guide-test-etape-0.md b/docs/guide-test-etape-0.md index 48caa7a..166c76c 100644 --- a/docs/guide-test-etape-0.md +++ b/docs/guide-test-etape-0.md @@ -5,8 +5,8 @@ Document temporaire de l'étape 0 : il sera supprimé avec `docs/etape-0.md`. Deux tests sur le téléphone : - **Étape 0d : jouer une partie** (section suivante), sur le site publié ; -- **Étape 0b : mesurer le moteur** (déjà fait le 2026-11-02), sur une page servie par - le PC. +- **Étape 0b : mesurer le moteur** (fait le 2026-11-02), sur une page + servie par le PC. ## Étape 0d : jouer une partie sur le téléphone diff --git a/docs/plans/2026-11-02-plateau-pwa.md b/docs/plans/2026-11-02-plateau-pwa.md index 3f16689..ded93a7 100644 --- a/docs/plans/2026-11-02-plateau-pwa.md +++ b/docs/plans/2026-11-02-plateau-pwa.md @@ -2,7 +2,7 @@ - **Date** : 2026-11-02 - **Étape** : 0d.2 -- **Statut** : en cours +- **Statut** : terminé (2026-11-03) - **Exécutant** : sous-agent general-purpose, dans un worktree isolé ## Objectif @@ -101,4 +101,57 @@ partie jouée dans les navigateurs sans interface, taille du site, difficultés, ## Résultat -À remplir à la clôture. +Fait en deux sessions de sous-agent (la première interrompue par la fin +d'un quota, la seconde l'a reprise), sur la branche `plateau-ui-travail`. + +- **Domaine** (`app/src/domain/game/game.ts`) : reprise d'une partie depuis + son journal, plateau vu de chaque camp, demi-coups possibles à la suite de + ceux déjà saisis, droit de doubler ; tout vient du moteur. +- **Interface** (`app/src/ui/`) : plateau en SVG (24 cases, barre, pions + sortis, dés, videau), saisie au toucher avec cases d'arrivée en + surbrillance, annulation, boutons du jet, du videau et de nouvelle partie, + coup de l'ordinateur affiché avec son temps, fin de partie (simple, gammon, + backgammon, points), reprise depuis `localStorage` (`app/src/storage/`), + textes dans `texts.ts`, thème clair et sombre en variables CSS + (`web/app.css`). +- **Assemblage** : `app/src/main.ts`, seul point d'entrée autorisé à tout + importer (règles dependency-cruiser) ; moteur dans le Web Worker. +- **Aide** (`app/src/ui/help.ts`) : scénario « Jouer une partie contre + l'ordinateur » et fiches du glossaire (terme et « En bref »), lus à + l'exécution dans les copies de `docs/` faites par `make site`. +- **PWA** : manifeste, icônes provisoires, service worker qui met en cache + les 70 fichiers du site (empreinte du contenu comme version), enregistré + une fois le moteur chargé. +- **Site** : application à la racine, pages de test sous `/moteur/` ; CSP + dans `deploy/Caddyfile`, vérifiée par `tests/test_site.sh` (en-tête) et + dans les navigateurs (aucune violation, script en ligne bloqué). Site de + 9,1 Mo (70 fichiers, dont 8,6 Mo pour le moteur), archive de 8,8 Mo. +- **Tests** : tests unitaires de l'interface et du domaine ; `make + test-browser` (Playwright, Apache-2.0, hors de `make test` : plusieurs + minutes et une image de 3 Go) joue une partie complète dans Chromium et + Firefox sans écran : videau proposé des deux côtés, reprise après + rechargement, aide, thème sombre, hors ligne, CSP. Les deux passent. +- **Guide de test** : section « Étape 0d » de `docs/guide-test-etape-0.md`. + +Deux défauts trouvés par la vérification dans les navigateurs et corrigés : + +1. **Pile du moteur WebAssembly** : `callMain` d'Emscripten laissait ses + arguments sur la pile (64 Ko) ; vers la 540e commande d'une partie, elle + débordait (moteur muet dans Firefox, onglet planté dans Chromium, + « memory access out of bounds » sous Node). La pile est remise en place + après chaque commande (`web/engine-wasm.mjs`) ; test de 3000 commandes. +2. **Installation du service worker dans Chromium** : elle échouait quand le + Worker téléchargeait les données du moteur au même moment ; le service + worker est désormais enregistré une fois le moteur chargé. + +Écarts et limites : + +- le hors ligne n'est vraiment simulé que dans Chromium (Firefox sous + Playwright refuse toute navigation hors ligne) : à confirmer en mode avion + sur le téléphone ; +- temps des coups de l'ordinateur en niveau standard, sur ce PC chargé : + 0,6 s au plus dans Chromium, jusqu'à 4,6 s dans Firefox ; le critère de + 2 s est à mesurer sur le Fairphone 4 ; +- thème sombre : les pions foncés sur les cases foncées restent lisibles + grâce à leur contour, mais le contraste est le point faible de la palette + provisoire. diff --git a/docs/scenarios/README.md b/docs/scenarios/README.md index 6a9aea1..61cfa29 100644 --- a/docs/scenarios/README.md +++ b/docs/scenarios/README.md @@ -5,6 +5,11 @@ joueur, écrit pour lui. Ces fichiers sont la seule source (ADR 0008) : l'application les met en forme comme pages d'aide, et ils sont exécutés comme tests d'intégration sur le domaine et le moteur réel. +`make site` copie ce dossier dans le site publié (`docs/scenarios/`) ; l'aide +de l'application (bouton « Aide ») lit ces copies à l'exécution +(`app/src/ui/help.ts`). Les fichiers affichés dans l'aide sont ceux de la +liste `FEATURES` de `help.ts`. + `make test` les exécute avec Cucumber : les étapes, définies dans `app/tests/steps/`, pilotent le domaine branché sur le vrai moteur WebAssembly, avec des dés imposés pour que chaque exécution soit identique. Une -- 2.54.0 From 6bc1c27beec02e118612d5035d41f2ffecda2b24 Mon Sep 17 00:00:00 2001 From: Pierre MARTIN Date: Tue, 3 Nov 2026 01:02:05 +0100 Subject: [PATCH 8/8] =?UTF-8?q?=C3=89tape=200d.2=20:=20plan=20clos,=20feui?= =?UTF-8?q?lle=20de=20route=20et=20journal=20=C3=A0=20jour?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- docs/feuille-de-route.md | 2 +- docs/journal.md | 25 +++++ docs/plans/2026-11-02-plateau-pwa.md | 157 --------------------------- 3 files changed, 26 insertions(+), 158 deletions(-) delete mode 100644 docs/plans/2026-11-02-plateau-pwa.md diff --git a/docs/feuille-de-route.md b/docs/feuille-de-route.md index b8126dc..b5a8b6e 100644 --- a/docs/feuille-de-route.md +++ b/docs/feuille-de-route.md @@ -20,7 +20,7 @@ garde-fous d'architecture (0d.1), déploiement sur https://ustagammon.sans.pub. | Sous-étape | Contenu | État | Plan | | --- | --- | --- | --- | -| 0d.2 | Plateau minimal jouable (PWA hors ligne) ; vérifié avec Pierre en suivant `docs/scenarios/jouer-une-partie.feature` | 🔄 | `plans/2026-11-02-plateau-pwa.md` | +| 0d.2 | Plateau minimal jouable (PWA hors ligne), publié ; reste la vérification avec Pierre sur le Fairphone 4 (`docs/guide-test-etape-0.md`, section « Étape 0d ») | 🔄 | | **Critère de fin** : une partie complète jouée sur le Fairphone 4, moins de 2 s par coup de l'IA en niveau standard, tous les tests au vert. diff --git a/docs/journal.md b/docs/journal.md index fb871c5..0828303 100644 --- a/docs/journal.md +++ b/docs/journal.md @@ -64,3 +64,28 @@ Une entrée datée par session : ce qui a été fait, décidé, et la suite. - Étape 0d : plateau minimal jouable sur le socle retenu. - `adb` n'est pas installé sur le PC : `sudo apt install adb` avant les tests sur le téléphone. + +## 2026-11-02 (soir) et 2026-11-03 + +**Fait** +- Mesures sur le Fairphone 4 (Chrome 156) : 2 plis en 348 ms au pire, seuil + 3 s ; ADR 0009 accepté (socle PWA en TypeScript), 0c abandonnée. +- Déploiement CapRover (MR !10) : app `ustagammon` créée, site publié sur + https://ustagammon.sans.pub (HTTPS forcé). +- Étape 0d.1 (MR !11) : domaine « partie » en journal d'événements, scénario + exécuté par Cucumber sur le vrai moteur, garde-fous d'architecture. +- Étape 0d.2 : plateau jouable au toucher en PWA hors ligne, aide intégrée + (glossaire et scénarios lus depuis leurs sources), CSP, `make test-browser` + (parties complètes dans Chromium et Firefox). Deux bugs trouvés et corrigés : + la pile du moteur WebAssembly débordait après quelques centaines de commandes + (plantage en cours de partie), et le service worker échouait dans Chromium en + téléchargeant les données en même temps que le moteur. +- Interruptions de quota : reprise programmée à 23h50 ; l'agent de 0d.2 arrêté a + été remplacé par un agent de reprise sur le même espace de travail. + +**Reste à faire** +- Vérification finale de l'étape 0 avec Pierre : une partie complète sur le + Fairphone 4, en suivant le guide de test (section « Étape 0d »). +- Sous Firefox sur PC, un tour de l'ordinateur a pris jusqu'à 5,1 s + (Chromium : 0,4 s) ; Firefox sur le téléphone reste à mesurer. +- Validation par Pierre de Playwright (Apache-2.0), outil de `make test-browser`. diff --git a/docs/plans/2026-11-02-plateau-pwa.md b/docs/plans/2026-11-02-plateau-pwa.md deleted file mode 100644 index ded93a7..0000000 --- a/docs/plans/2026-11-02-plateau-pwa.md +++ /dev/null @@ -1,157 +0,0 @@ -# Plateau minimal jouable, en PWA hors ligne - -- **Date** : 2026-11-02 -- **Étape** : 0d.2 -- **Statut** : terminé (2026-11-03) -- **Exécutant** : sous-agent general-purpose, dans un worktree isolé - -## Objectif - -Sur https://ustagammon.sans.pub, un joueur joue une partie complète contre -l'ordinateur sur son téléphone, au toucher. L'application s'installe -(PWA) et fonctionne hors ligne. C'est la fin de l'étape 0 : critère -« une partie complète jouée sur le Fairphone 4, moins de 2 s par coup de l'IA -en niveau standard, tous les tests au vert ». Pas de design soigné : l'objectif -est de valider la boucle de jeu complète, proprement. - -## Consignes - -1. **Interface `app/src/ui/`** (TypeScript, DOM ou SVG, sans framework) : - - un plateau lisible sur un écran de téléphone en portrait (24 points, - barre, pions sortis, dés, videau) ; - - saisie au toucher : toucher une case de départ, puis une case - d'arrivée ; les cases d'arrivée possibles sont mises en évidence (le - domaine expose ce qui est possible à partir des coups légaux du moteur, - sans réimplémenter de règle) ; annuler les demi-coups en cours ; - - boutons : lancer les dés, doubler, prendre, passer, nouvelle partie ; - - pendant le tour de l'ordinateur : un indicateur discret ; le coup de - l'ordinateur s'affiche (sans animation obligatoire) ; - - fin de partie : gagnant, simple, gammon ou backgammon, points ; - - reprise automatique de la partie en cours après fermeture de la page - (le journal d'événements sérialisé dans `localStorage` suffit pour - l'étape 0 ; stockage derrière une petite interface, hors du domaine). -2. **Architecture** (ADR 0007, garde-fous existants) : l'assemblage (domaine, - adaptateur moteur dans un Web Worker, interface, stockage) se fait dans un - point d'entrée dédié (par exemple `app/src/main.ts`), seul autorisé à tout - importer ; `ui/` n'importe toujours pas `engine/`. Le moteur tourne dans un - Web Worker, jamais sur le fil d'affichage. Étendre les règles - dependency-cruiser si besoin. -3. **Textes** : en français, dans un fichier de textes séparé du code - (ADR 0007, point 5) ; les mots du jeu sont ceux du glossaire. -4. **Identité minimale** : couleurs et tailles en variables CSS, thème clair - et sombre (`prefers-color-scheme`), contrastes lisibles (pions clairs et - foncés distincts du plateau). Palette provisoire et sobre inspirée du brief - (bois, ivoire, un accent) : l'identité définitive viendra plus tard. -5. **Aide dans l'application** (ADR 0008, source unique) : le build copie - `docs/glossaire/` et `docs/scenarios/` dans le site ; une page d'aide - simple affiche le scénario « Jouer une partie contre l'ordinateur » et les - fiches du glossaire (au moins le terme et sa phrase « En bref »), lus à - l'exécution depuis ces copies, jamais recopiés à la main. -6. **PWA** : manifeste, icônes provisoires simples (pas de logo : un symbole - neutre), service worker qui met en cache tous les fichiers du site (dont - les 8,7 Mo de données du moteur) pour le hors ligne, et se met à jour - proprement à chaque publication (le commit de `version.json` peut servir de - clé de cache). -7. **Site publié** (`make site`, `docs/deploiement.md`) : l'application à la - racine ; les pages de prototype (test du moteur et mesure) restent - accessibles, par exemple sous `/moteur/` et `/moteur/bench.html`, et la doc - le dit. Ajouter une politique de sécurité du contenu (CSP) dans - `deploy/Caddyfile`, compatible WebAssembly (`'wasm-unsafe-eval'`), et la - vérifier. `tests/test_site.sh` à jour (manifeste, service worker, aide). -8. **Tests** : - - tests unitaires de ce qui est testable hors navigateur (par exemple la - transformation de l'état du domaine en ce que dessine le plateau, les - destinations possibles) ; - - vérification dans un vrai navigateur : une partie jouée par l'interface, - au moins jusqu'à plusieurs coups de chaque joueur, dans Chromium et - Firefox sans interface (image `mcr.microsoft.com/playwright:v1.48.2-noble`, - déjà présente). Cette vérification est faite et rapportée ; ne l'ajouter à - `make test` que si c'est simple et rapide, en le signalant comme nouvelle - dépendance de test (Playwright, Apache-2.0) ; - - les tests existants restent verts. -9. **Guide de test** : compléter `docs/guide-test-etape-0.md` d'une section - « Étape 0d : jouer une partie sur le téléphone » : ouvrir - https://ustagammon.sans.pub, installer l'application, jouer une partie - complète en suivant `docs/scenarios/jouer-une-partie.feature`, vérifier - le hors ligne (mode avion), et ce qu'il faut rapporter (temps des coups de - l'ordinateur ressentis, problèmes rencontrés, captures si besoin). - -Contraintes : -- `CLAUDE.md`, ADR 0004, 0007, 0008, 0009 : simplicité avant tout, pas de - framework, identifiants en anglais, commentaires, docs et textes en français, - tout outil dans Docker, `-j2` ; -- aucune règle du jeu réimplémentée : tout vient du moteur, via le domaine ; -- ne pas modifier `engine/gnubg/` ni les références ; -- ne pas publier (`make deploy-prod`) : la session principale s'en charge ; -- `make lint` et `make test` doivent passer ; commits petits, en français, avec - la ligne `Co-Authored-By: Claude Opus 5.5 ` ; -- ne pas pousser ni créer de merge request ; ne rien simuler. - -Rapport attendu : ce qui a été fait, captures d'écran du plateau (fichiers dans -le dossier de travail temporaire, chemins dans le rapport), résultat de la -partie jouée dans les navigateurs sans interface, taille du site, difficultés, -écarts au plan. - -## Critères de réussite - -- Dans Chromium et Firefox sans interface, une partie se joue par l'interface - (dés, coups au toucher, coups de l'ordinateur, videau) sans erreur. -- Le site fonctionne hors ligne après un premier chargement (service worker). -- `make lint` et `make test` passent. - -## Résultat - -Fait en deux sessions de sous-agent (la première interrompue par la fin -d'un quota, la seconde l'a reprise), sur la branche `plateau-ui-travail`. - -- **Domaine** (`app/src/domain/game/game.ts`) : reprise d'une partie depuis - son journal, plateau vu de chaque camp, demi-coups possibles à la suite de - ceux déjà saisis, droit de doubler ; tout vient du moteur. -- **Interface** (`app/src/ui/`) : plateau en SVG (24 cases, barre, pions - sortis, dés, videau), saisie au toucher avec cases d'arrivée en - surbrillance, annulation, boutons du jet, du videau et de nouvelle partie, - coup de l'ordinateur affiché avec son temps, fin de partie (simple, gammon, - backgammon, points), reprise depuis `localStorage` (`app/src/storage/`), - textes dans `texts.ts`, thème clair et sombre en variables CSS - (`web/app.css`). -- **Assemblage** : `app/src/main.ts`, seul point d'entrée autorisé à tout - importer (règles dependency-cruiser) ; moteur dans le Web Worker. -- **Aide** (`app/src/ui/help.ts`) : scénario « Jouer une partie contre - l'ordinateur » et fiches du glossaire (terme et « En bref »), lus à - l'exécution dans les copies de `docs/` faites par `make site`. -- **PWA** : manifeste, icônes provisoires, service worker qui met en cache - les 70 fichiers du site (empreinte du contenu comme version), enregistré - une fois le moteur chargé. -- **Site** : application à la racine, pages de test sous `/moteur/` ; CSP - dans `deploy/Caddyfile`, vérifiée par `tests/test_site.sh` (en-tête) et - dans les navigateurs (aucune violation, script en ligne bloqué). Site de - 9,1 Mo (70 fichiers, dont 8,6 Mo pour le moteur), archive de 8,8 Mo. -- **Tests** : tests unitaires de l'interface et du domaine ; `make - test-browser` (Playwright, Apache-2.0, hors de `make test` : plusieurs - minutes et une image de 3 Go) joue une partie complète dans Chromium et - Firefox sans écran : videau proposé des deux côtés, reprise après - rechargement, aide, thème sombre, hors ligne, CSP. Les deux passent. -- **Guide de test** : section « Étape 0d » de `docs/guide-test-etape-0.md`. - -Deux défauts trouvés par la vérification dans les navigateurs et corrigés : - -1. **Pile du moteur WebAssembly** : `callMain` d'Emscripten laissait ses - arguments sur la pile (64 Ko) ; vers la 540e commande d'une partie, elle - débordait (moteur muet dans Firefox, onglet planté dans Chromium, - « memory access out of bounds » sous Node). La pile est remise en place - après chaque commande (`web/engine-wasm.mjs`) ; test de 3000 commandes. -2. **Installation du service worker dans Chromium** : elle échouait quand le - Worker téléchargeait les données du moteur au même moment ; le service - worker est désormais enregistré une fois le moteur chargé. - -Écarts et limites : - -- le hors ligne n'est vraiment simulé que dans Chromium (Firefox sous - Playwright refuse toute navigation hors ligne) : à confirmer en mode avion - sur le téléphone ; -- temps des coups de l'ordinateur en niveau standard, sur ce PC chargé : - 0,6 s au plus dans Chromium, jusqu'à 4,6 s dans Firefox ; le critère de - 2 s est à mesurer sur le Fairphone 4 ; -- thème sombre : les pions foncés sur les cases foncées restent lisibles - grâce à leur contour, mais le contraste est le point faible de la palette - provisoire. -- 2.54.0