From ab8dc512bafc4f411735c4ca709fe5470c00ade4 Mon Sep 17 00:00:00 2001 From: Pierre MARTIN Date: Tue, 10 Nov 2026 19:36:15 +0100 Subject: [PATCH] =?UTF-8?q?Sortie=20:=20un=20coup=20d=C3=A9j=C3=A0=20compl?= =?UTF-8?q?et=20peut=20encore=20se=20prolonger=20(6/off=20puis=201/off=20a?= =?UTF-8?q?vec=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"