From e9bb9ae88d957547ac4bb54a49679d1f870ba1d7 Mon Sep 17 00:00:00 2001 From: Pierre MARTIN Date: Tue, 10 Nov 2026 16:12:29 +0100 Subject: [PATCH 1/8] Plan des corrections : sortie, largeur du tutoriel, notation de l'ordinateur Co-Authored-By: Claude Opus 5.5 --- docs/plans/2026-11-10-corrections.md | 56 ++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 docs/plans/2026-11-10-corrections.md diff --git a/docs/plans/2026-11-10-corrections.md b/docs/plans/2026-11-10-corrections.md new file mode 100644 index 0000000..7238081 --- /dev/null +++ b/docs/plans/2026-11-10-corrections.md @@ -0,0 +1,56 @@ +# Corrections : saisie de la sortie, largeur du tutoriel, notation de l'ordinateur + +- **Date** : 2026-11-10 +- **Étape** : recette de l'étape 1 (après 1e) +- **Statut** : en cours +- **Exécutant** : sous-agent general-purpose, dans un worktree + +## Objectif + +Corriger trois défauts relevés à la recette : un ordre de saisie refusé pendant +la sortie des pions, l'étape 6 du tutoriel moins large que les autres sur le +Fairphone 4, et la ligne « L'ordinateur a joué … » écrite à partir des +demi-coups au lieu de la notation du moteur. + +## Consignes + +1. **Saisie de la sortie (prioritaire).** Avec un 6-1 en sortie, 6/off avant + 1/off est refusé alors que 1/off puis 6/off passe (position + BQAACAIAAAAAAA, « Jouer à partir d'ici »). Trouver la cause dans la chaîne + de saisie demi-coup par demi-coup (`nextHalfMoves` dans game.ts, + `engine_check_half_moves`, l'adaptateur). Le moteur reste la seule autorité + (ADR 0007) : un défaut côté C se corrige dans `engine/engine.c`, jamais dans + `engine/gnubg/`, avec un test dans `tests/test_engine.sh`, en comparant aux + coups légaux de gnubg pour les deux ordres. Ajouter un test (unitaire ou + scénario Gherkin) qui joue les deux ordres sur le vrai moteur. Chercher et + tester les cas voisins : dé plus grand que la flèche la plus haute, doubles + en sortie. +2. **Largeur de l'étape 6 du tutoriel.** Mesurer dans `make test-browser` la + largeur du plateau et de la colonne de texte à chaque étape (écrans + d'explication et « à vous » : départ, refusé, réussi) en 412×915, 915×412 + et 844×320. Ajouter à la recette automatique la règle : même largeur de + plateau à toutes les étapes pour une taille d'écran donnée. Trouver la + cause et la corriger ; si rien ne se reproduit, le dire avec les mesures. +3. **Notation du coup de l'ordinateur.** La ligne « L'ordinateur a joué … » + utilise la notation du moteur, comme le bilan, l'analyse et la feuille du + maître. Tester que le même coup s'écrit pareil sur l'écran de jeu et dans + l'analyse. + +Contraintes : CSP stricte (aucun `.style.`), aucune nouvelle dépendance, +`-j2` au plus, moteur recompilé une seule fois et seulement s'il change ; +vérifier avec `docker ps` qu'aucun test navigateur ne tourne avant +`make test-browser`. + +## Critères de réussite + +- `make lint`, `make test` et `make test-browser` (suite complète) passent. +- Un test sur le vrai moteur joue 6/off puis 1/off et 1/off puis 6/off dans + la position BQAACAIAAAAAAA, et les cas voisins. +- La recette automatique vérifie la largeur constante du plateau du tutoriel. +- Un test compare la notation du coup de l'ordinateur sur l'écran de jeu et + dans l'analyse. +- Branche `corrections-sortie-tutoriel` poussée, sans MR. + +## Résultat + +À remplir à la clôture. From ab8dc512bafc4f411735c4ca709fe5470c00ade4 Mon Sep 17 00:00:00 2001 From: Pierre MARTIN Date: Tue, 10 Nov 2026 19:36:15 +0100 Subject: [PATCH 2/8] =?UTF-8?q?Sortie=20:=20un=20coup=20d=C3=A9j=C3=A0=20c?= =?UTF-8?q?omplet=20peut=20encore=20se=20prolonger=20(6/off=20puis=201/off?= =?UTF-8?q?=20avec=206-1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit La partie fermait la saisie dès que les demi-coups formaient un coup légal. En sortie, 6/off seul mène à la même position que 6/5/off : le 1/off qui suivait était refusé. La saisie reste ouverte tant que le moteur accepte un demi-coup de plus ; le tutoriel suit la même règle. Tests : tous les ordres de saisie des fins de partie d'un à quatre pions, sur les 21 jets, scénario Gherkin et tests du moteur pour les deux ordres. Co-Authored-By: Claude Opus 5.5 --- app/src/domain/game/game.ts | 21 +-- app/src/domain/learning/tutorial.ts | 10 +- app/tests/bear-off-input.test.ts | 196 ++++++++++++++++++++++ app/tests/steps/jouer-une-partie.steps.ts | 33 ++++ app/tests/tutorial.test.ts | 15 ++ docs/scenarios/jouer-une-partie.feature | 18 ++ tests/test_engine.sh | 11 ++ 7 files changed, 291 insertions(+), 13 deletions(-) create mode 100644 app/tests/bear-off-input.test.ts diff --git a/app/src/domain/game/game.ts b/app/src/domain/game/game.ts index 0c56d4c..9584690 100644 --- a/app/src/domain/game/game.ts +++ b/app/src/domain/game/game.ts @@ -186,8 +186,11 @@ export class Game { } /** Les demi-coups saisis forment un coup complet (le moteur le dit) : il - * n'attend plus que d'être validé (confirmMove) ou défait - * (undoLastHalfMove). */ + * peut être validé (confirmMove) ou défait (undoLastHalfMove). La saisie + * n'est pas close pour autant : en sortie, un coup complet peut encore + * se prolonger en un autre coup légal (avec 6-1 et des pions sur 6 et 1, + * 6/off seul mène à la même position que 6/5/off, et 1/off reste + * possible) ; nextHalfMoves dit lesquels, selon le moteur. */ get moveComplete(): boolean { return this.#pending.at(-1)?.complete === true; } @@ -296,11 +299,12 @@ export class Game { * 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. + * un coup légal. Aucune règle n'est jugée ici : même après un coup + * complet, c'est le moteur qui dit si un dé reste à jouer. */ async nextHalfMoves(): Promise { const state = this.state; - if (state.turn !== "player" || state.phase !== "moving" || this.moveComplete) { + if (state.turn !== "player" || state.phase !== "moving") { return []; } const roll = rollToPlay(state); @@ -370,15 +374,12 @@ export class Game { /** * Le joueur déplace un pion (un dé). Renvoie false si ce demi-coup ne - * mène à aucun coup légal (ou si le coup est déjà complet) : il est - * refusé et rien ne change. Le coup n'est pas joué pour autant : une fois - * complet (moveComplete), il attend d'être validé (confirmMove). + * mène à aucun coup légal : il est refusé et rien ne change. Le coup + * n'est pas joué pour autant : une fois complet (moveComplete), il attend + * d'être validé (confirmMove), ou prolongé si le moteur le permet. */ async playHalfMove(halfMove: HalfMove): Promise { const state = this.#expect("player", "moving"); - if (this.moveComplete) { - return false; - } const check = await this.#engine.checkHalfMoves(state.position, rollToPlay(state), [ ...this.pendingHalfMoves, halfMove, diff --git a/app/src/domain/learning/tutorial.ts b/app/src/domain/learning/tutorial.ts index 2f54f0e..c89ae59 100644 --- a/app/src/domain/learning/tutorial.ts +++ b/app/src/domain/learning/tutorial.ts @@ -209,10 +209,14 @@ export class MovePractice { if (!(await this.#game.playHalfMove(halfMove))) { return "wrong"; } - if (this.#game.moveComplete) { - const position = this.#game.displayedPosition; - return this.#expected.some((m) => m.position === position) ? "right" : "wrong"; + if ( + this.#game.moveComplete && + this.#expected.some((m) => m.position === this.#game.displayedPosition) + ) { + return "right"; } + // Un coup complet peut encore se prolonger (en sortie) : il n'est + // refusé que s'il ne peut plus mener à un coup attendu. return canStillLeadTo(this.#game.pendingHalfMoves, this.#expected) ? "continue" : "wrong"; } diff --git a/app/tests/bear-off-input.test.ts b/app/tests/bear-off-input.test.ts new file mode 100644 index 0000000..802c33e --- /dev/null +++ b/app/tests/bear-off-input.test.ts @@ -0,0 +1,196 @@ +// Saisie d'un coup de sortie demi-coup par demi-coup, sur le vrai moteur +// WebAssembly : tout ordre de saisie que le moteur accepte doit être accepté +// par la partie, même quand un début de coup forme déjà un coup complet. +// +// Défaut corrigé (2026-11-10) : avec 6-1 et des pions sur 6 et 1, 6/off +// mène à la même position que le coup légal « 6/5/off » ; la partie le +// tenait pour complet et refusait ensuite 1/off, alors que 1/off puis 6/off +// passait. +// +// 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 type { Engine } from "../src/domain/game/engine.js"; +import { Game } from "../src/domain/game/game.js"; +import { formatHalfMoves, type HalfMove } from "../src/domain/game/position.js"; +import { fixedDice, type Roll } from "../src/domain/game/roll.js"; +import { loadNodeEngine } from "./support/node-engine.js"; + +// Le joueur, au trait, a un pion sur sa case 6 et un sur sa case 1 ; +// l'ordinateur un sur sa case 1 et un sur sa case 2. Le cas signalé par +// Pierre (#/position/BQAACAIAAAAAAA, « Jouer à partir d'ici »). +const SIX_AND_ONE = "BQAACAIAAAAAAA"; + +/** + * Position ID (format de gnubg) d'une fin de partie : les pions du joueur au + * trait sur ses cases 1 à 6 (player[i] : nombre de pions sur la case i + 1), + * l'ordinateur avec un pion sur sa case 1 et un sur sa case 2 ; les autres + * pions sont sortis. Outil de test seulement : chaque position produite est + * relue par le moteur, qui doit y retrouver ces pions. + */ +function bearOffPosition(player: readonly number[]): string { + const bits: number[] = []; + const side = (counts: readonly number[]) => { + for (let point = 0; point < 25; point++) { + for (let k = 0; k < (counts[point] ?? 0); k++) { + bits.push(1); + } + bits.push(0); + } + }; + side([1, 1]); // l'adversaire d'abord, comme dans gnubg + side(player); + const bytes = new Uint8Array(10); + bits.forEach((bit, i) => { + if (bit === 1) { + bytes[i >> 3] = (bytes[i >> 3] ?? 0) | (1 << (i & 7)); + } + }); + return Buffer.from(bytes).toString("base64").slice(0, 14); +} + +/** Une partie où le joueur, au trait dans position, vient de lancer roll. */ +async function rolled(engine: Engine, position: string, roll: Roll): Promise { + const game = Game.start(engine, { + dice: fixedDice(roll), + setup: { position, firstTurn: "player" }, + }); + await game.roll(); + return game; +} + +/** Toutes les permutations distinctes des demi-coups. */ +function orders(halfMoves: readonly HalfMove[]): HalfMove[][] { + if (halfMoves.length <= 1) { + return [[...halfMoves]]; + } + const seen = new Set(); + const result: HalfMove[][] = []; + halfMoves.forEach((h, i) => { + const rest = [...halfMoves.slice(0, i), ...halfMoves.slice(i + 1)]; + for (const tail of orders(rest)) { + const order = [h, ...tail]; + const key = formatHalfMoves(order); + if (!seen.has(key)) { + seen.add(key); + result.push(order); + } + } + }); + return result; +} + +test("le cas signalé est bien celui que décrit le test", async () => { + const engine = await loadNodeEngine(); + assert.equal(bearOffPosition([1, 0, 0, 0, 0, 1]), SIX_AND_ONE); + const board = await engine.board(SIX_AND_ONE, { value: 1, owner: "centered" }); + assert.deepEqual(board.onRoll.points.slice(0, 6), [1, 0, 0, 0, 0, 1]); +}); + +test("6-1 en sortie : 6/off puis 1/off, comme 1/off puis 6/off", async () => { + const engine = await loadNodeEngine(); + const allOff = (await engine.legalMoves(SIX_AND_ONE, [6, 1])).find( + (m) => m.notation === "6/off 1/off", + ); + assert.ok(allOff, "le moteur propose 6/off 1/off"); + + // 6/off d'abord : la position est déjà celle d'un coup légal (6/5/off), + // mais le 1 reste jouable et le moteur l'accepte. + const six = await rolled(engine, SIX_AND_ONE, [6, 1]); + assert.equal(await six.playHalfMove({ from: 6, to: 0 }), true); + assert.equal(six.moveComplete, true); + assert.deepEqual(await six.nextHalfMoves(), [{ from: 1, to: 0 }]); + assert.equal(await six.playHalfMove({ from: 1, to: 0 }), true); + assert.equal(six.moveComplete, true); + assert.equal(six.displayedPosition, allOff.position); + assert.deepEqual( + six.diceToPlay.map((d) => d.used), + [true, true], + ); + await six.confirmMove(); + assert.equal(six.state.phase, "over"); + + // 1/off d'abord : pas encore complet, puis 6/off. + const one = await rolled(engine, SIX_AND_ONE, [6, 1]); + assert.equal(await one.playHalfMove({ from: 1, to: 0 }), true); + assert.equal(one.moveComplete, false); + assert.equal(await one.playHalfMove({ from: 6, to: 0 }), true); + assert.equal(one.displayedPosition, allOff.position); + + // Et le coup 6/off seul (6/5/off) se valide toujours tel quel. + const alone = await rolled(engine, SIX_AND_ONE, [6, 1]); + assert.equal(await alone.playHalfMove({ from: 6, to: 0 }), true); + await alone.confirmMove(); + assert.equal(alone.state.turn, "computer"); +}); + +test("en sortie, tout ordre accepté par le moteur l'est par la partie", async () => { + const engine = await loadNodeEngine(); + // Toutes les fins de partie d'un à trois pions dans le jan intérieur, + // et quelques-unes à quatre (doubles), avec les 21 jets. + const positions: number[][] = []; + for (let a = 0; a < 6; a++) { + positions.push([a]); + for (let b = a; b < 6; b++) { + positions.push([a, b]); + for (let c = b; c < 6; c++) { + positions.push([a, b, c]); + } + } + } + positions.push([0, 0, 0, 0], [1, 2, 3, 4], [5, 5, 4, 4], [0, 2, 4, 5], [3, 3, 3, 3]); + const rolls: Roll[] = []; + for (let a = 1; a <= 6; a++) { + for (let b = 1; b <= a; b++) { + rolls.push([a, b]); + } + } + const failures: string[] = []; + let checked = 0; + for (const pips of positions) { + const counts = [0, 0, 0, 0, 0, 0]; + for (const p of pips) { + counts[p] = (counts[p] ?? 0) + 1; + } + const position = bearOffPosition(counts); + for (const roll of rolls) { + const moves = await engine.legalMoves(position, roll); + const game = await rolled(engine, position, roll); + for (const move of moves) { + for (const order of orders(move.halfMoves)) { + // Le moteur doit accepter chaque début de cet ordre… + let accepted = true; + for (let i = 1; i <= order.length && accepted; i++) { + accepted = (await engine.checkHalfMoves(position, roll, order.slice(0, i))) + .valid; + } + if (!accepted) { + continue; + } + // … et alors la partie aussi, jusqu'au coup complet. + game.cancelHalfMoves(); + checked++; + const label = `${position} ${roll.join("-")} ${formatHalfMoves(order)}`; + for (const h of order) { + const offered = (await game.nextHalfMoves()).some( + (n) => n.from === h.from && n.to === h.to, + ); + if (!offered || !(await game.playHalfMove(h))) { + failures.push(`${label} : ${formatHalfMoves([h])} refusé`); + break; + } + } + if (game.pendingHalfMoves.length === order.length) { + if (!game.moveComplete || game.displayedPosition !== move.position) { + failures.push(`${label} : coup incomplet ou position différente`); + } + } + } + } + } + } + assert.ok(checked > 1000, `${checked} ordres vérifiés`); + assert.deepEqual(failures, []); +}); diff --git a/app/tests/steps/jouer-une-partie.steps.ts b/app/tests/steps/jouer-une-partie.steps.ts index cbe6ad5..bc06205 100644 --- a/app/tests/steps/jouer-une-partie.steps.ts +++ b/app/tests/steps/jouer-une-partie.steps.ts @@ -476,6 +476,39 @@ Given("un joueur a sorti tous ses pions", async () => { assert.equal(await playAndConfirm("6/off 5/off"), true); }); +// ------------------------------------------------- Sortir dans tout ordre + +// Le joueur, au trait : un pion sur sa case 6, un sur sa case 1 ; +// l'ordinateur un pion sur sa case 1 et un sur sa case 2. +const SIX_AND_ONE = "BQAACAIAAAAAAA"; + +Given( + "vous avez un pion sur la flèche 6 et un sur la flèche 1, avec le jet 6-1", + async () => { + game = Game.start(engine, { + setup: { position: SIX_AND_ONE, firstTurn: "player" }, + dice: fixedDice([6, 1]), + }); + await game.roll(); + }, +); + +Then(/^vous pouvez valider ou jouer encore (.+)$/, async (text: string) => { + assert.equal(accepted, true); + // 6/off seul mène à la même position que 6/5/off, un coup légal. + assert.equal(game.moveComplete, true); + assert.deepEqual(await game.nextHalfMoves(), parseHalfMoves(text)); +}); + +Then("vos deux pions sont sortis", async () => { + assert.equal(accepted, true); + assert.equal(game.moveComplete, true); + const board = await game.displayedBoard(); + assert.equal(board.player.off, 15); + await game.confirmMove(); + assert.equal(game.state.phase, "over"); +}); + Then("le résultat indique le gagnant et les points gagnés", () => { // Gammon (l'ordinateur n'a sorti aucun pion), mais 1 point seulement : // règle Jacoby, le videau n'a pas bougé. diff --git a/app/tests/tutorial.test.ts b/app/tests/tutorial.test.ts index 86eea06..d4cc81f 100644 --- a/app/tests/tutorial.test.ts +++ b/app/tests/tutorial.test.ts @@ -174,6 +174,21 @@ test("le joueur peut revenir en arrière après un coup refusé", async () => { assert.equal(await practice.play({ from: 6, to: 5 }), "right"); }); +test("en sortie, un coup déjà complet peut encore mener au coup attendu", async () => { + // Pions sur 6 et 1, jet 6-1 : 6/off seul est déjà un coup complet (même + // position que 6/5/off), mais le coup attendu sort les deux pions. + const engine = await loadNodeEngine(); + const first = await MovePractice.start(engine, "BQAACAIAAAAAAA", [6, 1], ["6/off 1/off"]); + assert.equal(await first.play({ from: 6, to: 0 }), "continue"); + assert.equal(await first.play({ from: 1, to: 0 }), "right"); + const other = await MovePractice.start(engine, "BQAACAIAAAAAAA", [6, 1], ["6/off 1/off"]); + assert.equal(await other.play({ from: 1, to: 0 }), "continue"); + assert.equal(await other.play({ from: 6, to: 0 }), "right"); + // 6/5 puis 5/off : complet, mais ce n'est pas le coup attendu. + const wrong = await MovePractice.start(engine, "BQAACAIAAAAAAA", [6, 1], ["6/off 1/off"]); + assert.equal(await wrong.play({ from: 6, to: 5 }), "wrong"); +}); + test("la réponse au double de l'étape 8 est celle du moteur", async () => { const engine = await loadNodeEngine(); for (const [name, screen] of screens()) { diff --git a/docs/scenarios/jouer-une-partie.feature b/docs/scenarios/jouer-une-partie.feature index a19af74..902a570 100644 --- a/docs/scenarios/jouer-une-partie.feature +++ b/docs/scenarios/jouer-une-partie.feature @@ -142,6 +142,24 @@ Fonctionnalité: Jouer une partie contre l'ordinateur Alors la partie s'arrête Et vous perdez la partie à sa valeur d'avant le double + @position:BQAACAIAAAAAAA + Scénario: Sortir ses pions dans l'ordre de son choix + Une fois tous vos pions dans les six dernières flèches, vous les + sortez dans l'ordre que vous voulez. Avec 6-1 et un pion sur les flèches + 6 et 1, sortez celui de la 6 puis celui de la 1, ou l'inverse. Après le + premier, « Jouer ce coup » apparaît déjà : le 1 pourrait aussi faire + avancer ce pion de la 6 avant sa sortie, ce qui laisse l'autre en place. + Vous pouvez valider ainsi, ou sortir le second pion. + + Étant donné que vous avez un pion sur la flèche 6 et un sur la flèche 1, avec le jet 6-1 + Quand vous jouez 6/off + Alors vous pouvez valider ou jouer encore 1/off + Quand vous jouez 1/off + Alors vos deux pions sont sortis + Étant donné que vous avez un pion sur la flèche 6 et un sur la flèche 1, avec le jet 6-1 + Quand vous jouez 1/off 6/off + Alors vos deux pions sont sortis + Scénario: Comment se termine une partie ? La partie s'arrête dès qu'un joueur a sorti tous ses pions. Le panneau de fin dit qui gagne, et combien de points : la valeur du videau, doublée diff --git a/tests/test_engine.sh b/tests/test_engine.sh index 22af1ab..290daa3 100644 --- a/tests/test_engine.sh +++ b/tests/test_engine.sh @@ -205,6 +205,17 @@ attendre_demi_coups "true false /38AAAAgAAAAAA" "$course" 65 "5/off" # Course, jet 21 : il faut jouer les deux dés. attendre_demi_coups "false false " "$course" 21 "6/3" attendre_demi_coups "true true /38AAAAkAAAAAA" "$course" 21 "6/5 5/3" +# Sortie avec 6-1, pions sur 6 et 1 (BQAACAIAAAAAAA) : gnubg donne deux +# coups légaux, « 6/off 1/off » et « 6/off » (6/5/off, qui laisse le pion +# sur 1). 6/off seul mène déjà à la position de ce second coup (complet), +# mais 1/off reste permis ensuite : les deux ordres mènent à « tout sorti ». +sortie_61=BQAACAIAAAAAAA +grep -qF '"move": "6/off 1/off"' <<<"$($cli legal "$sortie_61" "$libre_j0" 61)" \ + || echouer "sortie 6-1 : coup légal « 6/off 1/off » attendu" +attendre_demi_coups "true true BQAACAAAAAAAAA" "$sortie_61" 61 "6/off" +attendre_demi_coups "true true BQAAAAAAAAAAAA" "$sortie_61" 61 "6/off 1/off" +attendre_demi_coups "true false BQAAAAEAAAAAAA" "$sortie_61" 61 "1/off" +attendre_demi_coups "true true BQAAAAAAAAAAAA" "$sortie_61" 61 "1/off 6/off" # Aucun coup possible : rien n'est valide. attendre_demi_coups "false false " 27YBBwAcz8EDQA 66 attendre_erreur usage halfmoves "$depart" 31 "8-5" From 4e74bab3ac7b220dfe756fdbe1508034177c0e3a Mon Sep 17 00:00:00 2001 From: Pierre MARTIN Date: Tue, 10 Nov 2026 19:37:46 +0100 Subject: [PATCH 3/8] =?UTF-8?q?=C3=89cran=20de=20jeu=20:=20le=20coup=20de?= =?UTF-8?q?=20l'ordinateur=20dans=20la=20notation=20du=20moteur?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit « L'ordinateur a joué 6-4 : 6/off » au lieu de « 6/2 2/off » : la notation vient du coup légal de gnubg qui mène à la même position (Game.moveNotation), comme dans l'analyse et le bilan. Testé : sur une partie analysée, chaque coup s'écrit pareil des deux côtés. Co-Authored-By: Claude Opus 5.5 --- app/src/domain/game/game.ts | 32 +++++++++++++++++++++++++++++++- app/src/ui/app.ts | 11 ++++++----- app/tests/game-analysis.test.ts | 28 +++++++++++++++++++++++++++- 3 files changed, 64 insertions(+), 7 deletions(-) diff --git a/app/src/domain/game/game.ts b/app/src/domain/game/game.ts index 9584690..6b66e7f 100644 --- a/app/src/domain/game/game.ts +++ b/app/src/domain/game/game.ts @@ -27,7 +27,14 @@ import { withAnalysesOf, } from "./events.js"; import { firstGameOf, type Match, nextGameOf } from "./match.js"; -import { BAR, type HalfMove, OFF, type PositionId, START_POSITION } from "./position.js"; +import { + BAR, + formatHalfMoves, + 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"; @@ -276,6 +283,29 @@ export class Game { return { side, halfMoves, boards, hits }; } + /** + * Le coup joué à l'événement eventId (un MovePlayed), dans la notation + * du moteur : celle du coup légal de gnubg qui mène à la même position + * (« 6/off » et non « 6/5 5/off »), comme dans l'analyse et le bilan. + * À défaut (aucun coup légal ne correspond), les demi-coups ; chaîne + * vide si l'événement n'est pas un coup ou si aucun pion n'a bougé. + */ + async moveNotation(eventId: string): Promise { + const line = currentLine({ ...this.#journal, head: eventId }); + const event = line.at(-1); + if (event?.type !== "MovePlayed" || event.halfMoves.length === 0) { + return ""; + } + const before = replay(line.slice(0, -1)); + const roll = rollToPlay(before); + const [check, legal] = await Promise.all([ + this.#engine.checkHalfMoves(before.position, roll, event.halfMoves), + this.#engine.legalMoves(before.position, roll), + ]); + const move = legal.find((m) => m.position === check.position); + return move?.notation ?? formatHalfMoves(event.halfMoves); + } + /** Les conseils du maître déjà rattachés aux décisions du joueur, par * identifiant d'événement. */ get advice(): Readonly> { diff --git a/app/src/ui/app.ts b/app/src/ui/app.ts index 6b0dea7..a4cb543 100644 --- a/app/src/ui/app.ts +++ b/app/src/ui/app.ts @@ -32,7 +32,7 @@ import type { Advice } from "../domain/game/advice.js"; import type { Cube } from "../domain/game/cube.js"; import type { GameJournal } from "../domain/game/events.js"; import type { Game, MoveSteps, NewGame, SideBoards } from "../domain/game/game.js"; -import { formatHalfMoves, type HalfMove } from "../domain/game/position.js"; +import type { HalfMove } from "../domain/game/position.js"; import { type PositionLink, type SharedPosition, setupOf } from "../domain/game/position-link.js"; import type { SavedGameSummary } from "../domain/game/saved-game.js"; import { otherSide, type Side } from "../domain/game/side.js"; @@ -1018,7 +1018,7 @@ export function startApp(options: AppOptions): void { while (game !== null && game.state.turn === "computer" && isPlaying(game)) { await refresh(); await game.playComputerTurn(); - info = describeComputerTurn(game); + info = await describeComputerTurn(game); save(); played = true; } @@ -2500,8 +2500,9 @@ async function computerMoveToShow(game: Game): Promise { return game.lastMoveSteps(); } -/** Ce qu'a fait l'ordinateur pendant son tour (sans durée de calcul). */ -function describeComputerTurn(game: Game): string { +/** Ce qu'a fait l'ordinateur pendant son tour (sans durée de calcul). Son + * coup s'écrit dans la notation du moteur, comme dans l'analyse. */ +async function describeComputerTurn(game: Game): Promise { const last = game.events.at(-1); if (last?.type === "CubeOffered") { return ""; @@ -2514,7 +2515,7 @@ function describeComputerTurn(game: Game): string { if (played.halfMoves.length === 0) { return TEXTS.computerCannotMove(dice); } - return TEXTS.computerMoved(dice, formatHalfMoves(played.halfMoves)); + return TEXTS.computerMoved(dice, await game.moveNotation(played.id)); } /** Le résultat d'une partie finie, en deux parties : le titre du panneau diff --git a/app/tests/game-analysis.test.ts b/app/tests/game-analysis.test.ts index 28fd014..21090fe 100644 --- a/app/tests/game-analysis.test.ts +++ b/app/tests/game-analysis.test.ts @@ -30,7 +30,7 @@ import { withAnalysesOf, } from "../src/domain/game/events.js"; import { Game } from "../src/domain/game/game.js"; -import { START_POSITION } from "../src/domain/game/position.js"; +import { formatHalfMoves, START_POSITION } from "../src/domain/game/position.js"; import { emptyReview, REVIEW_VERSION } from "../src/domain/game/review.js"; import { loadNodeEngine } from "./support/node-engine.js"; import { playToTheEnd, seededDice } from "./support/play.js"; @@ -315,4 +315,30 @@ test("une partie complète analysée par le moteur, comme une partie d'avant 1d" // Le journal analysé se reprend : l'analyse fait partie du format. const resumed = Game.resume(engine, JSON.parse(JSON.stringify(analysed))); assert.deepEqual(resumed.journal.review, analysed.review); + // Chaque coup s'écrit pareil sur l'écran de jeu (« L'ordinateur a + // joué … », moveNotation) et dans l'analyse : la notation du moteur. + let compared = 0; + let rewritten = 0; + for (const move of moves) { + if (move.type !== "MovePlayed" || move.halfMoves.length === 0) { + continue; + } + const advice = + move.side === "computer" + ? analysed.review?.computer[move.id] + : analysed.advice?.[move.id]; + if (advice?.kind !== "move") { + continue; + } + const notation = await resumed.moveNotation(move.id); + assert.equal(notation, advice.played.notation, `coup ${move.id}`); + compared++; + if (notation !== formatHalfMoves(move.halfMoves)) { + rewritten++; + } + } + assert.ok(compared > 5, `${compared} coups comparés`); + // La course finit en sortie : au moins un coup s'écrit autrement que + // ses demi-coups (« 6/off » pour « 6/2 2/off »…). + assert.ok(rewritten > 0, "aucun coup réécrit par le moteur"); }); From b2263c7e3ddb0add896c921439ae7e0b93179cc4 Mon Sep 17 00:00:00 2001 From: Pierre MARTIN Date: Tue, 10 Nov 2026 19:42:41 +0100 Subject: [PATCH 4/8] =?UTF-8?q?Tutoriel=20:=20m=C3=AAme=20largeur=20=C3=A0?= =?UTF-8?q?=20chaque=20=C3=A9tape=20;=20r=C3=A8gle=20ajout=C3=A9e=20=C3=A0?= =?UTF-8?q?=20la=20recette?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Les pages sont des éléments de la colonne flexible #screen ; leurs marges automatiques annulaient l'étirement, et une page au contenu court se réduisait à la largeur de ce contenu : l'étape 6 « à vous » (consigne courte, pas d'explication) avait un plateau de 300 px au lieu de 396 en 412 × 915. Les pages prennent toute la largeur, bornée par max-width. La recette mesure le plateau et le texte à chaque écran du tutoriel, dans les trois tailles, et refuse un plateau de largeur différente ou une page plus large que l'écran (mesures dans tutoriel-largeurs.txt). Co-Authored-By: Claude Opus 5.5 --- tests/browser/accueil.mjs | 20 +++++++++++- tests/browser/recette.mjs | 69 +++++++++++++++++++++++++++++++++++++++ web/app.css | 9 +++++ 3 files changed, 97 insertions(+), 1 deletion(-) diff --git a/tests/browser/accueil.mjs b/tests/browser/accueil.mjs index 9f51320..1e305dd 100644 --- a/tests/browser/accueil.mjs +++ b/tests/browser/accueil.mjs @@ -16,7 +16,7 @@ import { readFileSync, writeFileSync, mkdirSync } from "node:fs"; import { chromium, firefox } from "playwright"; -import { hiddenUnderActionBar } from "./recette.mjs"; +import { hiddenUnderActionBar, tutorialWidthProblems, tutorialWidths } from "./recette.mjs"; const [base, browserName, shots, updateBase, updateDir] = process.argv.slice(2); const browserType = { chromium, firefox }[browserName]; @@ -171,6 +171,7 @@ async function checkTutorialView(page, name) { return b.scrollHeight - b.clientHeight; })(), height: innerHeight, + width: innerWidth, buttons, }; }); @@ -189,8 +190,13 @@ async function checkTutorialView(page, name) { } tutorialViews++; tutorialMinNumber = Math.min(tutorialMinNumber, view.minNumber); + // Largeurs du plateau et du texte, comparées d'une étape à l'autre à la + // fin du tutoriel (tutorialWidthProblems). + const widths = await tutorialWidths(page); + tutorialWidthViews.push({ name, size: `${view.width}x${view.height}`, ...widths }); } let tutorialViews = 0; +const tutorialWidthViews = []; let tutorialBodyScroll = { px: 0, where: "" }; let tutorialMinNumber = Infinity; @@ -331,6 +337,18 @@ async function playTouches(page, touches) { await until(page, "fin", () => location.hash === "#/tutoriel/fin"); await page.click("#tutorial-next"); await until(page, "première partie", () => location.hash === "#/" && window.__shown("game")); + if (full) { + // Les mesures, écran par écran, à côté des captures. + writeFileSync(`${dir}/tutoriel-largeurs.txt`, tutorialWidthViews.map((v) => + `${v.size}\t${v.name}\tplateau ${v.board.toFixed(1)}\ttexte ${v.text.toFixed(1)}\tpage ${v.page}`) + .join("\n") + "\n"); + const widthProblems = tutorialWidthProblems(tutorialWidthViews); + check(widthProblems.length === 0, `largeurs du tutoriel :\n ${widthProblems.join("\n ")}`); + const sizes = [...new Set(tutorialWidthViews.map((v) => v.size))]; + log(`tutoriel : plateau de même largeur à chaque étape (${sizes.map((s) => + `${s} : ${tutorialWidthViews.find((v) => v.size === s).board.toFixed(1)} px`).join(", ")}), ` + + "aucune page plus large que l'écran"); + } log(`tutoriel : ${screens} écrans, gestes refusés puis justes sur les 4 écrans « à vous », fin` + (full ? ` ; recette sur ${tutorialViews} vues, numéros de ${tutorialMinNumber.toFixed(1)} px au moins, ` + `texte défilant au plus de ${tutorialBodyScroll.px} px${tutorialBodyScroll.px > 0 ? ` (${tutorialBodyScroll.where})` : ""}` : "")); diff --git a/tests/browser/recette.mjs b/tests/browser/recette.mjs index a3d7b6f..ddc2658 100644 --- a/tests/browser/recette.mjs +++ b/tests/browser/recette.mjs @@ -98,3 +98,72 @@ export async function hiddenUnderActionBar(target) { return hidden; }); } + +/** + * Les largeurs d'un écran du tutoriel, en px CSS : le plateau, la colonne + * de texte, et la largeur de la page (scrollWidth) face à celle de l'écran. + * Une page plus large que l'écran défile de côté ; sur un téléphone, Chrome + * la dézoome pour la montrer en entier, et tout l'écran paraît rétréci. + */ +export async function tutorialWidths(target) { + return target.evaluate(() => { + const width = (selector) => { + const el = document.querySelector(selector); + return el === null ? 0 : el.getBoundingClientRect().width; + }; + const page = document.scrollingElement; + // Le plus à droite des descendants du tutoriel, pour nommer le + // coupable d'un débordement. + let widest = { what: "", right: 0 }; + for (const el of document.querySelectorAll("#tutorial *")) { + const r = el.getBoundingClientRect(); + if (r.width > 0 && r.right > widest.right) { + const name = el.id + ? `#${el.id}` + : `${el.tagName.toLowerCase()}${[...el.classList].map((c) => `.${c}`).join("")}`; + widest = { what: name, right: r.right }; + } + } + return { + board: width("#tutorial-board"), + text: width("#tutorial .tutorial-body"), + page: Math.max(page.scrollWidth, document.body.scrollWidth), + screen: innerWidth, + widest, + }; + }); +} + +/** + * Règle de la recette : pour une taille d'écran donnée, le plateau du + * tutoriel a la même largeur à toutes les étapes, et aucune page n'est plus + * large que l'écran. views : [{ name, size, board, text, page, screen, + * widest }]. Renvoie la liste des écarts, décrits (vide si tout va bien). + */ +export function tutorialWidthProblems(views) { + const problems = []; + const bySize = new Map(); + for (const v of views) { + bySize.set(v.size, [...(bySize.get(v.size) ?? []), v]); + if (v.page > v.screen + 0.5) { + problems.push( + `${v.name} en ${v.size} : page de ${v.page} px pour ${v.screen} px ` + + `(le plus à droite : ${v.widest.what}, ${Math.round(v.widest.right)} px)`, + ); + } + } + for (const [size, list] of bySize) { + const widths = list.map((v) => v.board); + const min = Math.min(...widths); + const max = Math.max(...widths); + if (max - min > 0.5) { + const odd = list + .filter((v) => Math.abs(v.board - max) > 0.5) + .map((v) => `${v.name} ${v.board.toFixed(1)}`); + problems.push( + `${size} : plateau de ${min.toFixed(1)} à ${max.toFixed(1)} px (${odd.join(", ")})`, + ); + } + } + return problems; +} diff --git a/web/app.css b/web/app.css index 368b25c..f1b8457 100644 --- a/web/app.css +++ b/web/app.css @@ -295,6 +295,15 @@ a { flex-direction: column; } +/* Les pages sont des éléments de la colonne flexible #screen. Leurs marges + * automatiques les centrent, mais annulent leur étirement en largeur : sans + * largeur explicite, une page au contenu court se réduit à la largeur de ce + * contenu (le plateau de l'étape 6 « à vous » du tutoriel passait de 396 à + * 300 px en 412 × 915). Leur max-width les borne toujours. */ +.screen > .page { + width: 100%; +} + .spacer { flex: 1; } From fbf317909a6360ccf52d12f64c7fab1d1b90a533 Mon Sep 17 00:00:00 2001 From: Pierre MARTIN Date: Tue, 10 Nov 2026 19:44:36 +0100 Subject: [PATCH 5/8] =?UTF-8?q?Sc=C3=A9nario=20de=20sortie=20:=20mise=20en?= =?UTF-8?q?=20forme=20Biome?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- app/tests/steps/jouer-une-partie.steps.ts | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/app/tests/steps/jouer-une-partie.steps.ts b/app/tests/steps/jouer-une-partie.steps.ts index bc06205..62c3277 100644 --- a/app/tests/steps/jouer-une-partie.steps.ts +++ b/app/tests/steps/jouer-une-partie.steps.ts @@ -482,16 +482,13 @@ Given("un joueur a sorti tous ses pions", async () => { // l'ordinateur un pion sur sa case 1 et un sur sa case 2. const SIX_AND_ONE = "BQAACAIAAAAAAA"; -Given( - "vous avez un pion sur la flèche 6 et un sur la flèche 1, avec le jet 6-1", - async () => { - game = Game.start(engine, { - setup: { position: SIX_AND_ONE, firstTurn: "player" }, - dice: fixedDice([6, 1]), - }); - await game.roll(); - }, -); +Given("vous avez un pion sur la flèche 6 et un sur la flèche 1, avec le jet 6-1", async () => { + game = Game.start(engine, { + setup: { position: SIX_AND_ONE, firstTurn: "player" }, + dice: fixedDice([6, 1]), + }); + await game.roll(); +}); Then(/^vous pouvez valider ou jouer encore (.+)$/, async (text: string) => { assert.equal(accepted, true); From ce36d738fb49bc227d67ee2993b64945cf48106c Mon Sep 17 00:00:00 2001 From: Pierre MARTIN Date: Tue, 10 Nov 2026 20:05:14 +0100 Subject: [PATCH 6/8] =?UTF-8?q?Saisie=20:=20plus=20de=20demi-coup=20demand?= =?UTF-8?q?=C3=A9=20une=20fois=20tous=20les=20d=C3=A9s=20jou=C3=A9s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Après un double complet, la saisie restée ouverte demandait au moteur un cinquième demi-coup, que la ligne de commande refuse (erreur dans la page, relevée par make test-browser). Test : un moteur qui refuse plus de demi-coups que de dés. Coup de l'ordinateur : la recette du navigateur vérifie la ligne « L'ordinateur a joué … » au premier coup montré. Co-Authored-By: Claude Opus 5.5 --- app/src/domain/game/game.ts | 16 +++++++++++++-- app/tests/bear-off-input.test.ts | 34 ++++++++++++++++++++++++++++++++ tests/browser/play.mjs | 6 ++++++ 3 files changed, 54 insertions(+), 2 deletions(-) diff --git a/app/src/domain/game/game.ts b/app/src/domain/game/game.ts index 6b66e7f..70963d5 100644 --- a/app/src/domain/game/game.ts +++ b/app/src/domain/game/game.ts @@ -330,11 +330,13 @@ export class Game { * 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 : même après un coup - * complet, c'est le moteur qui dit si un dé reste à jouer. + * complet, c'est le moteur qui dit si un dé reste à jouer. Seule + * exception : une fois tous les dés utilisés (diceLeft), rien n'est + * demandé, le moteur n'acceptant pas plus de demi-coups que de dés. */ async nextHalfMoves(): Promise { const state = this.state; - if (state.turn !== "player" || state.phase !== "moving") { + if (state.turn !== "player" || state.phase !== "moving" || !this.#diceLeft(state)) { return []; } const roll = rollToPlay(state); @@ -410,6 +412,9 @@ export class Game { */ async playHalfMove(halfMove: HalfMove): Promise { const state = this.#expect("player", "moving"); + if (!this.#diceLeft(state)) { + return false; + } const check = await this.#engine.checkHalfMoves(state.position, rollToPlay(state), [ ...this.pendingHalfMoves, halfMove, @@ -424,6 +429,13 @@ export class Game { return true; } + /** Il reste un dé à jouer : moins de demi-coups saisis que de dés + * (quatre pour un double). */ + #diceLeft(state: GameState): boolean { + const roll = rollToPlay(state); + return this.#pending.length < (roll[0] === roll[1] ? 4 : 2); + } + /** Défait le dernier demi-coup saisi du coup en cours (rien s'il n'y en a * pas) : il n'a jamais été dans le journal. */ undoLastHalfMove(): void { diff --git a/app/tests/bear-off-input.test.ts b/app/tests/bear-off-input.test.ts index 802c33e..c2bca54 100644 --- a/app/tests/bear-off-input.test.ts +++ b/app/tests/bear-off-input.test.ts @@ -126,6 +126,40 @@ test("6-1 en sortie : 6/off puis 1/off, comme 1/off puis 6/off", async () => { assert.equal(alone.state.turn, "computer"); }); +test("tous les dés joués, le moteur n'est plus interrogé", async () => { + // Le moteur de la page refuse plus de quatre demi-coups (erreur de la + // ligne de commande) : la partie ne doit pas en demander un cinquième + // après un double, ni un troisième après un jet simple. + const real = await loadNodeEngine(); + const engine: Engine = { + ...real, + checkHalfMoves: (position, roll, halfMoves) => { + const dice = roll[0] === roll[1] ? 4 : 2; + if (halfMoves.length > dice) { + throw new Error(`${halfMoves.length} demi-coups pour ${dice} dés`); + } + return real.checkHalfMoves(position, roll, halfMoves); + }, + }; + const double = await rolled(engine, bearOffPosition([0, 0, 2, 2, 0, 0]), [2, 2]); + for (const h of [ + { from: 4, to: 2 }, + { from: 4, to: 2 }, + { from: 3, to: 1 }, + { from: 3, to: 1 }, + ]) { + assert.equal(await double.playHalfMove(h), true); + } + assert.equal(double.moveComplete, true); + assert.deepEqual(await double.nextHalfMoves(), []); + assert.equal(await double.playHalfMove({ from: 2, to: 0 }), false); + + const simple = await rolled(engine, SIX_AND_ONE, [6, 1]); + assert.equal(await simple.playHalfMove({ from: 1, to: 0 }), true); + assert.equal(await simple.playHalfMove({ from: 6, to: 0 }), true); + assert.deepEqual(await simple.nextHalfMoves(), []); +}); + test("en sortie, tout ordre accepté par le moteur l'est par la partie", async () => { const engine = await loadNodeEngine(); // Toutes les fins de partie d'un à trois pions dans le jan intérieur, diff --git a/tests/browser/play.mjs b/tests/browser/play.mjs index 94f1cd8..2b47443 100644 --- a/tests/browser/play.mjs +++ b/tests/browser/play.mjs @@ -1193,6 +1193,12 @@ for (let actions = 0; ; actions++) { tracesSeen++; if (!moments.has("coup-ordinateur")) { moments.add("coup-ordinateur"); + // La ligne du dernier coup (portrait seulement : en paysage, le + // plateau occupe tout l'écran), dans la notation du moteur. + const played = (await page.textContent("#info")) ?? ""; + check(/^L'ordinateur a joué \d-\d : \S.*\.$/.test(played), + `coup de l'ordinateur mal écrit : « ${played} »`); + log(`coup de l'ordinateur : « ${played} »`); await review("4-coup-ordinateur"); } if (replays < 2) { From 90f835869a249fca61924252d2e69eb1b8f508c1 Mon Sep 17 00:00:00 2001 From: Pierre MARTIN Date: Tue, 10 Nov 2026 20:24:22 +0100 Subject: [PATCH 7/8] =?UTF-8?q?Plan=20des=20corrections=20clos=20:=20r?= =?UTF-8?q?=C3=A9sultat=20dans=20la=20description=20de=20la=20MR?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- docs/plans/2026-11-10-corrections.md | 56 ---------------------------- 1 file changed, 56 deletions(-) delete mode 100644 docs/plans/2026-11-10-corrections.md diff --git a/docs/plans/2026-11-10-corrections.md b/docs/plans/2026-11-10-corrections.md deleted file mode 100644 index 7238081..0000000 --- a/docs/plans/2026-11-10-corrections.md +++ /dev/null @@ -1,56 +0,0 @@ -# Corrections : saisie de la sortie, largeur du tutoriel, notation de l'ordinateur - -- **Date** : 2026-11-10 -- **Étape** : recette de l'étape 1 (après 1e) -- **Statut** : en cours -- **Exécutant** : sous-agent general-purpose, dans un worktree - -## Objectif - -Corriger trois défauts relevés à la recette : un ordre de saisie refusé pendant -la sortie des pions, l'étape 6 du tutoriel moins large que les autres sur le -Fairphone 4, et la ligne « L'ordinateur a joué … » écrite à partir des -demi-coups au lieu de la notation du moteur. - -## Consignes - -1. **Saisie de la sortie (prioritaire).** Avec un 6-1 en sortie, 6/off avant - 1/off est refusé alors que 1/off puis 6/off passe (position - BQAACAIAAAAAAA, « Jouer à partir d'ici »). Trouver la cause dans la chaîne - de saisie demi-coup par demi-coup (`nextHalfMoves` dans game.ts, - `engine_check_half_moves`, l'adaptateur). Le moteur reste la seule autorité - (ADR 0007) : un défaut côté C se corrige dans `engine/engine.c`, jamais dans - `engine/gnubg/`, avec un test dans `tests/test_engine.sh`, en comparant aux - coups légaux de gnubg pour les deux ordres. Ajouter un test (unitaire ou - scénario Gherkin) qui joue les deux ordres sur le vrai moteur. Chercher et - tester les cas voisins : dé plus grand que la flèche la plus haute, doubles - en sortie. -2. **Largeur de l'étape 6 du tutoriel.** Mesurer dans `make test-browser` la - largeur du plateau et de la colonne de texte à chaque étape (écrans - d'explication et « à vous » : départ, refusé, réussi) en 412×915, 915×412 - et 844×320. Ajouter à la recette automatique la règle : même largeur de - plateau à toutes les étapes pour une taille d'écran donnée. Trouver la - cause et la corriger ; si rien ne se reproduit, le dire avec les mesures. -3. **Notation du coup de l'ordinateur.** La ligne « L'ordinateur a joué … » - utilise la notation du moteur, comme le bilan, l'analyse et la feuille du - maître. Tester que le même coup s'écrit pareil sur l'écran de jeu et dans - l'analyse. - -Contraintes : CSP stricte (aucun `.style.`), aucune nouvelle dépendance, -`-j2` au plus, moteur recompilé une seule fois et seulement s'il change ; -vérifier avec `docker ps` qu'aucun test navigateur ne tourne avant -`make test-browser`. - -## Critères de réussite - -- `make lint`, `make test` et `make test-browser` (suite complète) passent. -- Un test sur le vrai moteur joue 6/off puis 1/off et 1/off puis 6/off dans - la position BQAACAIAAAAAAA, et les cas voisins. -- La recette automatique vérifie la largeur constante du plateau du tutoriel. -- Un test compare la notation du coup de l'ordinateur sur l'écran de jeu et - dans l'analyse. -- Branche `corrections-sortie-tutoriel` poussée, sans MR. - -## Résultat - -À remplir à la clôture. From 7a6c0ac2accfdc9f68fa9d29011171e0c2f16861 Mon Sep 17 00:00:00 2001 From: Pierre MARTIN Date: Wed, 11 Nov 2026 21:53:14 +0100 Subject: [PATCH 8/8] =?UTF-8?q?Aide=20:=20texte=20de=20=C2=AB=20Sortir=20s?= =?UTF-8?q?es=20pions=20dans=20l'ordre=20de=20son=20choix=20=C2=BB=20chois?= =?UTF-8?q?i=20par=20Pierre?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- docs/scenarios/jouer-une-partie.feature | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/scenarios/jouer-une-partie.feature b/docs/scenarios/jouer-une-partie.feature index 902a570..5de42e5 100644 --- a/docs/scenarios/jouer-une-partie.feature +++ b/docs/scenarios/jouer-une-partie.feature @@ -145,11 +145,11 @@ Fonctionnalité: Jouer une partie contre l'ordinateur @position:BQAACAIAAAAAAA Scénario: Sortir ses pions dans l'ordre de son choix Une fois tous vos pions dans les six dernières flèches, vous les - sortez dans l'ordre que vous voulez. Avec 6-1 et un pion sur les flèches - 6 et 1, sortez celui de la 6 puis celui de la 1, ou l'inverse. Après le - premier, « Jouer ce coup » apparaît déjà : le 1 pourrait aussi faire - avancer ce pion de la 6 avant sa sortie, ce qui laisse l'autre en place. - Vous pouvez valider ainsi, ou sortir le second pion. + sortez dans l'ordre que vous voulez. Avec 6-1, un pion sur la flèche 6 + et un sur la flèche 1 : sortez l'un puis l'autre, ou l'inverse. Si vous + ne sortez que le pion de la 6, « Jouer ce coup » apparaît déjà, car ce + pion a pu utiliser vos deux dés (6/5, puis 5/off). Touchez l'autre pion + pour le sortir aussi. Étant donné que vous avez un pion sur la flèche 6 et un sur la flèche 1, avec le jet 6-1 Quand vous jouez 6/off