From 7a9b3764176a6be6e75bc216f752dc4695726720 Mon Sep 17 00:00:00 2001 From: Pierre MARTIN Date: Tue, 3 Nov 2026 01:53:26 +0100 Subject: [PATCH] Coup de l'ordinateur : animation, traces et bouton Revoir MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le domaine donne le dernier coup demi-coup par demi-coup (plateaux intermédiaires du moteur, pions frappés lus sur la barre) ; l'interface fait glisser les pions puis laisse flèches, pions entourés et fantômes jusqu'à l'action suivante. Scénario « Voir le coup de l'ordinateur », tests unitaires et test dans le navigateur. Co-Authored-By: Claude Opus 5.5 --- app/src/domain/game/game.ts | 67 +++++- app/src/ui/app.ts | 163 ++++++++++--- app/src/ui/board-model.ts | 44 ++++ app/src/ui/board-view.ts | 266 +++++++++++++++++----- app/src/ui/texts.ts | 2 + app/tests/move-steps.test.ts | 143 ++++++++++++ app/tests/steps/jouer-une-partie.steps.ts | 82 ++++++- app/tests/ui.test.ts | 53 ++++- docs/scenarios/jouer-une-partie.feature | 12 + tests/browser/play.mjs | 91 ++++++++ web/app.css | 28 ++- web/index.html | 1 + 12 files changed, 851 insertions(+), 101 deletions(-) create mode 100644 app/tests/move-steps.test.ts diff --git a/app/src/domain/game/game.ts b/app/src/domain/game/game.ts index d9f85b2..08e41f4 100644 --- a/app/src/domain/game/game.ts +++ b/app/src/domain/game/game.ts @@ -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 { + 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 }; } /** @@ -325,6 +370,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 { + 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; diff --git a/app/src/ui/app.ts b/app/src/ui/app.ts index 9cac0d4..a2b1dca 100644 --- a/app/src/ui/app.ts +++ b/app/src/ui/app.ts @@ -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; @@ -49,6 +52,7 @@ export function startApp(options: AppOptions): void { take: byId("take"), drop: byId("drop"), cancel: byId("cancel"), + replay: byId("replay"), again: byId("again"), newGame: byId("new-game"), helpButton: byId("help-button"), @@ -63,6 +67,8 @@ export function startApp(options: AppOptions): void { ui.take.textContent = TEXTS.take; ui.drop.textContent = TEXTS.drop; ui.cancel.textContent = TEXTS.cancel; + 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; @@ -76,11 +82,16 @@ export function startApp(options: AppOptions): void { let canDouble = 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 { + /** Recalcule ce qui demande le moteur, sans redessiner. */ + async function update(): Promise { if (game === null) { return; } @@ -90,6 +101,11 @@ export function startApp(options: AppOptions): void { if (selected !== null && !targets.has(selected)) { selected = null; } + } + + /** Recalcule ce qui demande le moteur, puis redessine. */ + async function refresh(): Promise { + await update(); render(); } @@ -99,14 +115,19 @@ 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), + 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), + }); + } ui.pips.textContent = TEXTS.pipCounts(model.pipCount.player, model.pipCount.computer); ui.info.textContent = info; ui.prompt.textContent = prompt(game, selected !== null, canDouble); @@ -122,7 +143,8 @@ export function startApp(options: AppOptions): void { 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.replay, ui.again, ui.newGame]) { button.disabled = busy; } // Pour le style et pour les tests dans un navigateur : l'écran @@ -142,14 +164,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 { + /** 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 { + 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 { + if (game === null || window.matchMedia("(prefers-reduced-motion: reduce)").matches) { + return; + } + const decor = { dice: shownDice(game), 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 { + try { + await update(); + if (animate && shownMove !== null) { + await animateMove(shownMove); + } + } finally { + busy = false; + render(); } } @@ -162,6 +243,7 @@ export function startApp(options: AppOptions): void { busy = true; selected = null; targets = new Map(); + shownMove = null; render(); try { await action(game); @@ -170,8 +252,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); } } @@ -214,6 +295,7 @@ export function startApp(options: AppOptions): void { if (!busy && game !== null) { game.cancelHalfMoves(); selected = null; + shownMove = null; void refresh(); } }; @@ -247,9 +329,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 +359,7 @@ 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.replay, ui.again]) { button.hidden = true; } ui.newGame.disabled = true; @@ -276,12 +371,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) => { @@ -318,18 +414,15 @@ function shownDice(game: Game): ShownDie[] { return rolled.roll.map((value, i) => ({ value, side: sides[i] ?? "player" })); } -/** Les cases de départ et d'arrivée du dernier coup, s'il est de - * l'ordinateur. */ -function lastComputerMove(game: Game): Set { - const played = game.events.findLast((e) => e.type === "MovePlayed"); - const places = new Set(); - 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)); - } +/** 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 { + 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. */ diff --git a/app/src/ui/board-model.ts b/app/src/ui/board-model.ts index dbd19f6..d07fe8f 100644 --- a/app/src/ui/board-model.ts +++ b/app/src/ui/board-model.ts @@ -82,6 +82,50 @@ export function moveTargets(halfMoves: readonly HalfMove[]): Map; + /** Par endroit, le nombre de pions partis (un pion fantôme chacun). */ + readonly left: ReadonlyMap; + /** 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(); + 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(); + const left = new Map(); + 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 } { diff --git a/app/src/ui/board-view.ts b/app/src/ui/board-view.ts index ce28bac..422d62e 100644 --- a/app/src/ui/board-view.ts +++ b/app/src/ui/board-view.ts @@ -11,7 +11,7 @@ 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"; /** Un dé affiché, de la couleur du camp qui l'a lancé. */ export interface ShownDie { @@ -28,10 +28,18 @@ export interface BoardDecor { /** Le pion touché, dont on montre les cases d'arrivée. */ readonly selected: Place | null; readonly targets: ReadonlySet; - /** Les cases de départ et d'arrivée du dernier coup de l'ordinateur. */ - readonly lastMove: ReadonlySet; + /** Les traces du dernier coup de l'ordinateur, tant qu'on les montre. */ + readonly trace: MoveTrace | null; } +/** Ce qui ne s'ajoute pas aux pions pendant l'animation d'un coup. */ +export const NO_MARKS = { + movable: new Set(), + selected: null, + targets: new Set(), + trace: null, +} as const; + // 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 +81,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. */ @@ -92,6 +141,9 @@ export function renderBoard(svg: SVGSVGElement, board: BoardModel, decor: BoardD drawTray(svg, hits, board, decor); drawDice(svg, decor.dice); drawCube(svg, decor.cube); + if (decor.trace !== null) { + drawTrace(svg, board, decor.trace); + } // Les zones à toucher passent devant tout le reste. svg.append(hits); } @@ -153,16 +205,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 +215,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 +234,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 +253,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 +264,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 +282,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 +290,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(); + 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 { + 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 = { 1: [[0.5, 0.5]], diff --git a/app/src/ui/texts.ts b/app/src/ui/texts.ts index 5a177df..d785aee 100644 --- a/app/src/ui/texts.ts +++ b/app/src/ui/texts.ts @@ -37,6 +37,8 @@ export const TEXTS = { take: "Prendre", drop: "Passer", cancel: "Annuler", + replay: "Revoir", + replayTitle: "Revoir le dernier coup de l'ordinateur", newGame: "Nouvelle partie", help: "Aide", back: "Retour à la partie", diff --git a/app/tests/move-steps.test.ts b/app/tests/move-steps.test.ts new file mode 100644 index 0000000..7657036 --- /dev/null +++ b/app/tests/move-steps.test.ts @@ -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); +}); diff --git a/app/tests/steps/jouer-une-partie.steps.ts b/app/tests/steps/jouer-une-partie.steps.ts index f1f3050..181430f 100644 --- a/app/tests/steps/jouer-une-partie.steps.ts +++ b/app/tests/steps/jouer-une-partie.steps.ts @@ -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,27 @@ const COMPUTER_DOUBLES = "6O5uAAC4uzsAAA"; // l'ordinateur n'en a sorti aucun. const PLAYER_ALMOST_DONE = "/38AAABQAAAAAA"; +// 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 +193,60 @@ Then( }, ); +// ---------------------------------------------- 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 () => { diff --git a/app/tests/ui.test.ts b/app/tests/ui.test.ts index cfc1218..55a4a2f 100644 --- a/app/tests/ui.test.ts +++ b/app/tests/ui.test.ts @@ -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"); diff --git a/docs/scenarios/jouer-une-partie.feature b/docs/scenarios/jouer-une-partie.feature index bd46470..8e32874 100644 --- a/docs/scenarios/jouer-une-partie.feature +++ b/docs/scenarios/jouer-une-partie.feature @@ -30,6 +30,18 @@ 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: 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 diff --git a/tests/browser/play.mjs b/tests/browser/play.mjs index f417a34..daf72dd 100644 --- a/tests/browser/play.mjs +++ b/tests/browser/play.mjs @@ -130,6 +130,77 @@ 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 move = computerMoveToShow(await savedJournal()); + const traces = await shownTraces(); + 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; +} + +/** 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 +228,28 @@ let doubled = false; let selectionShot = false; let reloaded = false; let computerMs = []; +let tracesSeen = 0; +let replays = 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")}`); } @@ -223,6 +312,8 @@ 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"); +log(`coup de l'ordinateur montré ${tracesSeen} fois, et effacé à chaque action suivante`); if (computerMs.length > 0) { log(`temps affichés des tours de l'ordinateur : max ${Math.max(...computerMs)} ms, ` + `${computerMs.length} tours`); diff --git a/web/app.css b/web/app.css index 0ed68b4..25e032d 100644 --- a/web/app.css +++ b/web/app.css @@ -226,7 +226,33 @@ button:disabled { .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); } .die-computer { fill: var(--die-computer); stroke: var(--checker-computer-edge); } diff --git a/web/index.html b/web/index.html index 63ad803..e09253b 100644 --- a/web/index.html +++ b/web/index.html @@ -30,6 +30,7 @@ +