From 39c85172b44d25b5d916dc055f6b6821747a6452 Mon Sep 17 00:00:00 2001 From: Pierre MARTIN Date: Tue, 3 Nov 2026 02:07:27 +0100 Subject: [PATCH] =?UTF-8?q?Aucun=20coup=20possible=20:=20le=20joueur=20con?= =?UTF-8?q?tinue=20lui-m=C3=AAme?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le tour du joueur ne passe plus tout seul quand son jet ne permet aucun coup : le domaine le dit (cannotMove) et le joueur laisse passer son tour (skipTurn, bouton « Continuer »). Les dés d'un jet sans coup, du joueur comme de l'ordinateur, restent affichés barrés avec un message jusqu'à l'action suivante. Scénario « Aucun coup possible avec votre jet ». Co-Authored-By: Claude Opus 5.5 --- app/src/domain/game/game.ts | 31 +++++++--- app/src/ui/app.ts | 74 ++++++++++++++++++----- app/src/ui/board-view.ts | 10 ++- app/src/ui/texts.ts | 5 +- app/tests/full-game.test.ts | 7 ++- app/tests/game.test.ts | 23 ++++++- app/tests/steps/jouer-une-partie.steps.ts | 38 ++++++++++++ docs/scenarios/jouer-une-partie.feature | 12 ++++ tests/browser/play.mjs | 28 ++++++++- web/app.css | 9 +++ web/index.html | 1 + 11 files changed, 209 insertions(+), 29 deletions(-) diff --git a/app/src/domain/game/game.ts b/app/src/domain/game/game.ts index 08e41f4..f6bb4f7 100644 --- a/app/src/domain/game/game.ts +++ b/app/src/domain/game/game.ts @@ -244,15 +244,32 @@ export class Game { // ------------------------------------------------------------- Joueur - /** Le joueur lance les dés. S'il ne peut pas jouer, son tour passe. */ + /** Le joueur lance les dés. S'il ne peut pas jouer (cannotMove), son + * tour ne passe qu'une fois qu'il l'a constaté (skipTurn). */ async roll(): Promise { - const state = this.#expect("player", "rolling"); - const roll = rollDice(this.#dice); - this.#append({ type: "DiceRolled", side: "player", roll }); - const moves = await this.#engine.legalMoves(state.position, roll); - if (moves.length === 0) { - await this.#playMove("player", [], null); + this.#expect("player", "rolling"); + this.#append({ type: "DiceRolled", side: "player", roll: rollDice(this.#dice) }); + } + + /** Le joueur a lancé les dés, mais le moteur ne lui donne aucun coup + * légal : il ne peut que laisser passer son tour. */ + async cannotMove(): Promise { + const state = this.state; + if (state.turn !== "player" || state.phase !== "moving") { + return false; } + const moves = await this.#engine.legalMoves(state.position, rollToPlay(state)); + return moves.length === 0; + } + + /** Le joueur, qui ne peut pas jouer son jet, laisse passer son tour. + * Erreur s'il a un coup possible. */ + async skipTurn(): Promise { + this.#expect("player", "moving"); + if (!(await this.cannotMove())) { + throw new Error("vous avez un coup possible : jouez-le"); + } + await this.#playMove("player", [], null); } /** diff --git a/app/src/ui/app.ts b/app/src/ui/app.ts index a2b1dca..9c2e8de 100644 --- a/app/src/ui/app.ts +++ b/app/src/ui/app.ts @@ -52,6 +52,7 @@ export function startApp(options: AppOptions): void { take: byId("take"), drop: byId("drop"), cancel: byId("cancel"), + skip: byId("skip"), replay: byId("replay"), again: byId("again"), newGame: byId("new-game"), @@ -67,6 +68,7 @@ export function startApp(options: AppOptions): void { ui.take.textContent = TEXTS.take; ui.drop.textContent = TEXTS.drop; ui.cancel.textContent = TEXTS.cancel; + ui.skip.textContent = TEXTS.skip; ui.replay.textContent = TEXTS.replay; ui.replay.title = TEXTS.replayTitle; ui.again.textContent = TEXTS.newGame; @@ -80,6 +82,8 @@ export function startApp(options: AppOptions): void { let targets = new Map>(); let selected: Place | null = null; let canDouble = false; + // Le joueur a lancé les dés mais ne peut pas jouer : il doit continuer. + let stuck = false; let busy = true; let info = ""; // Le dernier coup de l'ordinateur, montré (traces, bouton « Revoir ») @@ -98,6 +102,7 @@ export function startApp(options: AppOptions): void { sides = await game.displayedBoard(); targets = moveTargets(await game.nextHalfMoves()); canDouble = await game.canDouble(); + stuck = await game.cannotMove(); if (selected !== null && !targets.has(selected)) { selected = null; } @@ -117,7 +122,7 @@ export function startApp(options: AppOptions): void { const model = boardModel(sides); if (!animating) { renderBoard(ui.board, model, { - dice: shownDice(game), + dice: shownDice(game, stuck), cube: state.cube, movable: new Set(targets.keys()), selected, @@ -129,22 +134,41 @@ export function startApp(options: AppOptions): void { }); } ui.pips.textContent = TEXTS.pipCounts(model.pipCount.player, model.pipCount.computer); - ui.info.textContent = info; - ui.prompt.textContent = prompt(game, selected !== null, canDouble); + // Un jet sans coup possible reste annoncé jusqu'à l'action suivante, + // même après un rechargement de la page. + if (stuck && state.roll !== null) { + ui.info.textContent = TEXTS.youCannotMove(state.roll); + } else if (computerCouldNotMove(game)) { + ui.info.textContent = TEXTS.computerCannotMove(rollBefore(game)); + } else { + ui.info.textContent = info; + } + ui.prompt.textContent = stuck + ? TEXTS.pressContinue + : 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"); + show(ui.cancel, player && state.phase === "moving" && !stuck); + show(ui.skip, stuck); 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"); show(ui.replay, shownMove !== null); - for (const button of [ui.roll, ui.take, ui.drop, ui.replay, ui.again, ui.newGame]) { + for (const button of [ + ui.roll, + ui.take, + ui.drop, + ui.skip, + ui.replay, + ui.again, + ui.newGame, + ]) { button.disabled = busy; } // Pour le style et pour les tests dans un navigateur : l'écran @@ -192,7 +216,7 @@ export function startApp(options: AppOptions): void { if (game === null || window.matchMedia("(prefers-reduced-motion: reduce)").matches) { return; } - const decor = { dice: shownDice(game), cube: game.state.cube, ...NO_MARKS }; + const decor = { dice: shownDice(game, false), cube: game.state.cube, ...NO_MARKS }; const draw = (i: number) => { const boards = steps.boards[i]; if (boards !== undefined) { @@ -267,10 +291,13 @@ export function startApp(options: AppOptions): void { 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)); - } + }); + + // Aucun coup possible : le joueur laisse passer son tour. + ui.skip.onclick = () => + act(async (g) => { + info = ""; + await g.skipTurn(); }); ui.double.onclick = () => @@ -359,7 +386,16 @@ export function startApp(options: AppOptions): void { // ---------------------------------------------------------- Démarrage ui.prompt.textContent = TEXTS.loading; - for (const button of [ui.roll, ui.double, ui.take, ui.drop, ui.cancel, ui.replay, ui.again]) { + for (const button of [ + ui.roll, + ui.double, + ui.take, + ui.drop, + ui.cancel, + ui.skip, + ui.replay, + ui.again, + ]) { button.hidden = true; } ui.newGame.disabled = true; @@ -403,15 +439,25 @@ function rollBefore(game: Game): readonly number[] { } /** 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[] { + * (au jet d'ouverture, un dé chacun). Ils sont barrés si le jet ne permet + * aucun coup : le joueur est bloqué (stuck), ou l'ordinateur vient de passer + * son tour sans bouger. */ +function shownDice(game: Game, stuck: boolean): ShownDie[] { const rolled = game.events.findLast((e) => e.type === "DiceRolled"); if (rolled?.type !== "DiceRolled") { return []; } + const blocked = stuck || computerCouldNotMove(game); const sides: Side[] = rolled.side === null ? ["player", "computer"] : [rolled.side, rolled.side]; - return rolled.roll.map((value, i) => ({ value, side: sides[i] ?? "player" })); + return rolled.roll.map((value, i) => ({ value, side: sides[i] ?? "player", blocked })); +} + +/** La dernière chose jouée (fin de partie mise à part) est un tour de + * l'ordinateur qui n'a pas pu jouer son jet. */ +function computerCouldNotMove(game: Game): boolean { + const last = game.events.findLast((e) => e.type !== "GameEnded"); + return last?.type === "MovePlayed" && last.side === "computer" && last.halfMoves.length === 0; } /** Le dernier coup de l'ordinateur, demi-coup par demi-coup, s'il est la diff --git a/app/src/ui/board-view.ts b/app/src/ui/board-view.ts index 422d62e..e742699 100644 --- a/app/src/ui/board-view.ts +++ b/app/src/ui/board-view.ts @@ -17,6 +17,8 @@ import { type BoardModel, type MoveTrace, type Place, pointSlot } from "./board- export interface ShownDie { readonly value: number; readonly side: Side; + /** Le jet ne permet aucun coup : le dé est estompé et barré. */ + readonly blocked: boolean; } /** Ce qui s'ajoute aux pions : dés, videau, mises en évidence. */ @@ -470,7 +472,10 @@ function drawDice(svg: SVGSVGElement, dice: readonly ShownDie[]): void { 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 }); + const group = add(svg, "g", { + class: die.blocked ? "die die-blocked" : "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", { @@ -480,6 +485,9 @@ function drawDice(svg: SVGSVGElement, dice: readonly ShownDie[]): void { r: 3.5, }); } + if (die.blocked) { + add(group, "line", { class: "die-strike", x1: x, y1: y + DIE, x2: x + DIE, y2: y }); + } }); } diff --git a/app/src/ui/texts.ts b/app/src/ui/texts.ts index d785aee..fa01cb4 100644 --- a/app/src/ui/texts.ts +++ b/app/src/ui/texts.ts @@ -37,6 +37,7 @@ export const TEXTS = { take: "Prendre", drop: "Passer", cancel: "Annuler", + skip: "Continuer", replay: "Revoir", replayTitle: "Revoir le dernier coup de l'ordinateur", newGame: "Nouvelle partie", @@ -59,8 +60,8 @@ export const TEXTS = { 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.`, + youCannotMove: (dice: readonly number[]) => `Aucun coup possible avec ${roll(dice)}.`, + pressContinue: "Touchez « Continuer » : le tour passe à l'ordinateur.", thinking: "L'ordinateur réfléchit…", computerMoved: (dice: readonly number[], move: string, ms: number) => `L'ordinateur a joué ${roll(dice)} : ${move} (${seconds(ms)}).`, diff --git a/app/tests/full-game.test.ts b/app/tests/full-game.test.ts index 23a4c69..627e979 100644 --- a/app/tests/full-game.test.ts +++ b/app/tests/full-game.test.ts @@ -36,7 +36,12 @@ async function playToTheEnd(game: Game, engine: Awaited { +test("sans coup possible, le tour passe quand le joueur le laisse passer", async () => { const engine = fakeEngine({ legalMoves: async () => [] }); const game = Game.start(engine, { dice: fixedDice([6, 6]), setup: { position: "BLOCKED", firstTurn: "player" }, }); await game.roll(); + // Le tour ne passe pas tout seul : le jet reste à « jouer ». + assert.equal(game.state.phase, "moving"); + assert.equal(game.state.turn, "player"); + assert.equal(await game.cannotMove(), true); + await game.skipTurn(); const last = game.events.at(-1); assert.ok(last?.type === "MovePlayed"); assert.deepEqual(last.halfMoves, []); assert.equal(game.state.turn, "computer"); }); +test("le joueur ne peut pas laisser passer son tour s'il a un coup", async () => { + const engine = fakeEngine({ + legalMoves: async () => [ + { notation: "8/5", halfMoves: [{ from: 8, to: 5 }], position: "P" }, + ], + }); + const game = Game.start(engine, { + dice: fixedDice([3, 1]), + setup: { position: "X", firstTurn: "player" }, + }); + await game.roll(); + assert.equal(await game.cannotMove(), false); + await assert.rejects(game.skipTurn(), /coup possible/); + assert.equal(game.state.phase, "moving"); +}); + test("l'ordinateur double si le moteur le recommande ; le joueur prend", async () => { const engine = fakeEngine({ cubeDecision: async () => ({ available: true, double: true, take: true }), diff --git a/app/tests/steps/jouer-une-partie.steps.ts b/app/tests/steps/jouer-une-partie.steps.ts index 181430f..0cb5b91 100644 --- a/app/tests/steps/jouer-une-partie.steps.ts +++ b/app/tests/steps/jouer-une-partie.steps.ts @@ -33,6 +33,11 @@ const COMPUTER_DOUBLES = "6O5uAAC4uzsAAA"; // l'ordinateur n'en a sorti aucun. const PLAYER_ALMOST_DONE = "/38AAABQAAAAAA"; +// Le joueur, au trait : un pion sur la barre, quatorze sur sa case 6. +// L'ordinateur tient ses six premières cases (vos cases 19 à 24), où votre +// pion devrait rentrer. +const PLAYER_SHUT_OUT = "t+0OAADg/wcAQA"; + // L'ordinateur, au trait : un pion sur sa case 13, quatorze sur sa case 1. // Vous : un blot sur votre case 15 (sa case 10), deux pions sur votre case // 13 (sa case 12), douze sur votre case 6. Avec 3-1, son seul coup permis @@ -193,6 +198,39 @@ Then( }, ); +// ------------------------------------------------- Aucun coup possible + +Given("votre pion sur la barre ne peut pas rentrer", () => { + // Les six cases où il pourrait rentrer sont tenues par l'ordinateur. + game = Game.start(engine, { + setup: { position: PLAYER_SHUT_OUT, firstTurn: "player" }, + dice: fixedDice([6, 5]), + }); +}); + +When("vous lancez les dés", async () => { + await game.roll(); +}); + +Then("aucun coup n'est possible avec votre jet", async () => { + assert.deepEqual(game.state.roll, [6, 5]); + assert.equal(await game.cannotMove(), true); + assert.deepEqual(await game.nextHalfMoves(), []); +}); + +Then("c'est toujours à vous", () => { + assert.equal(game.state.turn, "player"); + assert.equal(game.state.phase, "moving"); + assert.equal(lastEvent().type, "DiceRolled"); +}); + +When("vous continuez", async () => { + await game.skipTurn(); + const event = lastEvent(); + assert.ok(event.type === "MovePlayed" && event.side === "player"); + assert.deepEqual(event.halfMoves, []); +}); + // ---------------------------------------------- Voir le coup de l'ordinateur Given("un de vos pions est seul à portée de l'ordinateur", () => { diff --git a/docs/scenarios/jouer-une-partie.feature b/docs/scenarios/jouer-une-partie.feature index 8e32874..5e5e2d1 100644 --- a/docs/scenarios/jouer-une-partie.feature +++ b/docs/scenarios/jouer-une-partie.feature @@ -30,6 +30,18 @@ Fonctionnalité: Jouer une partie contre l'ordinateur Quand ses dés sont tirés Alors il joue le coup recommandé par le moteur en moins de 2 secondes + Scénario: Aucun coup possible avec votre jet + Il arrive que les dés ne permettent de bouger aucun pion. Votre jet reste + affiché avec un message, et le tour ne passe à l'ordinateur qu'une fois + que vous avez touché « Continuer ». + + Étant donné que votre pion sur la barre ne peut pas rentrer + Quand vous lancez les dés + Alors aucun coup n'est possible avec votre jet + Et c'est toujours à vous + Quand vous continuez + Alors c'est ensuite au tour de l'ordinateur + Scénario: Voir le coup de l'ordinateur Après chaque coup de l'ordinateur, ses pions glissent un par un sur le plateau. Restent ensuite, jusqu'à votre prochaine action, une flèche par diff --git a/tests/browser/play.mjs b/tests/browser/play.mjs index daf72dd..1f1f03a 100644 --- a/tests/browser/play.mjs +++ b/tests/browser/play.mjs @@ -101,7 +101,7 @@ async function waitForPlayer() { const b = document.getElementById(id); return !b.hidden && !b.disabled; }; - for (const id of ["again", "take", "roll"]) { + for (const id of ["again", "take", "skip", "roll"]) { if (usable(id)) { return id; } @@ -153,8 +153,18 @@ async function shownTraces() { /** Après un tour de l'ordinateur : ses traces sont là si son coup est la * dernière chose jouée, et rien sinon. */ async function checkTraces() { - const move = computerMoveToShow(await savedJournal()); + const journal = await savedJournal(); + const move = computerMoveToShow(journal); const traces = await shownTraces(); + const last = journal.events.filter((e) => e.type !== "GameEnded").at(-1); + if (last?.type === "MovePlayed" && last.side === "computer" && last.halfMoves.length === 0) { + // L'ordinateur n'a pas pu jouer : son jet reste barré, avec un message. + const info = await page.textContent("#info"); + const blocked = await page.locator(".die-blocked").count(); + check(/^L'ordinateur ne peut pas jouer \d-\d\.$/.test(info ?? "") && blocked === 2, + `jet de l'ordinateur sans coup mal montré : « ${info} », ${blocked} dés barrés`); + log(`l'ordinateur ne peut pas jouer : « ${info} »`); + } if (move === null) { check(traces.arrows + traces.landed + traces.ghosts === 0 && !traces.replay, `traces sans coup de l'ordinateur à montrer : ${JSON.stringify(traces)}`); @@ -230,6 +240,7 @@ let reloaded = false; let computerMs = []; let tracesSeen = 0; let replays = 0; +let skips = 0; for (let actions = 0; ; actions++) { check(actions < 800, "la partie ne finit pas"); const todo = await waitForPlayer(); @@ -261,7 +272,17 @@ for (let actions = 0; ; actions++) { if (todo === "again") { break; } - if (todo === "take") { + if (todo === "skip") { + // Aucun coup possible : le jet reste affiché, barré, avec un + // message, et le tour ne passe qu'en touchant « Continuer ». + const info = await page.textContent("#info"); + const blocked = await page.locator(".die-blocked").count(); + check(/^Aucun coup possible avec \d-\d\.$/.test(info ?? "") && blocked === 2, + `aucun coup possible mal montré : « ${info} », ${blocked} dés barrés`); + skips++; + log(`aucun coup possible : « ${info} », vous continuez`); + await page.click("#skip"); + } else if (todo === "take") { log("l'ordinateur double : vous prenez"); await shot("videau-propose"); await page.click("#take"); @@ -314,6 +335,7 @@ check(countMoves(journal, "player") >= 3 && countMoves(journal, "computer") >= 3 check(reloaded, "la reprise après rechargement n'a pas été essayée"); check(replays === 2, "le coup de l'ordinateur n'a pas été revu"); log(`coup de l'ordinateur montré ${tracesSeen} fois, et effacé à chaque action suivante`); +log(skips > 0 ? `« Continuer » demandé ${skips} fois` : "aucun jet sans coup possible dans cette partie"); if (computerMs.length > 0) { log(`temps affichés des tours de l'ordinateur : max ${Math.max(...computerMs)} ms, ` + `${computerMs.length} tours`); diff --git a/web/app.css b/web/app.css index 25e032d..cc01530 100644 --- a/web/app.css +++ b/web/app.css @@ -259,6 +259,15 @@ button:disabled { .pip-player { fill: var(--pip-player); } .pip-computer { fill: var(--pip-computer); } +/* Jet sans coup possible : dés estompés et barrés. */ +.die-blocked { opacity: 0.5; } + +.die-strike { + stroke: var(--text); + stroke-width: 3; + stroke-linecap: round; +} + .cube-face { fill: var(--cube); stroke: var(--checker-player-edge); stroke-width: 2; } .cube-value { diff --git a/web/index.html b/web/index.html index e09253b..70734e6 100644 --- a/web/index.html +++ b/web/index.html @@ -30,6 +30,7 @@ +