Saisie du coup : un toucher, validation, Annuler, dés joués, videau confirmé #28

Manually merged
pierre merged 9 commits from saisie-du-coup into main 2026-11-03 09:17:38 +00:00
11 changed files with 510 additions and 80 deletions
Showing only changes of commit 2d934529d5 - Show all commits
+111 -22
View File
@@ -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<HTMLButtonElement>("double"),
take: byId<HTMLButtonElement>("take"),
drop: byId<HTMLButtonElement>("drop"),
confirm: byId<HTMLButtonElement>("confirm"),
cancel: byId<HTMLButtonElement>("cancel"),
skip: byId<HTMLButtonElement>("skip"),
replay: byId<HTMLButtonElement>("replay"),
@@ -61,12 +76,19 @@ export function startApp(options: AppOptions): void {
game: byId("game"),
help: byId("help"),
helpContent: byId("help-content"),
doubleDialog: byId<HTMLDialogElement>("double-dialog"),
doubleQuestion: byId("double-question"),
doubleConfirm: byId<HTMLButtonElement>("double-confirm"),
doubleCancel: byId<HTMLButtonElement>("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);
}
+13
View File
@@ -82,6 +82,19 @@ export function moveTargets(halfMoves: readonly HalfMove[]): Map<Place, Map<Plac
return targets;
}
/** Le seul demi-coup possible depuis place, s'il n'a qu'une case d'arrivée
* (le pion y va dès qu'on le touche) ; null sinon. */
export function onlyHalfMove(
targets: ReadonlyMap<Place, ReadonlyMap<Place, HalfMove>>,
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é. */
+28 -13
View File
@@ -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<Place>;
/** 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<Place>(),
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,
+1 -3
View File
@@ -91,10 +91,8 @@ export async function showHelp(container: HTMLElement): Promise<void> {
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;
+4 -1
View File
@@ -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;
+107 -2
View File
@@ -80,6 +80,15 @@ async function play(text: string): Promise<boolean> {
return true;
}
/** Joue les demi-coups puis valide le coup, complet. */
async function playAndConfirm(text: string): Promise<boolean> {
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", () => {
+13 -1
View File
@@ -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", () => {
+42
View File
@@ -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
+155 -32
View File
@@ -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) {
+27 -6
View File
@@ -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, à
+9
View File
@@ -43,11 +43,20 @@
<button id="double" type="button" class="secondary" hidden></button>
<button id="take" type="button" hidden></button>
<button id="drop" type="button" class="secondary" hidden></button>
<button id="confirm" type="button" hidden></button>
<button id="cancel" type="button" class="secondary" hidden></button>
<button id="skip" type="button" hidden></button>
<button id="replay" type="button" class="secondary" hidden></button>
<button id="again" type="button" hidden></button>
</div>
<!-- Confirmation avant de doubler (ouverte par app/ui/app.js). -->
<dialog id="double-dialog" class="confirm" aria-labelledby="double-question">
<p id="double-question"></p>
<div class="actions">
<button id="double-confirm" type="button"></button>
<button id="double-cancel" type="button" class="secondary"></button>
</div>
</dialog>
<noscript><p>Ustagammon a besoin de JavaScript.</p></noscript>
</main>