From 2c5278965b2fc05f42de9ddc4784c998c8e47daa Mon Sep 17 00:00:00 2001 From: Pierre MARTIN Date: Tue, 3 Nov 2026 07:04:26 +0100 Subject: [PATCH] =?UTF-8?q?Domaine=20:=20coup=20=C3=A0=20valider,=20demi-c?= =?UTF-8?q?oups=20d=C3=A9faits=20un=20par=20un,=20d=C3=A9s=20jou=C3=A9s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- app/src/domain/game/game.ts | 137 +++++++++++++++++++++++++++++------ app/tests/full-game.test.ts | 2 + app/tests/game.test.ts | 100 +++++++++++++++++++++++++ app/tests/move-steps.test.ts | 3 + 4 files changed, 218 insertions(+), 24 deletions(-) diff --git a/app/src/domain/game/game.ts b/app/src/domain/game/game.ts index f6bb4f7..fa40436 100644 --- a/app/src/domain/game/game.ts +++ b/app/src/domain/game/game.ts @@ -79,9 +79,10 @@ export class Game { readonly #dice: DiceSource; readonly #ply: Ply; #journal: GameJournal = EMPTY_JOURNAL; - // Coup du joueur en cours de saisie : pas encore dans le journal. - #pending: HalfMove[] = []; - #pendingPosition: PositionId | null = null; + // Coup du joueur en cours de saisie : pas encore dans le journal (il n'y + // entre qu'à sa validation, confirmMove). Pour chaque demi-coup saisi, la + // position après lui, et si le coup est alors complet, selon le moteur. + #pending: PendingHalfMove[] = []; private constructor(engine: Engine, options: GameOptions) { this.#engine = engine; @@ -141,12 +142,37 @@ export class Game { /** Les demi-coups déjà saisis du coup en cours. */ get pendingHalfMoves(): readonly HalfMove[] { - return this.#pending; + return this.#pending.map((p) => p.halfMove); + } + + /** 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). */ + get moveComplete(): boolean { + return this.#pending.at(-1)?.complete === true; } /** La position à afficher : après les demi-coups déjà saisis. */ get displayedPosition(): PositionId { - return this.#pendingPosition ?? this.state.position; + return this.#pending.at(-1)?.position ?? this.state.position; + } + + /** + * Les dés du jet à jouer (quatre pour un double), et pour chacun s'il est + * déjà utilisé par un demi-coup saisi ; liste vide hors de la saisie d'un + * coup du joueur. Un demi-coup utilise le dé qui l'a produit dans + * nextHalfMoves (de from vers from - dé, ou vers la sortie) : aucune + * règle n'est jugée ici, la légalité vient du moteur. + */ + get diceToPlay(): DieToPlay[] { + const state = this.state; + if (state.turn !== "player" || state.phase !== "moving" || state.roll === null) { + return []; + } + const [a, b] = state.roll; + const dice = a === b ? [a, a, a, a] : [a, b]; + const used = assignDice(dice, this.pendingHalfMoves); + return dice.map((value, i) => ({ value, used: used[i] === true })); } /** Le plateau affiché (après les demi-coups déjà saisis), décodé par le @@ -205,7 +231,7 @@ export class Game { */ async nextHalfMoves(): Promise { const state = this.state; - if (state.turn !== "player" || state.phase !== "moving") { + if (state.turn !== "player" || state.phase !== "moving" || this.moveComplete) { return []; } const roll = rollToPlay(state); @@ -220,7 +246,7 @@ export class Game { const moves = [...candidates.values()]; const checks = await Promise.all( moves.map((h) => - this.#engine.checkHalfMoves(state.position, roll, [...this.#pending, h]), + this.#engine.checkHalfMoves(state.position, roll, [...this.pendingHalfMoves, h]), ), ); return moves.filter((_, i) => checks[i]?.valid === true); @@ -274,34 +300,51 @@ export class Game { /** * Le joueur déplace un pion (un dé). Renvoie false si ce demi-coup ne - * mène à aucun coup légal : il est refusé et rien ne change. Quand les - * demi-coups forment un coup complet, le coup est joué. + * 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). */ async playHalfMove(halfMove: HalfMove): Promise { const state = this.#expect("player", "moving"); - const halfMoves = [...this.#pending, halfMove]; - const check = await this.#engine.checkHalfMoves( - state.position, - rollToPlay(state), - halfMoves, - ); + if (this.moveComplete) { + return false; + } + const check = await this.#engine.checkHalfMoves(state.position, rollToPlay(state), [ + ...this.pendingHalfMoves, + halfMove, + ]); if (!check.valid || check.position === null) { return false; } - if (check.complete) { - this.cancelHalfMoves(); - await this.#playMove("player", halfMoves, check.position); - } else { - this.#pending = halfMoves; - this.#pendingPosition = check.position; - } + this.#pending = [ + ...this.#pending, + { halfMove, position: check.position, complete: check.complete }, + ]; return true; } - /** Oublie les demi-coups saisis du coup en cours. */ + /** 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 { + this.#pending = this.#pending.slice(0, -1); + } + + /** Oublie tous les demi-coups saisis du coup en cours. */ cancelHalfMoves(): void { this.#pending = []; - this.#pendingPosition = null; + } + + /** Le joueur valide son coup complet : il entre dans le journal + * (MovePlayed) et le tour passe. Erreur si le coup n'est pas complet. */ + async confirmMove(): Promise { + this.#expect("player", "moving"); + const last = this.#pending.at(-1); + if (last?.complete !== true) { + throw new Error("le coup n'est pas complet : jouez encore"); + } + const halfMoves = this.pendingHalfMoves; + this.cancelHalfMoves(); + await this.#playMove("player", halfMoves, last.position); } /** Le joueur double, avant de lancer les dés ; l'ordinateur prend ou passe @@ -454,6 +497,52 @@ export class Game { } } +/** Un dé du jet à jouer, et s'il est déjà utilisé par un demi-coup saisi. */ +export interface DieToPlay { + readonly value: number; + readonly used: boolean; +} + +/** Un demi-coup saisi, pas encore validé. */ +interface PendingHalfMove { + readonly halfMove: HalfMove; + /** La position après lui, vue du joueur. */ + readonly position: PositionId; + /** Les demi-coups saisis jusqu'à lui forment un coup complet. */ + readonly complete: boolean; +} + +/** + * Quels dés les demi-coups ont utilisés : chaque demi-coup reçoit un dé + * différent qui le produit (de from vers from - dé, ou vers la sortie si le + * dé dépasse), comme les candidats de nextHalfMoves. Les dés sont essayés du + * plus petit au plus grand : un pion sorti l'est avec le plus petit dé + * suffisant. Si aucune répartition ne convient, les premiers dés sont + * marqués, un par demi-coup. + */ +function assignDice(dice: readonly number[], halfMoves: readonly HalfMove[]): boolean[] { + const order = dice.map((_, i) => i).sort((i, j) => (dice[i] ?? 0) - (dice[j] ?? 0)); + const used = dice.map(() => false); + const fits = (h: HalfMove, die: number) => Math.max(h.from - die, OFF) === h.to; + const place = (k: number): boolean => { + const h = halfMoves[k]; + if (h === undefined) { + return true; + } + for (const i of order) { + if (!used[i] && fits(h, dice[i] ?? 0)) { + used[i] = true; + if (place(k + 1)) { + return true; + } + used[i] = false; + } + } + return false; + }; + return place(0) ? used : dice.map((_, i) => i < halfMoves.length); +} + /** Le jet à jouer, en phase moving. */ function rollToPlay(state: GameState): Roll { if (state.roll === null) { diff --git a/app/tests/full-game.test.ts b/app/tests/full-game.test.ts index 627e979..d698b49 100644 --- a/app/tests/full-game.test.ts +++ b/app/tests/full-game.test.ts @@ -45,6 +45,8 @@ async function playToTheEnd(game: Game, engine: Awaited { + const game = Game.start(fakeEngine(), { dice: fixedDice([3, 1]) }); + await assert.rejects(game.confirmMove(), /pas complet/); + await game.playHalfMove({ from: 8, to: 5 }); + await assert.rejects(game.confirmMove(), /pas complet/); + assert.equal(game.events.length, 2); +}); + +test("Annuler défait les demi-coups un par un, sans toucher au journal", 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 journal = game.journal; + + game.undoLastHalfMove(); + assert.deepEqual(game.pendingHalfMoves, [{ from: 8, to: 5 }]); + assert.equal(game.moveComplete, false); + assert.equal(game.displayedPosition, "HALF"); + + game.undoLastHalfMove(); + assert.deepEqual(game.pendingHalfMoves, []); + assert.equal(game.displayedPosition, START_POSITION); + game.undoLastHalfMove(); // rien à défaire : sans effet + assert.equal(game.journal, journal); + + // Le coup se ressaisit ensuite normalement. + assert.equal(await game.playHalfMove({ from: 8, to: 5 }), true); + assert.equal(await game.playHalfMove({ from: 6, to: 5 }), true); + assert.equal(game.moveComplete, true); +}); + +test("les dés utilisés par les demi-coups saisis sont marqués", async () => { + const game = Game.start(fakeEngine(), { dice: fixedDice([3, 1]) }); + assert.deepEqual(game.diceToPlay, [ + { value: 3, used: false }, + { value: 1, used: false }, + ]); + await game.playHalfMove({ from: 8, to: 5 }); + assert.deepEqual(game.diceToPlay, [ + { value: 3, used: true }, + { value: 1, used: false }, + ]); + await game.playHalfMove({ from: 6, to: 5 }); + assert.deepEqual( + game.diceToPlay.map((d) => d.used), + [true, true], + ); + await game.confirmMove(); + assert.deepEqual(game.diceToPlay, []); +}); + +test("un double donne quatre dés, marqués un par un ; une sortie prend le plus petit dé suffisant", async () => { + const engine = fakeEngine({ + checkHalfMoves: async () => ({ valid: true, complete: false, position: "P" }), + }); + const double = Game.start(engine, { + dice: fixedDice([2, 2]), + setup: { position: "X", firstTurn: "player" }, + }); + await double.roll(); + assert.equal(double.diceToPlay.length, 4); + await double.playHalfMove({ from: 13, to: 11 }); + await double.playHalfMove({ from: 11, to: 9 }); + assert.deepEqual( + double.diceToPlay.map((d) => d.used), + [true, true, false, false], + ); + + const bearOff = Game.start(engine, { + dice: fixedDice([6, 4]), + setup: { position: "X", firstTurn: "player" }, + }); + await bearOff.roll(); + // 3/off : le 4 suffit, le 6 reste. + await bearOff.playHalfMove({ from: 3, to: 0 }); + assert.deepEqual(bearOff.diceToPlay, [ + { value: 6, used: false }, + { value: 4, used: true }, + ]); + // 6/2 demande le 4 : la sortie a donc pris le 6. + await bearOff.playHalfMove({ from: 6, to: 2 }); + assert.deepEqual( + bearOff.diceToPlay.map((d) => d.used), + [true, true], + ); +}); + test("sans coup possible, le tour passe quand le joueur le laisse passer", async () => { const engine = fakeEngine({ legalMoves: async () => [] }); const game = Game.start(engine, { @@ -192,6 +289,7 @@ test("annuler repart du parent ; l'action suivante ouvre une branche", async () const game = Game.start(fakeEngine(), { dice: fixedDice([3, 1, 6, 5, 4, 2]) }); await game.playHalfMove({ from: 8, to: 5 }); await game.playHalfMove({ from: 6, to: 5 }); + await game.confirmMove(); const played = game.events.at(-1); assert.equal(played?.type, "MovePlayed"); @@ -200,6 +298,7 @@ test("annuler repart du parent ; l'action suivante ouvre une branche", async () assert.equal(game.state.turn, "player"); await game.playHalfMove({ from: 8, to: 5 }); await game.playHalfMove({ from: 6, to: 5 }); + await game.confirmMove(); // Deux coups accrochés au même jet : l'ancien reste dans le journal. const moves = game.journal.events.filter((e) => e.type === "MovePlayed"); @@ -244,6 +343,7 @@ test("une partie se reprend depuis son journal ; un journal d'une autre version const game = Game.start(fakeEngine(), { dice: fixedDice([3, 1]) }); await game.playHalfMove({ from: 8, to: 5 }); await game.playHalfMove({ from: 6, to: 5 }); + await game.confirmMove(); const saved = JSON.parse(JSON.stringify(game.journal)); const resumed = Game.resume(fakeEngine(), saved); diff --git a/app/tests/move-steps.test.ts b/app/tests/move-steps.test.ts index 7657036..23f1ebc 100644 --- a/app/tests/move-steps.test.ts +++ b/app/tests/move-steps.test.ts @@ -42,6 +42,7 @@ async function playerMove(position: PositionId, dice: [number, number], move: st for (const halfMove of parseHalfMoves(move)) { assert.equal(await game.playHalfMove(halfMove), true, formatHalfMoves([halfMove])); } + await game.confirmMove(); return game; } @@ -59,6 +60,7 @@ test("un coup simple : un plateau par demi-coup, sans pion frappé", async () => for (const halfMove of parseHalfMoves("8/5 6/5")) { await game.playHalfMove(halfMove); } + await game.confirmMove(); const steps = await game.lastMoveSteps(); assert.ok(steps !== null); assert.equal(steps.side, "player"); @@ -121,6 +123,7 @@ test("le coup de l'ordinateur, vu dans sa propre numérotation", async () => { for (const halfMove of parseHalfMoves("8/5 6/5")) { await game.playHalfMove(halfMove); } + await game.confirmMove(); await game.playComputerTurn(); const steps = await game.lastMoveSteps(); assert.ok(steps !== null);