Saisie du coup : pion à destination unique, validation, Annuler, dés estompés, videau confirmé

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Pierre MARTIN
2026-11-03 07:04:26 +01:00
co-authored by Claude Opus 5.5
parent 2c5278965b
commit 2d934529d5
11 changed files with 510 additions and 80 deletions
+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", () => {