diff --git a/app/src/ui/app.ts b/app/src/ui/app.ts index 21c535a..9a23ffd 100644 --- a/app/src/ui/app.ts +++ b/app/src/ui/app.ts @@ -13,8 +13,22 @@ import type { GameJournal } from "../domain/game/events.js"; import type { Game, MoveSteps, SideBoards } from "../domain/game/game.js"; import { formatHalfMoves, type HalfMove } from "../domain/game/position.js"; import { otherSide, type Side } from "../domain/game/side.js"; -import { boardModel, moveTargets, moveTrace, type Place, placeOf } from "./board-model.js"; -import { checkerSpot, NO_MARKS, renderBoard, type ShownDie, slideChecker } from "./board-view.js"; +import { + boardModel, + moveTargets, + moveTrace, + onlyHalfMove, + type Place, + placeOf, +} from "./board-model.js"; +import { + checkerSpot, + type DiceAction, + NO_MARKS, + renderBoard, + type ShownDie, + slideChecker, +} from "./board-view.js"; import { showHelp } from "./help.js"; import { TEXTS } from "./texts.js"; @@ -51,6 +65,7 @@ export function startApp(options: AppOptions): void { double: byId("double"), take: byId("take"), drop: byId("drop"), + confirm: byId("confirm"), cancel: byId("cancel"), skip: byId("skip"), replay: byId("replay"), @@ -61,12 +76,19 @@ export function startApp(options: AppOptions): void { game: byId("game"), help: byId("help"), helpContent: byId("help-content"), + doubleDialog: byId("double-dialog"), + doubleQuestion: byId("double-question"), + doubleConfirm: byId("double-confirm"), + doubleCancel: byId("double-cancel"), }; ui.board.setAttribute("aria-label", TEXTS.boardLabel); ui.roll.textContent = TEXTS.roll; ui.double.textContent = TEXTS.double; ui.take.textContent = TEXTS.take; ui.drop.textContent = TEXTS.drop; + ui.confirm.textContent = TEXTS.confirm; + ui.doubleConfirm.textContent = TEXTS.double; + ui.doubleCancel.textContent = TEXTS.cancel; ui.cancel.textContent = TEXTS.cancel; ui.skip.textContent = TEXTS.skip; ui.replay.textContent = TEXTS.replay; @@ -131,9 +153,9 @@ export function startApp(options: AppOptions): void { shownMove === null ? null : moveTrace(shownMove.side, shownMove.halfMoves, shownMove.hits), - // Mêmes conditions que les boutons « Lancer les dés » et - // « Doubler ». - canRoll: !busy && state.turn === "player" && state.phase === "rolling", + // Mêmes conditions que les boutons « Lancer les dés », + // « Valider » et « Doubler ». + diceAction: busy ? null : diceAction(game), canDouble: !busy && canDouble, }); } @@ -156,6 +178,7 @@ export function startApp(options: AppOptions): void { show(ui.roll, player && state.phase === "rolling"); show(ui.double, player && state.phase === "rolling"); ui.double.disabled = busy || !canDouble; + show(ui.confirm, player && state.phase === "moving" && game.moveComplete); show(ui.cancel, player && state.phase === "moving" && !stuck); show(ui.skip, stuck); ui.cancel.disabled = busy || game.pendingHalfMoves.length === 0; @@ -168,6 +191,7 @@ export function startApp(options: AppOptions): void { ui.roll, ui.take, ui.drop, + ui.confirm, ui.skip, ui.replay, ui.again, @@ -304,11 +328,22 @@ export function startApp(options: AppOptions): void { await g.skipTurn(); }); - ui.double.onclick = () => - act(async (g) => { + // Doubler demande confirmation : un double proposé ne se reprend pas. + ui.double.onclick = () => { + if (busy || game === null || !canDouble) { + return; + } + ui.doubleQuestion.textContent = TEXTS.confirmDouble(2 * game.state.cube.value); + ui.doubleDialog.showModal(); + }; + ui.doubleCancel.onclick = () => ui.doubleDialog.close(); + ui.doubleConfirm.onclick = () => { + ui.doubleDialog.close(); + void act(async (g) => { await g.double(); info = g.state.phase === "over" ? "" : TEXTS.computerTakes(g.state.cube.value); }); + }; ui.take.onclick = () => act((g) => { @@ -322,9 +357,17 @@ export function startApp(options: AppOptions): void { info = ""; }); + // Le coup saisi, complet, entre dans la partie : le tour passe. + ui.confirm.onclick = () => + act(async (g) => { + info = ""; + await g.confirmMove(); + }); + + // Annuler défait le dernier demi-coup saisi, un par un. ui.cancel.onclick = () => { if (!busy && game !== null) { - game.cancelHalfMoves(); + game.undoLastHalfMove(); selected = null; shownMove = null; void refresh(); @@ -344,15 +387,30 @@ export function startApp(options: AppOptions): void { ui.newGame.onclick = newGame; ui.again.onclick = newGame; - // Toucher le plateau : un pion jouable, puis une de ses cases d'arrivée. + /** Joue un demi-coup du joueur ; le coup attend ensuite sa suite ou sa + * validation. */ + function playHalfMove(halfMove: HalfMove): void { + void act(async (g) => { + info = ""; + await g.playHalfMove(halfMove); + }); + } + + // Toucher le plateau : un pion jouable, puis une de ses cases d'arrivée + // (ou aucune, s'il n'en a qu'une : il y va tout de suite). ui.board.addEventListener("click", (event) => { const target = event.target as Element | null; - // Toucher les dés en attente ou le videau : comme leurs boutons. + // Toucher les dés (d'un côté ou de l'autre du plateau) ou le videau : + // comme leurs boutons. const action = target?.closest("[data-action]")?.getAttribute("data-action"); if (action === "roll" && !ui.roll.hidden && !ui.roll.disabled) { ui.roll.click(); return; } + if (action === "confirm" && !ui.confirm.hidden && !ui.confirm.disabled) { + ui.confirm.click(); + return; + } if (action === "double" && !ui.double.hidden && !ui.double.disabled) { ui.double.click(); return; @@ -363,10 +421,12 @@ export function startApp(options: AppOptions): void { } const halfMove = selected === null ? undefined : targets.get(selected)?.get(place); if (halfMove !== undefined) { - void act(async (g) => { - info = ""; - await g.playHalfMove(halfMove); - }); + playHalfMove(halfMove); + return; + } + const only = place === selected ? null : onlyHalfMove(targets, place); + if (only !== null) { + playHalfMove(only); return; } selected = targets.has(place) && place !== selected ? place : null; @@ -405,6 +465,7 @@ export function startApp(options: AppOptions): void { ui.double, ui.take, ui.drop, + ui.confirm, ui.cancel, ui.skip, ui.replay, @@ -452,19 +513,44 @@ function rollBefore(game: Game): readonly number[] { return rolled?.type === "DiceRolled" ? rolled.roll : []; } -/** Les dés à montrer : le dernier jet, de la couleur de qui l'a lancé - * (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. */ +/** Ce que fait un toucher sur les dés : lancer quand le joueur peut lancer, + * valider quand son coup est complet. */ +function diceAction(game: Game): DiceAction | null { + const state = game.state; + if (state.turn !== "player") { + return null; + } + if (state.phase === "rolling") { + return "roll"; + } + return state.phase === "moving" && game.moveComplete ? "confirm" : null; +} + +/** Les dés à montrer : le dernier jet, de la couleur de qui l'a lancé (au + * jet d'ouverture, un dé chacun ; quatre pour un double). Pendant que le + * joueur saisit son coup, chaque dé déjà utilisé est estompé. 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 toPlay = game.diceToPlay; 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", blocked })); + // Au jet d'ouverture, un dé chacun. + const sideOf = (i: number): Side => rolled.side ?? (i === 0 ? "player" : "computer"); + if (toPlay.length > 0) { + return toPlay.map((die, i) => ({ + value: die.value, + side: sideOf(i), + blocked: stuck, + used: die.used, + })); + } + const blocked = computerCouldNotMove(game); + const [a, b] = rolled.roll; + const values = a === b ? [a, a, a, a] : [a, b]; + return values.map((value, i) => ({ value, side: sideOf(i), blocked, used: false })); } /** La dernière chose jouée (fin de partie mise à part) est un tour de @@ -519,6 +605,9 @@ function prompt(game: Game, selecting: boolean, canDouble: boolean): string { if (state.phase === "rolling") { return canDouble ? TEXTS.yourRoll : TEXTS.yourRollNoDouble; } + if (state.phase === "moving" && game.moveComplete) { + return TEXTS.confirmOrUndo; + } if (state.phase === "moving" && state.roll !== null) { return selecting ? TEXTS.pickTarget : TEXTS.yourMove(state.roll); } diff --git a/app/src/ui/board-model.ts b/app/src/ui/board-model.ts index d07fe8f..bbf19f1 100644 --- a/app/src/ui/board-model.ts +++ b/app/src/ui/board-model.ts @@ -82,6 +82,19 @@ export function moveTargets(halfMoves: readonly HalfMove[]): Map>, + place: Place, +): HalfMove | null { + const tos = targets.get(place); + if (tos === undefined || tos.size !== 1) { + return null; + } + return [...tos.values()][0] ?? null; +} + /** Ce qui reste dessiné d'un coup une fois joué. */ export interface MoveTrace { /** Le camp qui a joué. */ diff --git a/app/src/ui/board-view.ts b/app/src/ui/board-view.ts index c6ad52e..b96d4ab 100644 --- a/app/src/ui/board-view.ts +++ b/app/src/ui/board-view.ts @@ -20,8 +20,13 @@ export interface ShownDie { readonly side: Side; /** Le jet ne permet aucun coup : le dé est estompé et barré. */ readonly blocked: boolean; + /** Le dé est déjà utilisé par un demi-coup saisi : il est estompé. */ + readonly used: boolean; } +/** Ce que fait un toucher sur les dés : lancer, ou valider le coup saisi. */ +export type DiceAction = "roll" | "confirm"; + /** Ce qui s'ajoute aux pions : dés, videau, mises en évidence. */ export interface BoardDecor { readonly dice: readonly ShownDie[]; @@ -33,8 +38,10 @@ export interface BoardDecor { readonly targets: ReadonlySet; /** Les traces du dernier coup de l'ordinateur, tant qu'on les montre. */ readonly trace: MoveTrace | null; - /** Le joueur peut lancer les dés : ses dés en attente se touchent. */ - readonly canRoll: boolean; + /** Ce que fait un toucher sur la zone des dés, d'un côté ou de l'autre + * du plateau (null : rien). Pour « roll », les dés du joueur en attente + * sont dessinés. */ + readonly diceAction: DiceAction | null; /** Le joueur peut doubler : le videau se touche. */ readonly canDouble: boolean; } @@ -45,7 +52,7 @@ export const NO_MARKS = { selected: null, targets: new Set(), trace: null, - canRoll: false, + diceAction: null, canDouble: false, } as const; @@ -158,9 +165,16 @@ export function renderBoard(svg: SVGSVGElement, board: BoardModel, decor: BoardD if (decor.trace !== null) { drawTrace(svg, board, decor.trace); } - if (decor.canRoll) { - const center = waitingDice(svg); - addAction(hits, "roll", TEXTS.roll, center, 2 * DIE + 8 + MIN_TARGET / 2); + if (decor.diceAction !== null) { + if (decor.diceAction === "roll") { + waitingDice(svg); + } + // Une zone sur chaque moitié du plateau : les dés en attente (à + // gauche) comme le dernier jet (à droite) se touchent. + const label = decor.diceAction === "roll" ? TEXTS.roll : TEXTS.confirm; + for (const x of [FRAME + HALF_WIDTH / 2, RIGHT_HALF + HALF_WIDTH / 2]) { + addAction(hits, decor.diceAction, label, { x, y: MIDDLE }, HALF_WIDTH - 2 * FRAME); + } } if (decor.canDouble) { cube.classList.add("can-double"); @@ -493,7 +507,9 @@ function drawDice(svg: SVGSVGElement, dice: readonly ShownDie[]): void { const x = center - total / 2 + i * (DIE + gap); const y = MIDDLE - DIE / 2; const group = add(svg, "g", { - class: die.blocked ? "die die-blocked" : "die", + class: ["die", die.blocked ? "die-blocked" : "", die.used ? "die-used" : ""] + .filter(Boolean) + .join(" "), "data-value": die.value, }); add(group, "rect", { class: `die-${die.side}`, x, y, width: DIE, height: DIE, rx: 6 }); @@ -535,9 +551,8 @@ function cubeCenter(cube: Cube): Spot { } /** Les dés du joueur en attente d'être lancés, faces vides, au milieu de la - * moitié gauche (la droite garde le dernier jet de l'ordinateur). Renvoie - * leur centre. */ -function waitingDice(svg: SVGSVGElement): Spot { + * moitié gauche (la droite garde le dernier jet de l'ordinateur). */ +function waitingDice(svg: SVGSVGElement): void { const center = { x: FRAME + HALF_WIDTH / 2, y: MIDDLE }; const gap = 8; const group = add(svg, "g", { class: "die die-waiting" }); @@ -551,13 +566,13 @@ function waitingDice(svg: SVGSVGElement): Spot { rx: 6, }); } - return center; } /** * Une zone à toucher qui fait la même chose qu'un bouton (lancer les dés, - * doubler) : au moins MIN_TARGET de côté, centrée sur ce qu'on touche, avec - * un rôle et un nom accessibles. Les boutons restent pour le clavier. + * valider le coup, doubler) : au moins MIN_TARGET de côté, centrée sur ce + * qu'on touche, avec un rôle et un nom accessibles. Les boutons restent + * pour le clavier. */ function addAction( hits: Element, diff --git a/app/src/ui/help.ts b/app/src/ui/help.ts index e064764..67afdd5 100644 --- a/app/src/ui/help.ts +++ b/app/src/ui/help.ts @@ -91,10 +91,8 @@ export async function showHelp(container: HTMLElement): Promise { renderFeature(container, feature); } renderGlossary(container, glossary); - const about = element(container, "p", `${TEXTS.about} `); + const about = element(container, "p", TEXTS.about); about.className = "about"; - const link = element(about, "a", TEXTS.engineTestLink); - link.href = "moteur/"; container.dataset.loaded = "true"; } catch { status.textContent = TEXTS.helpFailed; diff --git a/app/src/ui/texts.ts b/app/src/ui/texts.ts index fa01cb4..22c62b5 100644 --- a/app/src/ui/texts.ts +++ b/app/src/ui/texts.ts @@ -36,6 +36,7 @@ export const TEXTS = { double: "Doubler", take: "Prendre", drop: "Passer", + confirm: "Valider", cancel: "Annuler", skip: "Continuer", replay: "Revoir", @@ -60,6 +61,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.", + confirmOrUndo: + "Touchez vos dés ou « Valider » pour jouer ce coup, ou « Annuler » pour reprendre un pion.", youCannotMove: (dice: readonly number[]) => `Aucun coup possible avec ${roll(dice)}.`, pressContinue: "Touchez « Continuer » : le tour passe à l'ordinateur.", thinking: "L'ordinateur réfléchit…", @@ -67,6 +70,7 @@ export const TEXTS = { `L'ordinateur a joué ${roll(dice)} : ${move} (${seconds(ms)}).`, computerCannotMove: (dice: readonly number[]) => `L'ordinateur ne peut pas jouer ${roll(dice)}.`, + confirmDouble: (value: number) => `Doubler à ${value} ? L'ordinateur pourra prendre ou passer.`, computerDoubles: "L'ordinateur vous propose de doubler : prenez ou passez.", computerTakes: (value: number) => `L'ordinateur prend : la partie vaut maintenant ${points(value)}.`, @@ -90,5 +94,4 @@ export const TEXTS = { helpLoading: "Chargement de l'aide…", helpFailed: "L'aide n'a pas pu se charger.", about: "Ustagammon est un logiciel libre (GPLv3). L'ordinateur joue avec GNU Backgammon.", - engineTestLink: "Test du moteur", } as const; diff --git a/app/tests/steps/jouer-une-partie.steps.ts b/app/tests/steps/jouer-une-partie.steps.ts index 0cb5b91..4244d4c 100644 --- a/app/tests/steps/jouer-une-partie.steps.ts +++ b/app/tests/steps/jouer-une-partie.steps.ts @@ -80,6 +80,15 @@ async function play(text: string): Promise { return true; } +/** Joue les demi-coups puis valide le coup, complet. */ +async function playAndConfirm(text: string): Promise { + if (!(await play(text))) { + return false; + } + await game.confirmMove(); + return true; +} + function lastEvent(): GameEvent { const event = game.events.at(-1); assert.ok(event !== undefined, "aucun événement"); @@ -160,12 +169,62 @@ Then("la position n'a pas changé", () => { assert.equal(lastEvent().type, "DiceRolled"); }); +// ------------------------------------------ Valider ou reprendre son coup + +Then("votre coup attend votre validation", async () => { + assert.equal(accepted, true); + assert.equal(game.moveComplete, true); + // Rien n'est encore dans le journal, et plus aucun pion ne bouge. + assert.equal(lastEvent().type, "DiceRolled"); + assert.equal(game.state.turn, "player"); + assert.deepEqual(await game.nextHalfMoves(), []); +}); + +Then("vos dés sont tous marqués comme joués", () => { + assert.ok(game.diceToPlay.length > 0); + assert.ok(game.diceToPlay.every((die) => die.used)); +}); + +When("vous validez votre coup", async () => { + await game.confirmMove(); +}); + +Then("seul votre {int} est marqué comme joué", (value: number) => { + const used = game.diceToPlay.filter((die) => die.used); + assert.deepEqual( + used.map((die) => die.value), + [value], + ); +}); + +Then("chacun de vos pions jouables n'a plus qu'une case d'arrivée", async () => { + const halfMoves = await game.nextHalfMoves(); + assert.ok(halfMoves.length > 0); + const starts = halfMoves.map((h) => h.from); + assert.equal(new Set(starts).size, starts.length); +}); + +When("vous annulez le dernier demi-coup", () => { + game.undoLastHalfMove(); +}); + +Then("il ne reste de joué que {word}", (text: string) => { + assert.deepEqual(game.pendingHalfMoves, parseHalfMoves(text)); + assert.equal(game.moveComplete, false); + assert.equal(lastEvent().type, "DiceRolled"); +}); + +Then("aucun de vos dés n'est marqué comme joué", () => { + assert.ok(game.diceToPlay.length > 0); + assert.ok(game.diceToPlay.every((die) => !die.used)); +}); + // ------------------------------------------------------- L'ordinateur joue Given("c'est au tour de l'ordinateur", async () => { // Vous commencez avec 3-1, puis l'ordinateur tirera 6-4. game = Game.start(engine, { dice: fixedDice([3, 1, 6, 4]) }); - assert.equal(await play("8/5 6/5"), true); + assert.equal(await playAndConfirm("8/5 6/5"), true); assert.equal(game.state.turn, "computer"); }); @@ -287,6 +346,52 @@ Then("vous pouvez revoir son coup", async () => { // ------------------------------------------------------------------ Videau +// La confirmation elle-même est une question de l'interface (une fenêtre +// « Doubler à 2 ? ») : ces étapes vérifient que rien n'est proposé tant que +// vous n'avez pas confirmé, et ce qui suit votre confirmation. + +Given("vous pouvez doubler", async () => { + game = Game.start(engine, { setup: { position: START_POSITION, firstTurn: "player" } }); + assert.equal(game.state.phase, "rolling"); +}); + +When("vous demandez à doubler", async () => { + assert.equal(await game.canDouble(), true); +}); + +When("vous changez d'avis", () => { + // Vous refermez la question : la partie n'est pas touchée. +}); + +Then("rien n'est proposé à l'ordinateur", () => { + assert.ok(!game.events.some((e) => e.type === "CubeOffered")); + assert.equal(game.state.phase, "rolling"); + assert.equal(game.state.turn, "player"); + assert.deepEqual(game.state.cube, { value: 1, owner: null }); +}); + +When("vous confirmez", async () => { + await game.double(); +}); + +Then("l'ordinateur prend ou passe, comme le recommande le moteur", async () => { + const decision = await engine.cubeDecision( + START_POSITION, + { value: 1, owner: "centered" }, + STANDARD_PLY, + ); + const offered = game.events.findIndex((e) => e.type === "CubeOffered"); + const answer = game.events[offered + 1]; + assert.ok(offered >= 0 && answer !== undefined); + if (decision.take) { + assert.equal(answer.type, "CubeTaken"); + assert.deepEqual(game.state.cube, { value: 2, owner: "computer" }); + } else { + assert.equal(answer.type, "CubeDropped"); + assert.deepEqual(game.state.result, { winner: "player", kind: "single", points: 1 }); + } +}); + Given("l'ordinateur vous propose de doubler", async () => { game = Game.start(engine, { setup: { position: COMPUTER_DOUBLES, firstTurn: "computer" } }); await game.playComputerTurn(); @@ -346,7 +451,7 @@ Given("un joueur a sorti tous ses pions", async () => { dice: fixedDice([6, 5]), }); await game.roll(); - assert.equal(await play("6/off 5/off"), true); + assert.equal(await playAndConfirm("6/off 5/off"), true); }); Then("le résultat indique le gagnant et les points gagnés", () => { diff --git a/app/tests/ui.test.ts b/app/tests/ui.test.ts index 55a4a2f..4ba786d 100644 --- a/app/tests/ui.test.ts +++ b/app/tests/ui.test.ts @@ -12,7 +12,14 @@ import type { BoardSide } from "../src/domain/game/engine.js"; import { appendEvent, EMPTY_JOURNAL } from "../src/domain/game/events.js"; import { parseHalfMoves, START_POSITION } from "../src/domain/game/position.js"; import { createGameStore } from "../src/storage/game-store.js"; -import { boardModel, moveTargets, moveTrace, placeOf, pointSlot } from "../src/ui/board-model.js"; +import { + boardModel, + moveTargets, + moveTrace, + onlyHalfMove, + placeOf, + pointSlot, +} from "../src/ui/board-model.js"; import { parseFeature, parseGlossaryEntry, parseGlossaryIndex } from "../src/ui/help-text.js"; import { TEXTS } from "../src/ui/texts.js"; @@ -65,6 +72,11 @@ test("les demi-coups possibles donnent les pions jouables et leurs cases d'arriv assert.deepEqual([...targets.keys()], ["p8", "bar-player", "p3"]); assert.deepEqual([...(targets.get("p8")?.keys() ?? [])], ["p5", "p7"]); assert.deepEqual(targets.get("p3")?.get("off-player"), { from: 3, to: 0 }); + // Un seul toucher suffit pour un pion à destination unique. + assert.deepEqual(onlyHalfMove(targets, "p3"), { from: 3, to: 0 }); + assert.deepEqual(onlyHalfMove(targets, "bar-player"), { from: 25, to: 22 }); + assert.equal(onlyHalfMove(targets, "p8"), null); + assert.equal(onlyHalfMove(targets, "p13"), null); }); test("traces d'un coup simple : une flèche par demi-coup, départs et arrivées", () => { diff --git a/docs/scenarios/jouer-une-partie.feature b/docs/scenarios/jouer-une-partie.feature index 5e5e2d1..55d8b57 100644 --- a/docs/scenarios/jouer-une-partie.feature +++ b/docs/scenarios/jouer-une-partie.feature @@ -11,11 +11,40 @@ Fonctionnalité: Jouer une partie contre l'ordinateur Et le premier jet de dés désigne qui commence Scénario: Jouer un coup permis + Touchez un pion, puis sa case d'arrivée ; s'il n'en a qu'une, il y va dès + que vous le touchez. Chaque dé joué s'estompe. Une fois tous vos dés + joués, votre coup attend que vous le validiez : touchez vos dés, d'un côté + ou de l'autre du plateau, ou « Valider ». + Étant donné que c'est à vous de jouer avec le jet 3-1 Quand vous jouez 8/5 6/5 + Alors votre coup attend votre validation + Et vos dés sont tous marqués comme joués + Quand vous validez votre coup Alors votre coup est accepté Et c'est ensuite au tour de l'ordinateur + Scénario: Un pion qui n'a qu'une case d'arrivée + Après 8/5, il ne vous reste que le 1 : chaque pion jouable n'a plus + qu'une case d'arrivée, et il y va dès que vous le touchez. + + Étant donné que c'est à vous de jouer avec le jet 3-1 + Quand vous jouez 8/5 + Alors seul votre 3 est marqué comme joué + Et chacun de vos pions jouables n'a plus qu'une case d'arrivée + + Scénario: Reprendre un pion avant de valider + Tant que vous n'avez pas validé votre coup, « Annuler » reprend le + dernier pion déplacé ; touchez-le encore pour reprendre le précédent. + + Étant donné que c'est à vous de jouer avec le jet 3-1 + Quand vous jouez 8/5 6/5 + Et que vous annulez le dernier demi-coup + Alors il ne reste de joué que 8/5 + Quand vous annulez le dernier demi-coup + Alors la position n'a pas changé + Et aucun de vos dés n'est marqué comme joué + Scénario: Un coup interdit est refusé La case 12 est tenue par l'ordinateur, qui y a cinq pions : vous ne pouvez pas vous y poser. @@ -54,6 +83,19 @@ Fonctionnalité: Jouer une partie contre l'ordinateur Et votre pion frappé part sur la barre Et vous pouvez revoir son coup + Scénario: Doubler, après confirmation + Avant de lancer les dés, vous pouvez doubler en touchant le videau ou + « Doubler ». L'application vous demande de confirmer : une fois proposé, + un double ne se reprend pas. + + Étant donné que vous pouvez doubler + Quand vous demandez à doubler + Mais que vous changez d'avis + Alors rien n'est proposé à l'ordinateur + Quand vous demandez à doubler + Et que vous confirmez + Alors l'ordinateur prend ou passe, comme le recommande le moteur + Scénario: Prendre le double de l'ordinateur Étant donné que l'ordinateur vous propose de doubler Quand vous prenez diff --git a/tests/browser/play.mjs b/tests/browser/play.mjs index 4ef20f5..19e99e2 100644 --- a/tests/browser/play.mjs +++ b/tests/browser/play.mjs @@ -1,6 +1,7 @@ // play.mjs -- joue une partie complète par l'interface, dans un vrai // navigateur sans écran (Playwright), sur le site servi par l'image de -// production : dés, coups au toucher, coups de l'ordinateur, videau, reprise +// production : dés, coups au toucher (validés, annulés), coups de +// l'ordinateur, videau (avec sa confirmation), reprise // après rechargement, aide, puis hors ligne (service worker). // // Copyright (C) 2026 les auteurs d'Ustagammon. @@ -101,7 +102,7 @@ async function waitForPlayer() { const b = document.getElementById(id); return !b.hidden && !b.disabled; }; - for (const id of ["again", "take", "skip", "roll"]) { + for (const id of ["again", "take", "skip", "confirm", "roll"]) { if (usable(id)) { return id; } @@ -161,7 +162,8 @@ async function checkTraces() { // 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, + check(/^L'ordinateur ne peut pas jouer \d-\d\.$/.test(info ?? "") && blocked >= 2 && + blocked === (await shownDice()).dice, `jet de l'ordinateur sans coup mal montré : « ${info} », ${blocked} dés barrés`); log(`l'ordinateur ne peut pas jouer : « ${info} »`); } @@ -175,18 +177,35 @@ async function checkTraces() { return traces; } -/** La zone à toucher d'une action du plateau (dés ou videau) : un bouton - * accessible, d'au moins 44 px de côté. Renvoie son sélecteur. */ -async function touchTarget(action, label) { - const selector = `[data-action="${action}"]`; - const zone = page.locator(selector); - check((await zone.count()) === 1, `zone ${action} absente du plateau`); - check((await zone.getAttribute("role")) === "button" && - (await zone.getAttribute("aria-label")) === label, `zone ${action} sans rôle accessible`); - const box = await zone.boundingBox(); - check(box.width >= 44 && box.height >= 44, - `zone ${action} trop petite : ${Math.round(box.width)} x ${Math.round(box.height)} px`); - return selector; +/** Une zone à toucher d'une action du plateau (dés ou videau) : count zones + * (les dés en ont une de chaque côté du plateau), chacune un bouton + * accessible d'au moins 44 px de côté. Renvoie le locator de la zone index. */ +async function touchTarget(action, label, count = 1, index = 0) { + const zones = page.locator(`[data-action="${action}"]`); + check((await zones.count()) === count, + `zone ${action} : ${await zones.count()} sur le plateau, ${count} attendues`); + for (const zone of await zones.all()) { + check((await zone.getAttribute("role")) === "button" && + (await zone.getAttribute("aria-label")) === label, `zone ${action} sans rôle accessible`); + const box = await zone.boundingBox(); + check(box.width >= 44 && box.height >= 44, + `zone ${action} trop petite : ${Math.round(box.width)} x ${Math.round(box.height)} px`); + } + return zones.nth(index); +} + +/** Le nombre de dés montrés, et de dés estompés (déjà joués). */ +async function shownDice() { + return { + dice: await page.locator("g.die[data-value]").count(), + used: await page.locator("g.die-used").count(), + }; +} + +/** Le dernier jet du joueur, d'après le journal. */ +function lastPlayerRoll(journal) { + const rolled = journal.events.filter((e) => e.type === "DiceRolled").at(-1); + return rolled?.roll ?? []; } /** Observe la page pendant ms millisecondes : le plus grand nombre @@ -256,13 +275,24 @@ let tracesSeen = 0; let replays = 0; let skips = 0; let rolls = 0; +// Saisie du coup : demi-coups du coup en cours, pions avancés d'un seul +// toucher ou en deux (pion, puis case), coups validés et annulations. +let halfMoves = 0; +let singleTouch = 0; +let twoTouches = 0; +let confirms = 0; +let undone = 0; +let doubles = 0; +// Vrai dès que le joueur a touché un pion ou « Annuler » pendant son coup : +// le coup de l'ordinateur n'est plus montré. +let touched = false; for (let actions = 0; ; actions++) { - check(actions < 800, "la partie ne finit pas"); + check(actions < 1500, "la partie ne finit pas"); const todo = await waitForPlayer(); step(`partie, action ${actions}`); // Le coup de l'ordinateur est montré jusqu'à l'action suivante du // joueur ; deux fois, on le revoit (la seconde en mouvement réduit). - const traces = await checkTraces(); + const traces = touched ? null : await checkTraces(); if (traces !== null) { tracesSeen++; if (replays < 2) { @@ -292,7 +322,8 @@ for (let actions = 0; ; actions++) { // 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, + check(/^Aucun coup possible avec \d-\d\.$/.test(info ?? "") && blocked >= 2 && + blocked === (await shownDice()).dice, `aucun coup possible mal montré : « ${info} », ${blocked} dés barrés`); skips++; log(`aucun coup possible : « ${info} », vous continuez`); @@ -304,30 +335,115 @@ for (let actions = 0; ; actions++) { } else if (todo === "roll") { if (!doubled && (await page.isEnabled("#double"))) { doubled = true; - log("vous doublez en touchant le videau"); - await page.click(await touchTarget("double", "Doubler")); + // Toucher le videau demande confirmation ; « Annuler » ne + // propose rien, puis « Doubler » (le bouton) et confirmer. + const dialog = page.locator("#double-dialog"); + const question = "Doubler à 2 ? L'ordinateur pourra prendre ou passer."; + await (await touchTarget("double", "Doubler")).click(); + check(await dialog.isVisible(), "videau touché : pas de confirmation"); + check((await page.textContent("#double-question")) === question, + `confirmation du videau : « ${await page.textContent("#double-question")} »`); + await shot("videau-confirmation"); + await page.click("#double-cancel"); + check(!(await dialog.isVisible()), "Annuler ne ferme pas la confirmation"); + const offered = (await savedJournal()).events.some((e) => e.type === "CubeOffered"); + check(!offered && (await page.isVisible("#roll")), "double proposé malgré Annuler"); + await page.click("#double"); + check(await dialog.isVisible(), "« Doubler » : pas de confirmation"); + await page.click("#double-confirm"); await waitForPlayer(); const answer = (await page.textContent("#info")) || (await page.textContent("#prompt")); - log(`réponse : ${answer}`); + log(`videau touché : confirmation demandée, annulée, puis double confirmé ; réponse : ${answer}`); continue; } - // Une fois sur deux, en touchant les dés en attente plutôt que le - // bouton. + // Une fois sur deux, en touchant les dés plutôt que le bouton, d'un + // côté puis de l'autre du plateau. rolls++; - await page.click(rolls % 2 === 0 ? await touchTarget("roll", "Lancer les dés") : "#roll"); + if (rolls % 2 === 0) { + await (await touchTarget("roll", "Lancer les dés", 2, (rolls / 2) % 2)).click(); + } else { + await page.click("#roll"); + } + } else if (todo === "confirm") { + // Coup complet : rien n'est joué tant qu'il n'est pas validé. + const journal = await savedJournal(); + const last = journal.events.at(-1); + check(last?.type === "DiceRolled" && (await page.isEnabled("#cancel")), + `coup complet déjà joué ou non annulable (dernier événement ${last?.type})`); + check(/Valider/.test((await page.textContent("#prompt")) ?? ""), + `consigne de validation absente : « ${await page.textContent("#prompt")} »`); + const dice = await shownDice(); + check(dice.used === halfMoves && dice.used > 0, + `dés estompés : ${dice.used} pour ${halfMoves} demi-coups`); + if (undone === 0 && halfMoves >= 2) { + // Une fois : « Annuler » défait les demi-coups un par un. + if (confirms === 0) { + await shot("2b-coup-a-valider"); + } + for (let left = halfMoves - 1; left >= 0; left--) { + await page.click("#cancel"); + await waitForPlayer(); + const after = await shownDice(); + check(after.used === left && (await page.isHidden("#confirm")), + `Annuler : ${after.used} dés estompés, ${left} attendus`); + undone++; + } + check(await page.isDisabled("#cancel"), "Annuler reste actif sans demi-coup à défaire"); + check((await savedJournal()).head === journal.head, "Annuler a touché au journal"); + log(`Annuler : ${undone} demi-coups défaits un par un, journal intact`); + halfMoves = 0; + continue; + } + confirms++; + // Une fois sur trois avec le bouton, sinon en touchant les dés + // (d'un côté ou de l'autre). + if (confirms % 3 === 0) { + await page.click("#confirm"); + } else { + await (await touchTarget("confirm", "Valider", 2, confirms % 2)).click(); + } + await waitForPlayer(); + const played = await savedJournal(); + check(countMoves(played, "player") === countMoves(journal, "player") + 1, + "le coup validé n'est pas dans le journal"); + halfMoves = 0; + touched = false; } else { check((await page.locator("[data-action]").count()) === 0, "dés ou videau touchables pendant que vous jouez votre coup"); - await page.click(".hit.can-move"); - await page.waitForSelector(".hit.can-land"); - if (!selectionShot) { - selectionShot = true; - await shot("2-pion-touche"); + if (halfMoves === 0) { + // Quatre dés pour un double, deux sinon. + const [a, b] = lastPlayerRoll(await savedJournal()); + const dice = await shownDice(); + check(dice.dice === (a === b ? 4 : 2) && dice.used === 0, + `jet ${a}-${b} : ${dice.dice} dés montrés, ${dice.used} estompés`); + if (a === b) { + doubles++; + } } - await page.click(".hit.can-land"); + touched = true; + // Un pion à destination unique avance dès qu'on le touche ; sinon, + // ses cases d'arrivée s'allument. + await page.click(".hit.can-move"); + if ((await page.locator(".hit.can-land").count()) > 0) { + twoTouches++; + if (!selectionShot) { + selectionShot = true; + await shot("2-pion-touche"); + } + await page.click(".hit.can-land"); + } else { + singleTouch++; + } + halfMoves++; + await waitForPlayer(); + const dice = await shownDice(); + check(dice.used === halfMoves, `${dice.used} dés estompés après ${halfMoves} demi-coups`); + continue; } - // Une fois, au milieu de la partie : fermer et rouvrir la page. + // Une fois, au milieu de la partie, entre deux coups : fermer et rouvrir + // la page. const journal = await savedJournal(); if (!reloaded && countMoves(journal, "computer") >= 3 && countMoves(journal, "player") >= 3) { reloaded = true; @@ -355,7 +471,14 @@ 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"); check(doubled && rolls >= 2, "dés ou videau jamais touchés sur le plateau"); -log(`dés lancés ${Math.floor(rolls / 2)} fois en les touchant, videau touché pour doubler`); +check(singleTouch > 0 && twoTouches > 0, + `saisie : ${singleTouch} pions avancés d'un toucher, ${twoTouches} en deux touchers`); +check(undone >= 2, "Annuler n'a pas été essayé sur un coup complet"); +check(confirms >= 3, "trop peu de coups validés pour essayer dés et bouton"); +log(`dés lancés ${Math.floor(rolls / 2)} fois en les touchant, des deux côtés du plateau`); +log(`demi-coups : ${singleTouch} d'un seul toucher (destination unique), ${twoTouches} en deux ` + + `touchers ; ${confirms} coups validés (${Math.floor(confirms / 3)} par le bouton, les autres ` + + `en touchant les dés) ; ${doubles} doubles montrés en quatre dés`); 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) { diff --git a/web/app.css b/web/app.css index 372535e..d92fac2 100644 --- a/web/app.css +++ b/web/app.css @@ -499,6 +499,10 @@ body.busy .prompt { stroke-linecap: round; } +/* Dé déjà utilisé par un demi-coup du coup en cours : estompé, sans être + * barré (il n'est pas perdu : « Annuler » le rend). */ +.die-used { opacity: 0.35; } + .cube-face { fill: var(--cube); stroke: var(--checker-player-edge); stroke-width: 2; } .cube-value { @@ -513,6 +517,29 @@ body.busy .prompt { cursor: pointer; } +/* ------------------------------------------------ Confirmation (videau) */ + +/* Une carte au milieu de l'écran, comme le glossaire ; le fond de la page + * s'assombrit derrière. */ +.confirm { + width: min(22rem, calc(100vw - 2 * var(--space-4))); + padding: var(--space-4); + color: var(--text); + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius-md); + box-shadow: var(--shadow-raised); +} + +.confirm::backdrop { + background: rgba(43, 29, 21, 0.5); +} + +.confirm p { + margin: 0 0 var(--space-4); + font-weight: 700; +} + /* --------------------------------------------------------------- Aide */ .help { @@ -582,12 +609,6 @@ body.busy .prompt { color: var(--muted); } -/* Une cible tactile confortable pour le lien. */ -.about a { - display: inline-block; - padding-block: 12px; -} - /* ------------------------------------------------------- Mode paysage */ /* Téléphone tourné : le plateau prend toute la hauteur de l'écran, à diff --git a/web/index.html b/web/index.html index 6b3d0aa..2df0e12 100644 --- a/web/index.html +++ b/web/index.html @@ -43,11 +43,20 @@ + + + +

+
+ + +
+