diff --git a/app/src/domain/game/game.ts b/app/src/domain/game/game.ts index f638309..1173397 100644 --- a/app/src/domain/game/game.ts +++ b/app/src/domain/game/game.ts @@ -365,6 +365,69 @@ export class Game { return moves.filter((_, i) => checks[i]?.valid === true); } + /** + * Le coup évident qui commence par le pion de from (une case du joueur, + * ou la barre : BAR), après les demi-coups déjà saisis : les demi-coups + * qui restent à jouer pour le compléter, ou null s'il n'y en a pas + * (charte, ecrans-etape-1.md, 1i : le double toucher). Il existe quand + * un seul coup légal complet commence par ce pion, ou quand le moteur ne + * propose qu'un coup. Deux coups sont différents s'ils mènent à des + * positions différentes, comme dans la liste des coups de gnubg. + * + * Aucune règle n'est jugée ici : chaque demi-coup essayé est soumis au + * moteur (checkHalfMoves), qui dit s'il commence un coup légal et si le + * coup est complet. On parcourt ses réponses et on s'arrête dès que deux + * coups complets différents apparaissent. + */ + async obviousMove(from: number): Promise { + // Lus avant toute attente : la réponse vaut pour l'état de l'appel. + const state = this.state; + const pending = this.pendingHalfMoves; + const displayed = this.displayedPosition; + if (state.turn !== "player" || state.phase !== "moving" || !this.#diceLeft(state)) { + return null; + } + const roll = rollToPlay(state); + const limit = (roll[0] === roll[1] ? 4 : 2) - pending.length; + const legal = await this.#engine.legalMoves(state.position, roll); + // Un seul coup légal : il est évident, quel que soit le pion. + const anyChecker = legal.length === 1; + const found = new Map(); + const explore = async (sequence: HalfMove[], position: PositionId): Promise => { + if (found.size > 1 || sequence.length >= limit) { + return; + } + const { player } = await this.#sideBoards(position, "player", state); + const starts = sequence.length === 0 && !anyChecker ? [from] : occupiedPoints(player); + const candidates = new Map(); + for (const start of starts) { + for (const die of roll) { + const to = Math.max(start - die, OFF); + candidates.set(`${start}/${to}`, { from: start, to }); + } + } + for (const halfMove of candidates.values()) { + if (found.size > 1) { + return; + } + const next = [...sequence, halfMove]; + const check = await this.#engine.checkHalfMoves(state.position, roll, [ + ...pending, + ...next, + ]); + if (!check.valid || check.position === null) { + continue; + } + if (check.complete && !found.has(check.position)) { + found.set(check.position, next); + } + await explore(next, check.position); + } + }; + await explore([], displayed); + return found.size === 1 ? ([...found.values()][0] ?? null) : null; + } + /** Le joueur peut-il doubler maintenant ? (Le moteur dit si le videau le * permet.) */ async canDouble(): Promise { diff --git a/app/tests/obvious-move.test.ts b/app/tests/obvious-move.test.ts new file mode 100644 index 0000000..84f49c5 --- /dev/null +++ b/app/tests/obvious-move.test.ts @@ -0,0 +1,82 @@ +// Le coup évident du double toucher (charte, ecrans-etape-1.md, 1i), sur le +// vrai moteur WebAssembly : un seul coup légal complet commence par le pion +// touché, ou le moteur ne propose qu'un coup. +// +// Copyright (C) 2026 les auteurs d'Ustagammon. +// Licence : GPLv3 ou ultérieure (voir LICENSE). + +import assert from "node:assert/strict"; +import { test } from "node:test"; +import 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 } from "../src/domain/game/roll.js"; +import { loadNodeEngine } from "./support/node-engine.js"; + +// Le joueur, au trait : un pion sur sa case 6, un sur sa case 1. +const SIX_AND_ONE = "BQAACAIAAAAAAA"; +// Le joueur, au trait : deux pions, sur ses cases 6 et 5. +const PLAYER_ALMOST_DONE = "/38AAABQAAAAAA"; + +let engine: Engine | null = null; +async function loaded(): Promise { + engine ??= await loadNodeEngine(); + return engine; +} + +/** Une partie à partir d'une position, joueur au trait, dés lancés. */ +async function from(position: string, dice: [number, number]): Promise { + const game = Game.start(await loaded(), { + setup: { position, firstTurn: "player" }, + dice: fixedDice(dice), + }); + await game.roll(); + return game; +} + +function text(halfMoves: readonly HalfMove[] | null): string | null { + return halfMoves === null ? null : formatHalfMoves(halfMoves); +} + +test("au départ avec 3-1 : plusieurs coups commencent par chaque pion, rien d'évident", async () => { + const game = Game.start(await loaded(), { dice: fixedDice([3, 1]) }); + for (const point of [24, 13, 8, 6]) { + assert.equal(await game.obviousMove(point), null, `pion de la ${point}`); + } + // Rien n'a été saisi. + assert.deepEqual(game.pendingHalfMoves, []); +}); + +test("6-1 avec des pions sur 6 et 1 : seul le pion de la 1 a un coup évident", async () => { + const game = await from(SIX_AND_ONE, [6, 1]); + // 1/off, puis le 6 sort l'autre pion : un seul coup. + assert.equal(text(await game.obviousMove(1)), "1/off 6/off"); + // Depuis la 6 : 6/off 1/off, ou 6/5/off (le pion de la 1 reste). + assert.equal(await game.obviousMove(6), null); + // Une case sans pion n'a pas de coup. + assert.equal(await game.obviousMove(3), null); +}); + +test("un seul coup légal : il est évident depuis chaque pion, même à moitié saisi", async () => { + const game = await from(PLAYER_ALMOST_DONE, [6, 6]); + assert.equal((await (await loaded()).legalMoves(game.state.position, [6, 6])).length, 1); + const fromFive = await game.obviousMove(5); + const fromSix = await game.obviousMove(6); + assert.ok(fromFive !== null && fromSix !== null); + assert.equal(fromFive.length, 2); + assert.equal(fromSix.length, 2); + // Une fois 6/off saisi, il ne reste que 5/off. + assert.equal(await game.playHalfMove({ from: 6, to: 0 }), true); + assert.equal(text(await game.obviousMove(5)), "5/off"); + // Le coup évident se joue tel quel, demi-coup par demi-coup. + for (const halfMove of (await game.obviousMove(5)) ?? []) { + assert.equal(await game.playHalfMove(halfMove), true); + } + assert.equal(game.moveComplete, true); +}); + +test("hors du tour de jeu du joueur, aucun coup évident", async () => { + const game = Game.start(await loaded(), { dice: fixedDice([1, 3]) }); + assert.equal(game.state.turn, "computer"); + assert.equal(await game.obviousMove(13), null); +}); diff --git a/app/tests/steps/jouer-une-partie.steps.ts b/app/tests/steps/jouer-une-partie.steps.ts index 62c3277..69be889 100644 --- a/app/tests/steps/jouer-une-partie.steps.ts +++ b/app/tests/steps/jouer-une-partie.steps.ts @@ -511,3 +511,20 @@ Then("le résultat indique le gagnant et les points gagnés", () => { // règle Jacoby, le videau n'a pas bougé. assert.deepEqual(game.state.result, { winner: "player", kind: "gammon", points: 1 }); }); + +// ------------------------------------------------- Glisser, double toucher + +When("vous demandez le coup évident de votre pion de la flèche {int}", async (point: number) => { + positionBefore = game.displayedPosition; + const obvious = await game.obviousMove(point); + accepted = obvious !== null; + for (const halfMove of obvious ?? []) { + accepted &&= await game.playHalfMove(halfMove); + } +}); + +Then("aucun pion n'a bougé", () => { + assert.equal(accepted, false); + assert.deepEqual(game.pendingHalfMoves, []); + assert.equal(game.displayedPosition, positionBefore); +}); diff --git a/docs/scenarios/jouer-une-partie.feature b/docs/scenarios/jouer-une-partie.feature index 5fd8be1..34defc8 100644 --- a/docs/scenarios/jouer-une-partie.feature +++ b/docs/scenarios/jouer-une-partie.feature @@ -30,6 +30,24 @@ Fonctionnalité: Jouer une partie contre l'ordinateur Alors votre coup est accepté Et c'est ensuite au tour de l'ordinateur + @position:BQAACAIAAAAAAA + Scénario: Jouer un pion en le faisant glisser, ou d'un double toucher + Vous pouvez aussi faire glisser un pion jusqu'à sa case d'arrivée : il + suit votre doigt, juste au-dessus pour rester visible, et un pion en + pointillés marque sa place de départ. Lâché ailleurs que sur une case + permise, il revient à sa place. Touchez deux fois de suite un pion pour + jouer le coup évident : quand un seul coup commence par ce pion, ou que + vous n'avez qu'un coup possible. Sinon, rien de plus ne se passe. Votre + coup attend ensuite votre validation, comme d'habitude. Au clavier, les + flèches gauche et droite choisissent un pion, les flèches haut et bas sa + case d'arrivée, et Entrée le pose. + + É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 demandez le coup évident de votre pion de la flèche 6 + Alors aucun pion n'a bougé + Quand vous demandez le coup évident de votre pion de la flèche 1 + Alors vos deux pions sont sortis + Scénario: Pourquoi mon pion part-il dès que je le touche ? Un pion qui n'a qu'une case d'arrivée possible y va dès que vous le touchez, sans attendre un second toucher. Par exemple, avec 3-1, une fois