Coup de l'ordinateur montré, aucun coup possible, dés et videau touchables #19

Merged
pierre merged 8 commits from coup-adverse into main 2026-11-03 04:06:50 +00:00
14 changed files with 1185 additions and 134 deletions
+84 -14
View File
@@ -47,6 +47,20 @@ export interface SideBoards {
readonly computer: BoardSide;
}
/**
* Un coup joué, demi-coup par demi-coup, pour le montrer : les plateaux
* successifs, tels que le moteur les donne.
*/
export interface MoveSteps {
readonly side: Side;
readonly halfMoves: readonly HalfMove[];
/** boards[0] : avant le coup ; boards[i + 1] : après le demi-coup i. */
readonly boards: readonly SideBoards[];
/** hits[i] : le demi-coup i a frappé un pion adverse, envoyé sur la barre
* (lu sur les plateaux du moteur, pas déduit des règles). */
readonly hits: readonly boolean[];
}
export interface GameOptions {
/** Source des dés ; le hasard ordinaire par défaut. */
readonly dice?: DiceSource;
@@ -141,14 +155,45 @@ export class Game {
const state = this.state;
// La position est vue du camp qui a la main (le joueur pendant le jet
// d'ouverture : la position de départ est la même pour les deux).
const onRoll = state.turn ?? "player";
const board = await this.#engine.board(
this.displayedPosition,
engineCube(state.cube, onRoll),
return this.#sideBoards(this.displayedPosition, state.turn ?? "player", state.cube);
}
/**
* Le dernier coup joué de la partie, demi-coup par demi-coup (null s'il
* n'y en a pas). Les plateaux intermédiaires viennent du moteur
* (checkHalfMoves, puis board) : aucune règle n'est rejouée ici.
*/
async lastMoveSteps(): Promise<MoveSteps | null> {
const events = this.events;
const index = events.findLastIndex((e) => e.type === "MovePlayed");
const played = events[index];
if (played?.type !== "MovePlayed") {
return null;
}
// L'état juste avant le coup : la position vue de celui qui joue, et
// son jet.
const before = replay(events.slice(0, index));
const roll = rollToPlay(before);
const positions = [before.position];
for (let i = 1; i <= played.halfMoves.length; i++) {
const check = await this.#engine.checkHalfMoves(
before.position,
roll,
played.halfMoves.slice(0, i),
);
if (check.position === null) {
throw new Error("coup enregistré refusé par le moteur");
}
positions.push(check.position);
}
const boards = await Promise.all(
positions.map((p) => this.#sideBoards(p, played.side, before.cube)),
);
return onRoll === "player"
? { player: board.onRoll, computer: board.opponent }
: { player: board.opponent, computer: board.onRoll };
const opponent = otherSide(played.side);
const hits = played.halfMoves.map(
(_, i) => (boards[i + 1]?.[opponent].bar ?? 0) > (boards[i]?.[opponent].bar ?? 0),
);
return { side: played.side, halfMoves: played.halfMoves, boards, hits };
}
/**
@@ -199,15 +244,32 @@ export class Game {
// ------------------------------------------------------------- Joueur
/** Le joueur lance les dés. S'il ne peut pas jouer, son tour passe. */
/** Le joueur lance les dés. S'il ne peut pas jouer (cannotMove), son
* tour ne passe qu'une fois qu'il l'a constaté (skipTurn). */
async roll(): Promise<void> {
const state = this.#expect("player", "rolling");
const roll = rollDice(this.#dice);
this.#append({ type: "DiceRolled", side: "player", roll });
const moves = await this.#engine.legalMoves(state.position, roll);
if (moves.length === 0) {
await this.#playMove("player", [], null);
this.#expect("player", "rolling");
this.#append({ type: "DiceRolled", side: "player", roll: rollDice(this.#dice) });
}
/** Le joueur a lancé les dés, mais le moteur ne lui donne aucun coup
* légal : il ne peut que laisser passer son tour. */
async cannotMove(): Promise<boolean> {
const state = this.state;
if (state.turn !== "player" || state.phase !== "moving") {
return false;
}
const moves = await this.#engine.legalMoves(state.position, rollToPlay(state));
return moves.length === 0;
}
/** Le joueur, qui ne peut pas jouer son jet, laisse passer son tour.
* Erreur s'il a un coup possible. */
async skipTurn(): Promise<void> {
this.#expect("player", "moving");
if (!(await this.cannotMove())) {
throw new Error("vous avez un coup possible : jouez-le");
}
await this.#playMove("player", [], null);
}
/**
@@ -325,6 +387,14 @@ export class Game {
this.#journal = appendEvent(this.#journal, event);
}
/** Le plateau d'une position vue de onRoll, décodé par le moteur. */
async #sideBoards(position: PositionId, onRoll: Side, cube: Cube): Promise<SideBoards> {
const board = await this.#engine.board(position, engineCube(cube, onRoll));
return onRoll === "player"
? { player: board.onRoll, computer: board.opponent }
: { player: board.opponent, computer: board.onRoll };
}
/** Vérifie que c'est bien à side d'agir, dans l'une des phases. */
#expect(side: Side, ...phases: GameState["phase"][]): GameState {
const state = this.state;
+198 -45
View File
@@ -10,14 +10,17 @@
// son tour ; le plateau est ensuite redessiné à partir de l'état de la partie.
import type { GameJournal } from "../domain/game/events.js";
import type { Game, SideBoards } from "../domain/game/game.js";
import type { Game, MoveSteps, SideBoards } from "../domain/game/game.js";
import { formatHalfMoves, type HalfMove } from "../domain/game/position.js";
import type { Side } from "../domain/game/side.js";
import { boardModel, moveTargets, type Place, placeOf } from "./board-model.js";
import { renderBoard, type ShownDie } from "./board-view.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 { showHelp } from "./help.js";
import { TEXTS } from "./texts.js";
/** Durée du glissement d'un pion, pour chaque demi-coup de l'ordinateur. */
const SLIDE_MS = 400;
export interface AppOptions {
/** Résolue quand le moteur est chargé, rejetée s'il ne peut pas l'être. */
readonly engineReady: Promise<unknown>;
@@ -49,6 +52,8 @@ export function startApp(options: AppOptions): void {
take: byId<HTMLButtonElement>("take"),
drop: byId<HTMLButtonElement>("drop"),
cancel: byId<HTMLButtonElement>("cancel"),
skip: byId<HTMLButtonElement>("skip"),
replay: byId<HTMLButtonElement>("replay"),
again: byId<HTMLButtonElement>("again"),
newGame: byId<HTMLButtonElement>("new-game"),
helpButton: byId<HTMLButtonElement>("help-button"),
@@ -63,6 +68,9 @@ export function startApp(options: AppOptions): void {
ui.take.textContent = TEXTS.take;
ui.drop.textContent = TEXTS.drop;
ui.cancel.textContent = TEXTS.cancel;
ui.skip.textContent = TEXTS.skip;
ui.replay.textContent = TEXTS.replay;
ui.replay.title = TEXTS.replayTitle;
ui.again.textContent = TEXTS.newGame;
ui.newGame.textContent = TEXTS.newGame;
ui.helpButton.textContent = TEXTS.help;
@@ -74,22 +82,35 @@ export function startApp(options: AppOptions): void {
let targets = new Map<Place, Map<Place, HalfMove>>();
let selected: Place | null = null;
let canDouble = false;
// Le joueur a lancé les dés mais ne peut pas jouer : il doit continuer.
let stuck = false;
let busy = true;
let info = "";
// Le dernier coup de l'ordinateur, montré (traces, bouton « Revoir »)
// jusqu'à la prochaine action du joueur ; null : rien à montrer.
let shownMove: MoveSteps | null = null;
// Pendant l'animation d'un coup, render() laisse le plateau à l'animation.
let animating = false;
// ------------------------------------------------------------ Dessin
/** Recalcule ce qui demande le moteur, puis redessine. */
async function refresh(): Promise<void> {
/** Recalcule ce qui demande le moteur, sans redessiner. */
async function update(): Promise<void> {
if (game === null) {
return;
}
sides = await game.displayedBoard();
targets = moveTargets(await game.nextHalfMoves());
canDouble = await game.canDouble();
stuck = await game.cannotMove();
if (selected !== null && !targets.has(selected)) {
selected = null;
}
}
/** Recalcule ce qui demande le moteur, puis redessine. */
async function refresh(): Promise<void> {
await update();
render();
}
@@ -99,30 +120,59 @@ export function startApp(options: AppOptions): void {
}
const state = game.state;
const model = boardModel(sides);
renderBoard(ui.board, model, {
dice: shownDice(game),
cube: state.cube,
movable: new Set(targets.keys()),
selected,
targets: new Set(selected === null ? [] : (targets.get(selected)?.keys() ?? [])),
lastMove: lastComputerMove(game),
});
if (!animating) {
renderBoard(ui.board, model, {
dice: shownDice(game, stuck),
cube: state.cube,
movable: new Set(targets.keys()),
selected,
targets: new Set(selected === null ? [] : (targets.get(selected)?.keys() ?? [])),
trace:
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",
canDouble: !busy && canDouble,
});
}
ui.pips.textContent = TEXTS.pipCounts(model.pipCount.player, model.pipCount.computer);
ui.info.textContent = info;
ui.prompt.textContent = prompt(game, selected !== null, canDouble);
// Un jet sans coup possible reste annoncé jusqu'à l'action suivante,
// même après un rechargement de la page.
if (stuck && state.roll !== null) {
ui.info.textContent = TEXTS.youCannotMove(state.roll);
} else if (computerCouldNotMove(game)) {
ui.info.textContent = TEXTS.computerCannotMove(rollBefore(game));
} else {
ui.info.textContent = info;
}
ui.prompt.textContent = stuck
? TEXTS.pressContinue
: prompt(game, selected !== null, canDouble);
ui.prompt.classList.toggle("thinking", state.turn === "computer" && isPlaying(game));
const player = state.turn === "player";
show(ui.roll, player && state.phase === "rolling");
show(ui.double, player && state.phase === "rolling");
ui.double.disabled = busy || !canDouble;
show(ui.cancel, player && state.phase === "moving");
show(ui.cancel, player && state.phase === "moving" && !stuck);
show(ui.skip, stuck);
ui.cancel.disabled = busy || game.pendingHalfMoves.length === 0;
const doubled = state.turn === "computer" && state.phase === "doubled";
show(ui.take, doubled);
show(ui.drop, doubled);
show(ui.again, state.phase === "over");
for (const button of [ui.roll, ui.take, ui.drop, ui.again, ui.newGame]) {
show(ui.replay, shownMove !== null);
for (const button of [
ui.roll,
ui.take,
ui.drop,
ui.skip,
ui.replay,
ui.again,
ui.newGame,
]) {
button.disabled = busy;
}
// Pour le style et pour les tests dans un navigateur : l'écran
@@ -142,14 +192,73 @@ export function startApp(options: AppOptions): void {
}
}
/** L'ordinateur joue tant que c'est son tour (il s'arrête s'il double). */
async function computerTurns(): Promise<void> {
/** L'ordinateur joue tant que c'est son tour (il s'arrête s'il double).
* Son dernier coup, s'il est la dernière chose jouée, devient le coup à
* montrer. Renvoie vrai si l'ordinateur a joué. */
async function computerTurns(): Promise<boolean> {
let played = false;
while (game !== null && game.state.turn === "computer" && isPlaying(game)) {
await refresh();
const start = performance.now();
await game.playComputerTurn();
info = describeComputerTurn(game, performance.now() - start);
save();
played = true;
}
if (game !== null) {
shownMove = await computerMoveToShow(game);
}
return played;
}
/**
* Fait glisser les pions du coup de l'ordinateur, un demi-coup après
* l'autre, à partir des plateaux intermédiaires donnés par le domaine. Rien
* si le système demande moins d'animations : les traces suffisent.
*/
async function animateMove(steps: MoveSteps): Promise<void> {
if (game === null || window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
return;
}
const decor = { dice: shownDice(game, false), cube: game.state.cube, ...NO_MARKS };
const draw = (i: number) => {
const boards = steps.boards[i];
if (boards !== undefined) {
renderBoard(ui.board, boardModel(boards), decor);
}
};
animating = true;
render(); // messages et boutons ; le plateau est à l'animation
try {
for (const [i, halfMove] of steps.halfMoves.entries()) {
const from = placeOf(steps.side, halfMove.from);
const to = placeOf(steps.side, halfMove.to);
draw(i);
const start = checkerSpot(ui.board, from);
const victim = steps.hits[i] === true ? checkerSpot(ui.board, to) : null;
draw(i + 1);
await Promise.all([
start && slideChecker(ui.board, to, start, SLIDE_MS),
victim &&
slideChecker(ui.board, `bar-${otherSide(steps.side)}`, victim, SLIDE_MS),
]);
}
} finally {
animating = false;
}
}
/** Rend la main au joueur : recalcule, anime le coup à montrer (si
* animate), puis redessine avec ses traces. */
async function settle(animate: boolean): Promise<void> {
try {
await update();
if (animate && shownMove !== null) {
await animateMove(shownMove);
}
} finally {
busy = false;
render();
}
}
@@ -162,6 +271,7 @@ export function startApp(options: AppOptions): void {
busy = true;
selected = null;
targets = new Map();
shownMove = null;
render();
try {
await action(game);
@@ -170,8 +280,7 @@ export function startApp(options: AppOptions): void {
} catch (error) {
info = TEXTS.actionFailed(error instanceof Error ? error.message : String(error));
} finally {
busy = false;
await refresh();
await settle(true);
}
}
@@ -186,10 +295,13 @@ export function startApp(options: AppOptions): void {
act(async (g) => {
info = "";
await g.roll();
const last = g.events.at(-1);
if (last?.type === "MovePlayed" && last.halfMoves.length === 0) {
info = TEXTS.youCannotMove(rollBefore(g));
}
});
// Aucun coup possible : le joueur laisse passer son tour.
ui.skip.onclick = () =>
act(async (g) => {
info = "";
await g.skipTurn();
});
ui.double.onclick = () =>
@@ -214,6 +326,7 @@ export function startApp(options: AppOptions): void {
if (!busy && game !== null) {
game.cancelHalfMoves();
selected = null;
shownMove = null;
void refresh();
}
};
@@ -234,6 +347,16 @@ export function startApp(options: AppOptions): void {
// Toucher le plateau : un pion jouable, puis une de ses cases d'arrivée.
ui.board.addEventListener("click", (event) => {
const target = event.target as Element | null;
// Toucher les dés en attente 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 === "double" && !ui.double.hidden && !ui.double.disabled) {
ui.double.click();
return;
}
const place = target?.closest("[data-place]")?.getAttribute("data-place") as Place | null;
if (busy || place == null) {
return;
@@ -247,9 +370,22 @@ export function startApp(options: AppOptions): void {
return;
}
selected = targets.has(place) && place !== selected ? place : null;
if (selected !== null) {
shownMove = null; // toucher un pion : le coup de l'ordinateur s'efface
}
render();
});
// Revoir le dernier coup de l'ordinateur, puis ses traces.
ui.replay.onclick = async () => {
if (busy || shownMove === null) {
return;
}
busy = true;
selected = null;
await settle(true);
};
ui.helpButton.onclick = () => {
ui.game.hidden = true;
ui.help.hidden = false;
@@ -264,7 +400,16 @@ export function startApp(options: AppOptions): void {
// ---------------------------------------------------------- Démarrage
ui.prompt.textContent = TEXTS.loading;
for (const button of [ui.roll, ui.double, ui.take, ui.drop, ui.cancel, ui.again]) {
for (const button of [
ui.roll,
ui.double,
ui.take,
ui.drop,
ui.cancel,
ui.skip,
ui.replay,
ui.again,
]) {
button.hidden = true;
}
ui.newGame.disabled = true;
@@ -276,12 +421,13 @@ export function startApp(options: AppOptions): void {
save();
}
// Comme après une action du joueur : l'ordinateur joue si c'est
// à lui (partie nouvelle ou reprise pendant son tour).
// à lui (partie nouvelle ou reprise pendant son tour). Une partie
// reprise juste après son coup le montre, sans l'animer.
let played = false;
try {
await computerTurns();
played = await computerTurns();
} finally {
busy = false;
await refresh();
await settle(played);
}
},
(error: unknown) => {
@@ -307,29 +453,36 @@ function rollBefore(game: Game): readonly number[] {
}
/** Les dés à montrer : le dernier jet, de la couleur de qui l'a lancé
* (au jet d'ouverture, un dé chacun). */
function shownDice(game: Game): ShownDie[] {
* (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. */
function shownDice(game: Game, stuck: boolean): ShownDie[] {
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" }));
return rolled.roll.map((value, i) => ({ value, side: sides[i] ?? "player", blocked }));
}
/** Les cases de départ et d'arrivée du dernier coup, s'il est de
* l'ordinateur. */
function lastComputerMove(game: Game): Set<Place> {
const played = game.events.findLast((e) => e.type === "MovePlayed");
const places = new Set<Place>();
if (played?.type === "MovePlayed" && played.side === "computer") {
for (const h of played.halfMoves) {
places.add(placeOf("computer", h.from));
places.add(placeOf("computer", h.to));
}
/** La dernière chose jouée (fin de partie mise à part) est un tour de
* l'ordinateur qui n'a pas pu jouer son jet. */
function computerCouldNotMove(game: Game): boolean {
const last = game.events.findLast((e) => e.type !== "GameEnded");
return last?.type === "MovePlayed" && last.side === "computer" && last.halfMoves.length === 0;
}
/** Le dernier coup de l'ordinateur, demi-coup par demi-coup, s'il est la
* dernière chose jouée (fin de partie mise à part) et qu'il a déplacé des
* pions ; sinon null. */
async function computerMoveToShow(game: Game): Promise<MoveSteps | null> {
const last = game.events.findLast((e) => e.type !== "GameEnded");
if (last?.type !== "MovePlayed" || last.side !== "computer" || last.halfMoves.length === 0) {
return null;
}
return places;
return game.lastMoveSteps();
}
/** Ce qu'a fait l'ordinateur pendant son tour. */
+44
View File
@@ -82,6 +82,50 @@ export function moveTargets(halfMoves: readonly HalfMove[]): Map<Place, Map<Plac
return targets;
}
/** Ce qui reste dessiné d'un coup une fois joué. */
export interface MoveTrace {
/** Le camp qui a joué. */
readonly side: Side;
/** Une flèche par demi-coup, de sa case de départ à sa case d'arrivée. */
readonly arrows: readonly { readonly from: Place; readonly to: Place }[];
/** Par endroit, le nombre de pions arrivés et restés là (entourés). */
readonly landed: ReadonlyMap<Place, number>;
/** Par endroit, le nombre de pions partis (un pion fantôme chacun). */
readonly left: ReadonlyMap<Place, number>;
/** Le nombre de pions adverses frappés, envoyés sur la barre. */
readonly hits: number;
}
/**
* Les traces d'un coup de side : ses demi-coups, et pour chacun s'il a frappé.
* Un pion qui fait étape sur une case (13/7 7/4) n'y laisse ni fantôme ni
* cercle : seuls comptent le départ et l'arrivée de chaque pion.
*/
export function moveTrace(
side: Side,
halfMoves: readonly HalfMove[],
hits: readonly boolean[],
): MoveTrace {
const balance = new Map<Place, number>();
const arrows = halfMoves.map((h) => {
const from = placeOf(side, h.from);
const to = placeOf(side, h.to);
balance.set(from, (balance.get(from) ?? 0) - 1);
balance.set(to, (balance.get(to) ?? 0) + 1);
return { from, to };
});
const landed = new Map<Place, number>();
const left = new Map<Place, number>();
for (const [place, n] of balance) {
if (n > 0) {
landed.set(place, n);
} else if (n < 0) {
left.set(place, -n);
}
}
return { side, arrows, landed, left, hits: hits.filter(Boolean).length };
}
/** Où se trouve une case sur le dessin : rangée du haut ou du bas, et
* colonne de 0 (à gauche) à 11 (à droite), sans compter la barre. */
export function pointSlot(number: number): { top: boolean; column: number } {
+300 -60
View File
@@ -11,12 +11,15 @@
import type { Cube } from "../domain/game/cube.js";
import type { Side } from "../domain/game/side.js";
import { type BoardModel, type Place, pointSlot } from "./board-model.js";
import { type BoardModel, type MoveTrace, type Place, pointSlot } from "./board-model.js";
import { TEXTS } from "./texts.js";
/** Un dé affiché, de la couleur du camp qui l'a lancé. */
export interface ShownDie {
readonly value: number;
readonly side: Side;
/** Le jet ne permet aucun coup : le dé est estompé et barré. */
readonly blocked: boolean;
}
/** Ce qui s'ajoute aux pions : dés, videau, mises en évidence. */
@@ -28,10 +31,29 @@ export interface BoardDecor {
/** Le pion touché, dont on montre les cases d'arrivée. */
readonly selected: Place | null;
readonly targets: ReadonlySet<Place>;
/** Les cases de départ et d'arrivée du dernier coup de l'ordinateur. */
readonly lastMove: 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;
/** Le joueur peut doubler : le videau se touche. */
readonly canDouble: boolean;
}
/** Ce qui ne s'ajoute pas aux pions pendant l'animation d'un coup. */
export const NO_MARKS = {
movable: new Set<Place>(),
selected: null,
targets: new Set<Place>(),
trace: null,
canRoll: false,
canDouble: false,
} as const;
/** Taille minimale d'une cible à toucher, en unités du dessin : au moins
* 44 px sur un téléphone de 360 px de large, où le plateau en fait 344
* (640 unités). */
const MIN_TARGET = 88;
// Géométrie, en unités du viewBox (le SVG s'adapte à la largeur de l'écran).
const WIDTH = 640;
const HEIGHT = 520;
@@ -73,10 +95,51 @@ function columnLeft(column: number): number {
return column < 6 ? FRAME + column * POINT_WIDTH : RIGHT_HALF + (column - 6) * POINT_WIDTH;
}
/** Centre du i-ième pion d'une pile, depuis le bord du plateau. */
function stackY(top: boolean, index: number): number {
const offset = RADIUS + 1 + index * STEP;
return top ? FRAME + offset : HEIGHT - FRAME - offset;
/** Un point du dessin. */
export interface Spot {
readonly x: number;
readonly y: number;
}
/**
* Centre du pion numéro index (0 : le premier posé) d'un endroit : sur une
* case, depuis le bord du plateau ; sur la barre, depuis le milieu (le
* joueur dans la moitié haute, où il rentre, l'ordinateur en bas) ; dans la
* sortie, une tranche de pion vu de profil (l'ordinateur en haut).
*/
function slotCenter(place: Place, index: number): Spot {
if (place === "bar-player" || place === "bar-computer") {
const offset = RADIUS + 4 + Math.min(index, MAX_STACK) * STEP;
return {
x: BAR_LEFT + BAR_WIDTH / 2,
y: place === "bar-player" ? MIDDLE - offset : MIDDLE + offset,
};
}
if (place === "off-player" || place === "off-computer") {
const y =
place === "off-computer"
? FRAME + index * OFF_SLAB
: HEIGHT - FRAME - (index + 1) * OFF_SLAB;
return { x: TRAY_LEFT + TRAY_WIDTH / 2, y: y + OFF_SLAB / 2 };
}
const { top, column } = pointSlot(Number(place.slice(1)));
const offset = RADIUS + 1 + Math.min(index, MAX_STACK) * STEP;
return {
x: columnLeft(column) + POINT_WIDTH / 2,
y: top ? FRAME + offset : HEIGHT - FRAME - offset,
};
}
/** Le nombre de pions de side à un endroit. */
function countAt(board: BoardModel, side: Side, place: Place): number {
if (place === `bar-${side}`) {
return board.bar[side];
}
if (place === `off-${side}`) {
return board.off[side];
}
const point = board.points.find((p) => p.place === place);
return point?.owner === side ? point.count : 0;
}
/** Redessine tout le plateau dans svg. */
@@ -91,7 +154,18 @@ export function renderBoard(svg: SVGSVGElement, board: BoardModel, decor: BoardD
drawBar(svg, hits, board, decor);
drawTray(svg, hits, board, decor);
drawDice(svg, decor.dice);
drawCube(svg, decor.cube);
const cube = drawCube(svg, decor.cube);
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.canDouble) {
cube.classList.add("can-double");
addAction(hits, "double", TEXTS.double, cubeCenter(decor.cube), MIN_TARGET);
}
// Les zones à toucher passent devant tout le reste.
svg.append(hits);
}
@@ -153,16 +227,7 @@ function drawPoint(
});
}
if (point.owner !== null) {
const y = (i: number) => stackY(top, i);
drawStack(svg, point.owner, point.count, center, y, point.place, decor);
}
if (decor.lastMove.has(point.place)) {
add(svg, "circle", {
class: "last-move",
cx: center,
cy: top ? MIDDLE - 12 : MIDDLE + 12,
r: 5,
});
drawStack(svg, point.owner, point.count, point.place, decor);
}
addHit(hits, point.place, decor, {
x: left,
@@ -172,14 +237,13 @@ function drawPoint(
});
}
/** Une pile de pions, le i-ième centré en (cx, y(i)) ; le dernier porte le
* nombre total s'il y en a trop pour les montrer tous. */
/** Une pile de pions ; le dernier porte le nombre total s'il y en a trop
* pour les montrer tous. Le pion du dessus porte son endroit dans
* data-checker : c'est lui qu'on fait glisser quand un pion arrive. */
function drawStack(
svg: SVGSVGElement,
side: Side,
count: number,
cx: number,
y: (index: number) => number,
place: Place,
decor: BoardDecor,
): void {
@@ -192,15 +256,13 @@ function drawStack(
} else if (last && decor.movable.has(place)) {
classes.push("movable");
}
const cy = y(i);
add(svg, "circle", { class: classes.join(" "), cx, cy, r: RADIUS });
const { x, y } = slotCenter(place, i);
const checker = add(svg, "circle", { class: classes.join(" "), cx: x, cy: y, r: RADIUS });
if (last) {
checker.setAttribute("data-checker", place);
}
if (last && count > MAX_STACK) {
add(
svg,
"text",
{ class: `checker-count count-${side}`, x: cx, y: cy + 6 },
String(count),
);
add(svg, "text", { class: `checker-count count-${side}`, x, y: y + 6 }, String(count));
}
}
}
@@ -213,14 +275,10 @@ function drawBar(svg: SVGSVGElement, hits: Element, board: BoardModel, decor: Bo
width: BAR_WIDTH,
height: HEIGHT,
});
const cx = BAR_LEFT + BAR_WIDTH / 2;
// Les pions du joueur sur la barre entrent en haut à droite : on les met
// dans la moitié haute, en partant du milieu ; ceux de l'ordinateur en bas.
for (const side of ["player", "computer"] as const) {
const up = side === "player";
const y = (i: number) =>
up ? MIDDLE - RADIUS - 4 - i * STEP : MIDDLE + RADIUS + 4 + i * STEP;
drawStack(svg, side, board.bar[side], cx, y, `bar-${side}`, decor);
drawStack(svg, side, board.bar[side], `bar-${side}`, decor);
}
addHit(hits, "bar-player", decor, { x: BAR_LEFT, y: 0, width: BAR_WIDTH, height: MIDDLE });
}
@@ -228,17 +286,12 @@ function drawBar(svg: SVGSVGElement, hits: Element, board: BoardModel, decor: Bo
function drawTray(svg: SVGSVGElement, hits: Element, board: BoardModel, decor: BoardDecor): void {
// Pions sortis, vus de profil : l'ordinateur en haut, le joueur en bas.
for (const side of ["player", "computer"] as const) {
for (let i = 0; i < board.off[side]; i++) {
const y =
side === "computer" ? FRAME + i * OFF_SLAB : HEIGHT - FRAME - (i + 1) * OFF_SLAB;
add(svg, "rect", {
class: `checker checker-${side}`,
x: TRAY_LEFT + 4,
y: y + 1,
width: TRAY_WIDTH - 8,
height: OFF_SLAB - 2,
rx: 3,
});
const count = board.off[side];
for (let i = 0; i < count; i++) {
const slab = addSlab(svg, `checker checker-${side}`, slotCenter(`off-${side}`, i));
if (i === count - 1) {
slab.setAttribute("data-checker", `off-${side}`);
}
}
}
if (decor.targets.has("off-player")) {
@@ -251,14 +304,6 @@ function drawTray(svg: SVGSVGElement, hits: Element, board: BoardModel, decor: B
rx: 4,
});
}
if (decor.lastMove.has("off-computer")) {
add(svg, "circle", {
class: "last-move",
cx: TRAY_LEFT + TRAY_WIDTH / 2,
cy: MIDDLE - 70,
r: 5,
});
}
addHit(hits, "off-player", decor, {
x: TRAY_LEFT,
y: MIDDLE,
@@ -267,6 +312,143 @@ function drawTray(svg: SVGSVGElement, hits: Element, board: BoardModel, decor: B
});
}
/** Une tranche de pion sorti, centrée en spot. */
function addSlab(svg: Element, cssClass: string, spot: Spot): SVGElement {
return add(svg, "rect", {
class: cssClass,
x: TRAY_LEFT + 4,
y: spot.y - OFF_SLAB / 2 + 1,
width: TRAY_WIDTH - 8,
height: OFF_SLAB - 2,
rx: 3,
});
}
/** Un cercle de rayon r autour d'un pion (un cadre pour un pion sorti). */
function addRing(svg: Element, cssClass: string, place: Place, spot: Spot, r: number): void {
if (place.startsWith("off-")) {
addSlab(svg, cssClass, spot);
} else {
add(svg, "circle", { class: cssClass, cx: spot.x, cy: spot.y, r });
}
}
// ------------------------------------------------------ Coup de l'ordinateur
/**
* Les traces d'un coup : un pion fantôme (contour seul) là où chaque pion
* est parti, un cercle autour de chaque pion arrivé et de chaque pion frappé
* (sur la barre), et une flèche par demi-coup.
*/
function drawTrace(svg: SVGSVGElement, board: BoardModel, trace: MoveTrace): void {
const side = trace.side;
for (const [place, n] of trace.left) {
// Au-dessus des pions restés sur la case.
const remaining = countAt(board, side, place);
for (let k = 0; k < n; k++) {
addRing(svg, "trace-ghost", place, slotCenter(place, remaining + k), RADIUS - 1);
}
}
for (const [place, n] of trace.landed) {
const top = topIndex(board, side, place);
for (let k = 0; k < n && top - k >= 0; k++) {
addRing(svg, "trace-landed", place, slotCenter(place, top - k), RADIUS + 3);
}
}
const opponent: Side = side === "player" ? "computer" : "player";
const bar: Place = `bar-${opponent}`;
const top = topIndex(board, opponent, bar);
for (let k = 0; k < trace.hits && top - k >= 0; k++) {
addRing(svg, "trace-hit", bar, slotCenter(bar, top - k), RADIUS + 3);
}
const defs = add(svg, "defs", {});
const marker = add(defs, "marker", {
id: "trace-head",
viewBox: "0 0 10 10",
refX: 8,
refY: 5,
markerWidth: 3.5,
markerHeight: 3.5,
orient: "auto",
});
add(marker, "path", { class: "trace-head", d: "M0,0 L10,5 L0,10 z" });
const departures = new Map<Place, number>();
for (const { from, to } of trace.arrows) {
// De la place libre laissée par le pion (son fantôme) à son pion
// d'arrivée.
const k = departures.get(from) ?? 0;
departures.set(from, k + 1);
const start = slotCenter(from, countAt(board, side, from) + k);
const end = slotCenter(to, Math.max(topIndex(board, side, to), 0));
drawArrow(svg, start, end, to.startsWith("off-") ? OFF_SLAB : RADIUS + 4);
}
}
/** L'indice du pion du dessus dessiné à un endroit (-1 s'il n'y en a pas). */
function topIndex(board: BoardModel, side: Side, place: Place): number {
const count = countAt(board, side, place);
return place.startsWith("off-") ? count - 1 : Math.min(count, MAX_STACK) - 1;
}
/** Une flèche de start vers end, arrêtée à gap du centre d'arrivée. */
function drawArrow(svg: SVGSVGElement, start: Spot, end: Spot, gap: number): void {
const dx = end.x - start.x;
const dy = end.y - start.y;
const length = Math.hypot(dx, dy);
const keep = length > 2 * gap ? (length - gap) / length : 0.5;
add(svg, "line", {
class: "trace-arrow",
x1: start.x,
y1: start.y,
x2: start.x + dx * keep,
y2: start.y + dy * keep,
"marker-end": "url(#trace-head)",
});
}
/** Le centre du pion du dessus d'un endroit, sur le plateau tel qu'il est
* dessiné (null s'il n'y a pas de pion). */
export function checkerSpot(svg: SVGSVGElement, place: Place): Spot | null {
const checker = svg.querySelector(`[data-checker="${place}"]`);
if (checker === null) {
return null;
}
const number = (name: string) => Number(checker.getAttribute(name));
if (checker.tagName === "circle") {
return { x: number("cx"), y: number("cy") };
}
return { x: number("x") + number("width") / 2, y: number("y") + number("height") / 2 };
}
/**
* Fait glisser le pion du dessus de place depuis from jusqu'à sa position
* dessinée, en ms millisecondes (animation du navigateur, sans bibliothèque).
* Résolue à la fin du glissement.
*/
export async function slideChecker(
svg: SVGSVGElement,
place: Place,
from: Spot,
ms: number,
): Promise<void> {
const checker = svg.querySelector(`[data-checker="${place}"]`);
const to = checkerSpot(svg, place);
if (checker === null || to === null || typeof checker.animate !== "function") {
return;
}
// Devant les autres pions pendant le trajet, mais derrière les zones à
// toucher, toujours dessinées en dernier.
svg.insertBefore(checker, svg.lastChild);
const animation = checker.animate(
[
{ transform: `translate(${from.x - to.x}px, ${from.y - to.y}px)` },
{ transform: "translate(0px, 0px)" },
],
{ duration: ms, easing: "ease-in-out" },
);
await animation.finished.catch(() => undefined);
}
// Position des points d'un dé, en fractions de son côté.
const PIPS: Record<number, [number, number][]> = {
1: [[0.5, 0.5]],
@@ -310,7 +492,10 @@ function drawDice(svg: SVGSVGElement, dice: readonly ShownDie[]): void {
dice.forEach((die, i) => {
const x = center - total / 2 + i * (DIE + gap);
const y = MIDDLE - DIE / 2;
const group = add(svg, "g", { class: "die", "data-value": die.value });
const group = add(svg, "g", {
class: die.blocked ? "die die-blocked" : "die",
"data-value": die.value,
});
add(group, "rect", { class: `die-${die.side}`, x, y, width: DIE, height: DIE, rx: 6 });
for (const [px, py] of PIPS[die.value] ?? []) {
add(group, "circle", {
@@ -320,23 +505,78 @@ function drawDice(svg: SVGSVGElement, dice: readonly ShownDie[]): void {
r: 3.5,
});
}
if (die.blocked) {
add(group, "line", { class: "die-strike", x1: x, y1: y + DIE, x2: x + DIE, y2: y });
}
});
}
/** Le videau, dans le plateau de sortie : au milieu s'il est au centre,
* sinon du côté de son propriétaire. Au centre, il montre 64. */
function drawCube(svg: SVGSVGElement, cube: Cube): void {
const shift = cube.owner === "computer" ? -48 : cube.owner === "player" ? 48 : 0;
const x = TRAY_LEFT + (TRAY_WIDTH - CUBE) / 2;
const y = MIDDLE + shift - CUBE / 2;
function drawCube(svg: SVGSVGElement, cube: Cube): SVGElement {
const center = cubeCenter(cube);
const x = center.x - CUBE / 2;
const y = center.y - CUBE / 2;
const group = add(svg, "g", { class: "cube", "data-owner": cube.owner ?? "center" });
add(group, "rect", { class: "cube-face", x, y, width: CUBE, height: CUBE, rx: 4 });
add(
group,
"text",
{ class: "cube-value", x: x + CUBE / 2, y: y + CUBE / 2 + 6 },
{ class: "cube-value", x: center.x, y: center.y + 6 },
String(cube.owner === null ? 64 : cube.value),
);
return group;
}
/** Le centre du videau dans le plateau de sortie. */
function cubeCenter(cube: Cube): Spot {
const shift = cube.owner === "computer" ? -48 : cube.owner === "player" ? 48 : 0;
return { x: TRAY_LEFT + TRAY_WIDTH / 2, y: MIDDLE + shift };
}
/** 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 {
const center = { x: FRAME + HALF_WIDTH / 2, y: MIDDLE };
const gap = 8;
const group = add(svg, "g", { class: "die die-waiting" });
for (const x of [center.x - gap / 2 - DIE, center.x + gap / 2]) {
add(group, "rect", {
class: "die-player",
x,
y: MIDDLE - DIE / 2,
width: DIE,
height: DIE,
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.
*/
function addAction(
hits: Element,
action: string,
label: string,
center: Spot,
width: number,
): void {
add(hits, "rect", {
class: "hit action",
"data-action": action,
role: "button",
"aria-label": label,
// Sans déborder du dessin à droite (le videau est contre le bord).
x: Math.min(center.x - width / 2, WIDTH - width),
y: center.y - MIN_TARGET / 2,
width,
height: MIN_TARGET,
});
}
/** Une zone à toucher. Elle porte son état (can-move, can-land) pour les
+5 -2
View File
@@ -37,6 +37,9 @@ export const TEXTS = {
take: "Prendre",
drop: "Passer",
cancel: "Annuler",
skip: "Continuer",
replay: "Revoir",
replayTitle: "Revoir le dernier coup de l'ordinateur",
newGame: "Nouvelle partie",
help: "Aide",
back: "Retour à la partie",
@@ -57,8 +60,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.",
youCannotMove: (dice: readonly number[]) =>
`Vous ne pouvez pas jouer ${roll(dice)} : votre tour passe.`,
youCannotMove: (dice: readonly number[]) => `Aucun coup possible avec ${roll(dice)}.`,
pressContinue: "Touchez « Continuer » : le tour passe à l'ordinateur.",
thinking: "L'ordinateur réfléchit…",
computerMoved: (dice: readonly number[], move: string, ms: number) =>
`L'ordinateur a joué ${roll(dice)} : ${move} (${seconds(ms)}).`,
+6 -1
View File
@@ -36,7 +36,12 @@ async function playToTheEnd(game: Game, engine: Awaited<ReturnType<typeof loadNo
} else {
assert.ok(state.roll !== null);
const [first] = await engine.legalMoves(state.position, state.roll);
assert.ok(first !== undefined, "le joueur devrait avoir un coup");
if (first === undefined) {
// Aucun coup possible : le joueur laisse passer son tour.
assert.equal(await game.cannotMove(), true);
await game.skipTurn();
continue;
}
for (const halfMove of first.halfMoves) {
assert.equal(await game.playHalfMove(halfMove), true);
}
+22 -1
View File
@@ -86,19 +86,40 @@ test("un coup se saisit demi-coup par demi-coup ; un demi-coup refusé ne change
assert.equal(game.state.position, "SWAPPED");
});
test("sans coup possible, le tour passe", async () => {
test("sans coup possible, le tour passe quand le joueur le laisse passer", async () => {
const engine = fakeEngine({ legalMoves: async () => [] });
const game = Game.start(engine, {
dice: fixedDice([6, 6]),
setup: { position: "BLOCKED", firstTurn: "player" },
});
await game.roll();
// Le tour ne passe pas tout seul : le jet reste à « jouer ».
assert.equal(game.state.phase, "moving");
assert.equal(game.state.turn, "player");
assert.equal(await game.cannotMove(), true);
await game.skipTurn();
const last = game.events.at(-1);
assert.ok(last?.type === "MovePlayed");
assert.deepEqual(last.halfMoves, []);
assert.equal(game.state.turn, "computer");
});
test("le joueur ne peut pas laisser passer son tour s'il a un coup", async () => {
const engine = fakeEngine({
legalMoves: async () => [
{ notation: "8/5", halfMoves: [{ from: 8, to: 5 }], position: "P" },
],
});
const game = Game.start(engine, {
dice: fixedDice([3, 1]),
setup: { position: "X", firstTurn: "player" },
});
await game.roll();
assert.equal(await game.cannotMove(), false);
await assert.rejects(game.skipTurn(), /coup possible/);
assert.equal(game.state.phase, "moving");
});
test("l'ordinateur double si le moteur le recommande ; le joueur prend", async () => {
const engine = fakeEngine({
cubeDecision: async () => ({ available: true, double: true, take: true }),
+143
View File
@@ -0,0 +1,143 @@
// Un coup demi-coup par demi-coup (Game.lastMoveSteps), sur le vrai moteur
// WebAssembly : les plateaux intermédiaires que l'interface anime, et les
// pions frappés qu'elle signale.
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
import assert from "node:assert/strict";
import { test } from "node:test";
import type { BoardSide } from "../src/domain/game/engine.js";
import { Game } from "../src/domain/game/game.js";
import {
BAR,
formatHalfMoves,
type PositionId,
parseHalfMoves,
} from "../src/domain/game/position.js";
import { fixedDice } from "../src/domain/game/roll.js";
import { loadNodeEngine } from "./support/node-engine.js";
// Le joueur, au trait : un pion sur sa case 13, quatorze sur sa case 6.
// L'ordinateur : un blot sur la case 10 du joueur (sa case 15), quatorze sur
// sa case 6.
const PLAYER_CAN_HIT = "4P8HEADg/wcEAA";
// Le joueur, au trait : un pion sur la barre, quatorze sur sa case 6.
// L'ordinateur : un blot sur la case 21 du joueur (sa case 4), quatorze sur
// sa case 6.
const PLAYER_ON_BAR = "yP8PAADg/wcAQA";
// Le joueur, au trait, n'a plus que deux pions, sur ses cases 6 et 5.
const PLAYER_ALMOST_DONE = "/38AAABQAAAAAA";
/** Une partie où le joueur, au trait, joue le jet dice à la position. */
async function playerMove(position: PositionId, dice: [number, number], move: string) {
const engine = await loadNodeEngine();
const game = Game.start(engine, {
dice: fixedDice(dice),
setup: { position, firstTurn: "player" },
});
await game.roll();
for (const halfMove of parseHalfMoves(move)) {
assert.equal(await game.playHalfMove(halfMove), true, formatHalfMoves([halfMove]));
}
return game;
}
/** Le nombre de pions d'un camp sur une case (1 à 24), sur la barre ou sortis. */
function at(side: BoardSide, point: number | "bar" | "off"): number {
if (point === "bar") {
return side.bar;
}
return point === "off" ? side.off : (side.points[point - 1] ?? 0);
}
test("un coup simple : un plateau par demi-coup, sans pion frappé", async () => {
const engine = await loadNodeEngine();
const game = Game.start(engine, { dice: fixedDice([3, 1]) });
for (const halfMove of parseHalfMoves("8/5 6/5")) {
await game.playHalfMove(halfMove);
}
const steps = await game.lastMoveSteps();
assert.ok(steps !== null);
assert.equal(steps.side, "player");
assert.equal(formatHalfMoves(steps.halfMoves), "8/5 6/5");
assert.deepEqual(steps.hits, [false, false]);
const counts = steps.boards.map((b) => [at(b.player, 8), at(b.player, 6), at(b.player, 5)]);
assert.deepEqual(counts, [
[3, 5, 0],
[2, 5, 1],
[2, 4, 2],
]);
// Le dernier plateau est celui de la partie après le coup.
assert.deepEqual(steps.boards[2], await game.displayedBoard());
});
test("un pion frappé part sur la barre au demi-coup qui le frappe", async () => {
const game = await playerMove(PLAYER_CAN_HIT, [3, 1], "13/10 10/9");
const steps = await game.lastMoveSteps();
assert.ok(steps !== null);
assert.deepEqual(steps.hits, [true, false]);
const bar = steps.boards.map((b) => at(b.computer, "bar"));
assert.deepEqual(bar, [0, 1, 1]);
// Le blot était sur la case 10 du joueur, la case 15 de l'ordinateur.
assert.equal(at(steps.boards[0]?.computer as BoardSide, 15), 1);
assert.equal(at(steps.boards[1]?.computer as BoardSide, 15), 0);
assert.equal(at(steps.boards[2]?.player as BoardSide, 9), 1);
});
test("un coup depuis la barre, qui frappe en rentrant", async () => {
const game = await playerMove(PLAYER_ON_BAR, [4, 2], "bar/21 6/4");
const steps = await game.lastMoveSteps();
assert.ok(steps !== null);
assert.deepEqual(steps.hits, [true, false]);
assert.deepEqual(
steps.boards.map((b) => [at(b.player, "bar"), at(b.player, 21), at(b.computer, "bar")]),
[
[1, 0, 0],
[0, 1, 1],
[0, 1, 1],
],
);
});
test("une sortie de pions, jusqu'à la fin de la partie", async () => {
const game = await playerMove(PLAYER_ALMOST_DONE, [6, 5], "6/off 5/off");
assert.equal(game.state.phase, "over");
const steps = await game.lastMoveSteps();
assert.ok(steps !== null);
assert.deepEqual(steps.hits, [false, false]);
assert.deepEqual(
steps.boards.map((b) => at(b.player, "off")),
[13, 14, 15],
);
});
test("le coup de l'ordinateur, vu dans sa propre numérotation", async () => {
const engine = await loadNodeEngine();
// Vous commencez avec 3-1, puis l'ordinateur tire 6-4.
const game = Game.start(engine, { dice: fixedDice([3, 1, 6, 4]) });
for (const halfMove of parseHalfMoves("8/5 6/5")) {
await game.playHalfMove(halfMove);
}
await game.playComputerTurn();
const steps = await game.lastMoveSteps();
assert.ok(steps !== null);
assert.equal(steps.side, "computer");
assert.equal(steps.boards.length, steps.halfMoves.length + 1);
// Chaque demi-coup déplace un pion de l'ordinateur de sa case de départ.
for (const [i, h] of steps.halfMoves.entries()) {
const from = h.from === BAR ? "bar" : h.from;
const before = steps.boards[i]?.computer as BoardSide;
const after = steps.boards[i + 1]?.computer as BoardSide;
assert.equal(at(after, from), at(before, from) - 1);
}
assert.deepEqual(steps.boards.at(-1), await game.displayedBoard());
});
test("avant le premier coup, il n'y a rien à montrer", async () => {
const engine = await loadNodeEngine();
const game = Game.start(engine, { dice: fixedDice([3, 1]) });
assert.equal(await game.lastMoveSteps(), null);
});
+117 -3
View File
@@ -7,10 +7,16 @@
import assert from "node:assert/strict";
import { Before, BeforeAll, Given, setDefaultTimeout, Then, When } from "@cucumber/cucumber";
import type { Engine, Move } from "../../src/domain/game/engine.js";
import type { BoardSide, Engine, Move } from "../../src/domain/game/engine.js";
import type { GameEvent } from "../../src/domain/game/events.js";
import { Game, STANDARD_PLY } from "../../src/domain/game/game.js";
import { type PositionId, parseHalfMoves, START_POSITION } from "../../src/domain/game/position.js";
import { Game, type MoveSteps, STANDARD_PLY } from "../../src/domain/game/game.js";
import {
BAR,
OFF,
type PositionId,
parseHalfMoves,
START_POSITION,
} from "../../src/domain/game/position.js";
import { fixedDice } from "../../src/domain/game/roll.js";
import type { GameState } from "../../src/domain/game/state.js";
import { loadNodeEngine } from "../support/node-engine.js";
@@ -27,11 +33,32 @@ const COMPUTER_DOUBLES = "6O5uAAC4uzsAAA";
// l'ordinateur n'en a sorti aucun.
const PLAYER_ALMOST_DONE = "/38AAABQAAAAAA";
// Le joueur, au trait : un pion sur la barre, quatorze sur sa case 6.
// L'ordinateur tient ses six premières cases (vos cases 19 à 24), où votre
// pion devrait rentrer.
const PLAYER_SHUT_OUT = "t+0OAADg/wcAQA";
// L'ordinateur, au trait : un pion sur sa case 13, quatorze sur sa case 1.
// Vous : un blot sur votre case 15 (sa case 10), deux pions sur votre case
// 13 (sa case 12), douze sur votre case 6. Avec 3-1, son seul coup permis
// est 13/10* 10/9 : il frappe votre blot.
const COMPUTER_MUST_HIT = "4P8BEwD/PwAEAA";
let engine: Engine;
let game: Game;
let accepted: boolean;
let positionBefore: PositionId;
let computerTurn: { before: GameState; ms: number };
let computerMove: MoveSteps;
/** Le nombre de pions d'un camp à une case d'un demi-coup (barre, sortie ou
* 1 à 24, dans sa numérotation). */
function checkersAt(side: BoardSide, point: number): number {
if (point === BAR) {
return side.bar;
}
return point === OFF ? side.off : (side.points[point - 1] ?? 0);
}
BeforeAll(async () => {
engine = await loadNodeEngine();
@@ -171,6 +198,93 @@ Then(
},
);
// ------------------------------------------------- Aucun coup possible
Given("votre pion sur la barre ne peut pas rentrer", () => {
// Les six cases où il pourrait rentrer sont tenues par l'ordinateur.
game = Game.start(engine, {
setup: { position: PLAYER_SHUT_OUT, firstTurn: "player" },
dice: fixedDice([6, 5]),
});
});
When("vous lancez les dés", async () => {
await game.roll();
});
Then("aucun coup n'est possible avec votre jet", async () => {
assert.deepEqual(game.state.roll, [6, 5]);
assert.equal(await game.cannotMove(), true);
assert.deepEqual(await game.nextHalfMoves(), []);
});
Then("c'est toujours à vous", () => {
assert.equal(game.state.turn, "player");
assert.equal(game.state.phase, "moving");
assert.equal(lastEvent().type, "DiceRolled");
});
When("vous continuez", async () => {
await game.skipTurn();
const event = lastEvent();
assert.ok(event.type === "MovePlayed" && event.side === "player");
assert.deepEqual(event.halfMoves, []);
});
// ---------------------------------------------- Voir le coup de l'ordinateur
Given("un de vos pions est seul à portée de l'ordinateur", () => {
// L'ordinateur tirera 3-1. Le videau est chez vous : il ne peut pas
// doubler, il joue.
game = Game.start(engine, {
setup: {
position: COMPUTER_MUST_HIT,
firstTurn: "computer",
cube: { value: 2, owner: "player" },
},
dice: fixedDice([3, 1]),
});
});
When("l'ordinateur a joué", async () => {
await game.playComputerTurn();
const played = lastEvent();
assert.ok(played.type === "MovePlayed" && played.side === "computer");
const steps = await game.lastMoveSteps();
assert.ok(steps !== null);
computerMove = steps;
});
Then("son coup vous est montré case par case", async () => {
const { side, halfMoves, boards } = computerMove;
assert.equal(side, "computer");
assert.ok(halfMoves.length > 0);
assert.equal(boards.length, halfMoves.length + 1);
// Chaque plateau ne diffère du précédent que d'un pion de l'ordinateur,
// parti de la case de départ du demi-coup pour sa case d'arrivée.
for (const [i, h] of halfMoves.entries()) {
const before = boards[i]?.computer;
const after = boards[i + 1]?.computer;
assert.ok(before !== undefined && after !== undefined);
assert.equal(checkersAt(after, h.from), checkersAt(before, h.from) - 1);
assert.equal(checkersAt(after, h.to), checkersAt(before, h.to) + 1);
}
// Le dernier est le plateau de la partie.
assert.deepEqual(boards.at(-1), await game.displayedBoard());
});
Then("votre pion frappé part sur la barre", () => {
const { boards, hits } = computerMove;
const hit = hits.indexOf(true);
assert.ok(hit >= 0, "aucun pion frappé");
assert.equal(boards[hit]?.player.bar, 0);
assert.equal(boards[hit + 1]?.player.bar, 1);
});
Then("vous pouvez revoir son coup", async () => {
assert.deepEqual(await game.lastMoveSteps(), computerMove);
});
// ------------------------------------------------------------------ Videau
Given("l'ordinateur vous propose de doubler", async () => {
+51 -2
View File
@@ -10,9 +10,9 @@ import { readFileSync } from "node:fs";
import { test } from "node:test";
import type { BoardSide } from "../src/domain/game/engine.js";
import { appendEvent, EMPTY_JOURNAL } from "../src/domain/game/events.js";
import { START_POSITION } from "../src/domain/game/position.js";
import { parseHalfMoves, START_POSITION } from "../src/domain/game/position.js";
import { createGameStore } from "../src/storage/game-store.js";
import { boardModel, moveTargets, placeOf, pointSlot } from "../src/ui/board-model.js";
import { boardModel, moveTargets, moveTrace, 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";
@@ -67,6 +67,55 @@ test("les demi-coups possibles donnent les pions jouables et leurs cases d'arriv
assert.deepEqual(targets.get("p3")?.get("off-player"), { from: 3, to: 0 });
});
test("traces d'un coup simple : une flèche par demi-coup, départs et arrivées", () => {
// L'ordinateur joue 13/9 13/7 dans sa numérotation : ses cases 13, 9 et
// 7 sont les cases 12, 16 et 18 du joueur.
const trace = moveTrace("computer", parseHalfMoves("13/9 13/7"), [false, false]);
assert.deepEqual(trace.arrows, [
{ from: "p12", to: "p16" },
{ from: "p12", to: "p18" },
]);
assert.deepEqual([...trace.left], [["p12", 2]]);
assert.deepEqual(
[...trace.landed],
[
["p16", 1],
["p18", 1],
],
);
assert.equal(trace.hits, 0);
});
test("traces : un pion qui fait étape ne laisse ni fantôme ni cercle à l'étape", () => {
const trace = moveTrace("computer", parseHalfMoves("24/18 18/14"), [false, true]);
assert.equal(trace.arrows.length, 2);
assert.deepEqual([...trace.left], [["p1", 1]]);
assert.deepEqual([...trace.landed], [["p11", 1]]);
assert.equal(trace.hits, 1);
});
test("traces : rentrée depuis la barre avec un pion frappé, sortie de pions", () => {
const entering = moveTrace("computer", parseHalfMoves("bar/22 6/4"), [true, false]);
assert.deepEqual(entering.arrows[0], { from: "bar-computer", to: "p3" });
assert.deepEqual(
[...entering.left],
[
["bar-computer", 1],
["p19", 1],
],
);
assert.equal(entering.hits, 1);
const bearingOff = moveTrace("computer", parseHalfMoves("6/off 5/off"), [false, false]);
assert.deepEqual([...bearingOff.landed], [["off-computer", 2]]);
assert.deepEqual(
[...bearingOff.left],
[
["p19", 1],
["p20", 1],
],
);
});
test("l'aide lit le scénario tel qu'il est écrit dans docs/scenarios/", () => {
const feature = parseFeature(read("docs/scenarios/jouer-une-partie.feature"));
assert.equal(feature.title, "Jouer une partie contre l'ordinateur");
+24
View File
@@ -30,6 +30,30 @@ Fonctionnalité: Jouer une partie contre l'ordinateur
Quand ses dés sont tirés
Alors il joue le coup recommandé par le moteur en moins de 2 secondes
Scénario: Aucun coup possible avec votre jet
Il arrive que les dés ne permettent de bouger aucun pion. Votre jet reste
affiché avec un message, et le tour ne passe à l'ordinateur qu'une fois
que vous avez touché « Continuer ».
Étant donné que votre pion sur la barre ne peut pas rentrer
Quand vous lancez les dés
Alors aucun coup n'est possible avec votre jet
Et c'est toujours à vous
Quand vous continuez
Alors c'est ensuite au tour de l'ordinateur
Scénario: Voir le coup de l'ordinateur
Après chaque coup de l'ordinateur, ses pions glissent un par un sur le
plateau. Restent ensuite, jusqu'à votre prochaine action, une flèche par
pion déplacé, ses pions arrivés entourés et un pion fantôme là où ils
étaient. Le bouton « Revoir » rejoue son coup.
Étant donné qu'un de vos pions est seul à portée de l'ordinateur
Quand l'ordinateur a joué
Alors son coup vous est montré case par case
Et votre pion frappé part sur la barre
Et vous pouvez revoir son coup
Scénario: Prendre le double de l'ordinateur
Étant donné que l'ordinateur vous propose de doubler
Quand vous prenez
+140 -5
View File
@@ -101,7 +101,7 @@ async function waitForPlayer() {
const b = document.getElementById(id);
return !b.hidden && !b.disabled;
};
for (const id of ["again", "take", "roll"]) {
for (const id of ["again", "take", "skip", "roll"]) {
if (usable(id)) {
return id;
}
@@ -130,6 +130,101 @@ async function waitForPlayer() {
throw new Error(`rien à faire pour le joueur depuis 60 s : ${screen}`);
}
/** Le dernier coup de l'ordinateur, s'il est la dernière chose jouée (fin de
* partie mise à part) et qu'il a déplacé des pions ; sinon null. */
function computerMoveToShow(journal) {
const last = journal.events.filter((e) => e.type !== "GameEnded").at(-1);
return last?.type === "MovePlayed" && last.side === "computer" && last.halfMoves.length > 0
? last
: null;
}
/** Ce que montre le plateau du coup de l'ordinateur. */
async function shownTraces() {
return page.evaluate(() => ({
arrows: document.querySelectorAll(".trace-arrow").length,
landed: document.querySelectorAll(".trace-landed").length,
ghosts: document.querySelectorAll(".trace-ghost").length,
hits: document.querySelectorAll(".trace-hit").length,
replay: !document.getElementById("replay").hidden,
}));
}
/** Après un tour de l'ordinateur : ses traces sont là si son coup est la
* dernière chose jouée, et rien sinon. */
async function checkTraces() {
const journal = await savedJournal();
const move = computerMoveToShow(journal);
const traces = await shownTraces();
const last = journal.events.filter((e) => e.type !== "GameEnded").at(-1);
if (last?.type === "MovePlayed" && last.side === "computer" && last.halfMoves.length === 0) {
// 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,
`jet de l'ordinateur sans coup mal montré : « ${info} », ${blocked} dés barrés`);
log(`l'ordinateur ne peut pas jouer : « ${info} »`);
}
if (move === null) {
check(traces.arrows + traces.landed + traces.ghosts === 0 && !traces.replay,
`traces sans coup de l'ordinateur à montrer : ${JSON.stringify(traces)}`);
return null;
}
check(traces.arrows === move.halfMoves.length && traces.landed > 0 && traces.ghosts > 0 &&
traces.replay, `coup de l'ordinateur mal montré : ${JSON.stringify(traces)}`);
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;
}
/** Observe la page pendant ms millisecondes : le plus grand nombre
* d'animations en cours à la fois, et le plus petit nombre de flèches. */
async function watchReplay(ms) {
let animations = 0;
let arrows = Infinity;
for (const end = Date.now() + ms; Date.now() < end; ) {
const seen = await page.evaluate(() => ({
animations: document.getAnimations().length,
arrows: document.querySelectorAll(".trace-arrow").length,
}));
animations = Math.max(animations, seen.animations);
arrows = Math.min(arrows, seen.arrows);
await new Promise((resolve) => setTimeout(resolve, 40));
}
return { animations, arrows };
}
/** « Revoir » : rejoue le coup (sans animation en mouvement réduit), puis
* remet les mêmes traces. */
async function checkReplay(traces, reducedMotion) {
await page.emulateMedia({ reducedMotion: reducedMotion ? "reduce" : "no-preference" });
await page.click("#replay");
const seen = await watchReplay(300);
if (reducedMotion) {
check(seen.animations === 0, "Revoir en mouvement réduit : des pions glissent quand même");
} else {
check(seen.animations > 0 && seen.arrows === 0,
`Revoir : pas d'animation sans traces (${JSON.stringify(seen)})`);
}
await waitForPlayer();
const after = await shownTraces();
check(JSON.stringify(after) === JSON.stringify(traces),
`traces différentes après Revoir : ${JSON.stringify(after)}`);
await page.emulateMedia({ reducedMotion: "no-preference" });
}
// Garde-fou : une étape bloquée plus de deux minutes arrête tout, avec une
// capture et l'état de l'écran.
let lastStep = Date.now();
@@ -157,10 +252,30 @@ let doubled = false;
let selectionShot = false;
let reloaded = false;
let computerMs = [];
let tracesSeen = 0;
let replays = 0;
let skips = 0;
let rolls = 0;
for (let actions = 0; ; actions++) {
check(actions < 800, "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();
if (traces !== null) {
tracesSeen++;
if (replays < 2) {
replays++;
await checkReplay(traces, replays === 2);
if (replays === 1) {
await shot("coup-adverse");
}
log(`coup de l'ordinateur revu${replays === 2 ? " en mouvement réduit" : ""} : ` +
`${traces.arrows} flèches, ${traces.landed} pions entourés, ` +
`${traces.ghosts} fantômes, ${traces.hits} pions frappés signalés`);
}
}
if (process.env.TRACE) {
log(`${todo} : ${await page.textContent("#info")} / ${await page.textContent("#prompt")}`);
}
@@ -172,22 +287,37 @@ for (let actions = 0; ; actions++) {
if (todo === "again") {
break;
}
if (todo === "take") {
if (todo === "skip") {
// Aucun coup possible : le jet reste affiché, barré, avec un
// 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,
`aucun coup possible mal montré : « ${info} », ${blocked} dés barrés`);
skips++;
log(`aucun coup possible : « ${info} », vous continuez`);
await page.click("#skip");
} else if (todo === "take") {
log("l'ordinateur double : vous prenez");
await shot("videau-propose");
await page.click("#take");
} else if (todo === "roll") {
if (!doubled && (await page.isEnabled("#double"))) {
doubled = true;
log("vous doublez");
await page.click("#double");
log("vous doublez en touchant le videau");
await page.click(await touchTarget("double", "Doubler"));
await waitForPlayer();
const answer = (await page.textContent("#info")) || (await page.textContent("#prompt"));
log(`réponse : ${answer}`);
continue;
}
await page.click("#roll");
// Une fois sur deux, en touchant les dés en attente plutôt que le
// bouton.
rolls++;
await page.click(rolls % 2 === 0 ? await touchTarget("roll", "Lancer les dés") : "#roll");
} 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) {
@@ -223,6 +353,11 @@ log(
);
check(countMoves(journal, "player") >= 3 && countMoves(journal, "computer") >= 3, "trop peu de coups");
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`);
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) {
log(`temps affichés des tours de l'ordinateur : max ${Math.max(...computerMs)} ms, ` +
`${computerMs.length} tours`);
+49 -1
View File
@@ -444,13 +444,61 @@ body.busy .prompt {
.count-computer { fill: var(--count-computer); }
.target-zone { fill: var(--target); }
.last-move { fill: var(--last-move); }
/* Le dernier coup de l'ordinateur (couleur laiton, charte) : une flèche par
* demi-coup, les pions arrivés et frappés entourés, un pion fantôme là où
* un pion est parti. */
.trace-arrow {
stroke: var(--last-move);
stroke-width: 5;
stroke-linecap: round;
opacity: 0.85;
}
.trace-head { fill: var(--last-move); }
.trace-landed,
.trace-hit {
fill: none;
stroke: var(--last-move);
stroke-width: 4;
}
.trace-hit { stroke-dasharray: 6 4; }
.trace-ghost {
fill: none;
stroke: var(--last-move);
stroke-width: 2.5;
stroke-dasharray: 5 4;
}
.die-player { fill: var(--die-player); stroke: var(--checker-player-edge); stroke-width: 1.5; }
.die-computer { fill: var(--die-computer); stroke: var(--checker-computer-edge); stroke-width: 1.5; }
.pip-player { fill: var(--pip-player); }
.pip-computer { fill: var(--pip-computer); }
/* Dés du joueur en attente d'être lancés, et videau quand il peut doubler :
* ils se touchent comme les boutons. */
.die-waiting rect {
stroke: var(--highlight);
stroke-width: 2.5;
stroke-dasharray: 5 4;
}
.cube.can-double .cube-face {
stroke: var(--highlight);
stroke-width: 3;
}
/* Jet sans coup possible : dés estompés et barrés. */
.die-blocked { opacity: 0.5; }
.die-strike {
stroke: var(--text);
stroke-width: 3;
stroke-linecap: round;
}
.cube-face { fill: var(--cube); stroke: var(--checker-player-edge); stroke-width: 2; }
.cube-value {
+2
View File
@@ -44,6 +44,8 @@
<button id="take" type="button" hidden></button>
<button id="drop" type="button" class="secondary" 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>
<noscript><p>Ustagammon a besoin de JavaScript.</p></noscript>