From 9c241de5c9876dcdd3c4681399c401681866010c Mon Sep 17 00:00:00 2001 From: Pierre MARTIN Date: Tue, 3 Nov 2026 01:37:50 +0100 Subject: [PATCH 1/7] Plan : montrer le coup de l'ordinateur Co-Authored-By: Claude Opus 5.5 --- docs/feuille-de-route.md | 1 + docs/plans/2026-11-03-coup-adverse.md | 77 +++++++++++++++++++++++++++ 2 files changed, 78 insertions(+) create mode 100644 docs/plans/2026-11-03-coup-adverse.md diff --git a/docs/feuille-de-route.md b/docs/feuille-de-route.md index b5a8b6e..67637ba 100644 --- a/docs/feuille-de-route.md +++ b/docs/feuille-de-route.md @@ -21,6 +21,7 @@ garde-fous d'architecture (0d.1), déploiement sur https://ustagammon.sans.pub. | Sous-étape | Contenu | État | Plan | | --- | --- | --- | --- | | 0d.2 | Plateau minimal jouable (PWA hors ligne), publié ; reste la vérification avec Pierre sur le Fairphone 4 (`docs/guide-test-etape-0.md`, section « Étape 0d ») | 🔄 | | +| 0d.2+ | Montrer le coup de l'ordinateur (animation, traces, « Revoir ») : retour de Pierre | 🔄 | `plans/2026-11-03-coup-adverse.md` | **Critère de fin** : une partie complète jouée sur le Fairphone 4, moins de 2 s par coup de l'IA en niveau standard, tous les tests au vert. diff --git a/docs/plans/2026-11-03-coup-adverse.md b/docs/plans/2026-11-03-coup-adverse.md new file mode 100644 index 0000000..fe69c17 --- /dev/null +++ b/docs/plans/2026-11-03-coup-adverse.md @@ -0,0 +1,77 @@ +# Montrer le coup que l'ordinateur vient de jouer + +- **Date** : 2026-11-03 +- **Étape** : 0d (retour de Pierre sur le plateau publié) +- **Statut** : en cours +- **Exécutant** : sous-agent general-purpose, dans un worktree isolé + +## Objectif + +Après le coup de l'ordinateur, le joueur voit ce qui a bougé sans lire la +notation. Choix de Pierre : **animation puis traces**. + +## Consignes + +1. **Animation** : quand l'ordinateur joue, ses pions glissent un par un, un + demi-coup après l'autre (environ 0,4 s chacun), de la case de départ à la + case d'arrivée. Un pion frappé part sur la barre de façon visible. Si le + système demande moins d'animations (`prefers-reduced-motion`), pas + d'animation : on passe directement aux traces. +2. **Traces** : une fois l'animation finie, et jusqu'à la prochaine action du + joueur (lancer les dés, doubler, prendre, passer, toucher un pion) : + - une flèche de chaque case de départ vers sa case d'arrivée ; + - les pions déplacés entourés ; + - à la case de départ d'un pion parti, un pion « fantôme » (contour + seul) ; + - un pion frappé signalé sur la barre. + Les petits points orange actuels sont remplacés par ces traces. +3. **Bouton « Revoir »** à côté des boutons d'action : il rejoue l'animation du + dernier coup de l'ordinateur, puis remet les traces. Il disparaît dès que + le joueur agit. +4. **Où vit la logique** : la liste des demi-coups du dernier coup de + l'ordinateur et les positions intermédiaires viennent du domaine (le journal + contient déjà `MovePlayed` avec ses demi-coups ; ajouter si besoin une + fonction qui donne la suite des plateaux intermédiaires, à partir du + moteur, sans réimplémenter de règle : par exemple `checkHalfMoves` ou le + décodage de position à chaque étape). L'interface ne fait que dessiner. + Les garde-fous d'architecture existants doivent passer. +5. **Textes** dans `app/src/ui/texts.ts` ; mots du glossaire. +6. **Scénario** (ADR 0008) : ajouter à `docs/scenarios/jouer-une-partie.feature` + un scénario « Voir le coup de l'ordinateur », écrit en intentions (par + exemple : quand l'ordinateur a joué, alors son coup vous est montré case par + case, et vous pouvez le revoir), avec ses étapes Cucumber sur le domaine. +7. **Tests** : + - tests unitaires de ce qui est calculable hors navigateur (traces et + plateaux intermédiaires d'un coup avec et sans pion frappé, coup depuis la + barre, sortie de pions) ; + - `make test-browser` (`tests/browser/play.mjs`) : après un coup de + l'ordinateur, les flèches et les pions entourés sont présents ; « Revoir » + rejoue puis les remet ; tout disparaît à l'action suivante ; capture + d'écran `coup-adverse` ; + - `make lint`, `make test` et `make test-browser` verts. + +Contraintes : +- `CLAUDE.md`, ADR 0007, 0008, 0009 : simplicité, pas de framework ni de + bibliothèque d'animation (animations CSS ou `requestAnimationFrame`), + identifiants en anglais, commentaires et textes en français ; +- aucune règle du jeu réimplémentée ; +- l'animation ne doit pas retarder le moteur ni bloquer la saisie au-delà de + sa durée ; le temps de calcul affiché reste celui du moteur ; +- ne pas publier, ne pas pousser, ne pas créer de merge request ; +- commits petits, en français, avec la ligne + `Co-Authored-By: Claude Opus 5.5 `. + +Rapport attendu : ce qui a été fait, captures d'écran (chemins), résultat des +tests, difficultés, écarts au plan. + +## Critères de réussite + +- Dans Chromium et Firefox sans interface, après chaque coup de l'ordinateur : + animation (ou traces directes en mouvement réduit), traces visibles, « Revoir » + fonctionnel, traces effacées à l'action suivante. +- Le scénario « Voir le coup de l'ordinateur » passe dans `make test`. +- `make lint`, `make test` et `make test-browser` passent. + +## Résultat + +À remplir à la clôture. From 7a9b3764176a6be6e75bc216f752dc4695726720 Mon Sep 17 00:00:00 2001 From: Pierre MARTIN Date: Tue, 3 Nov 2026 01:53:26 +0100 Subject: [PATCH 2/7] 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 @@ + From 1bb9a9555123a736696037d8abccc72806e71af8 Mon Sep 17 00:00:00 2001 From: Pierre MARTIN Date: Tue, 3 Nov 2026 02:00:58 +0100 Subject: [PATCH 3/7] =?UTF-8?q?Plan=20coup=20adverse=20:=20r=C3=A9sultat?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- docs/plans/2026-11-03-coup-adverse.md | 72 ++++++++++++++++++++++++++- 1 file changed, 70 insertions(+), 2 deletions(-) diff --git a/docs/plans/2026-11-03-coup-adverse.md b/docs/plans/2026-11-03-coup-adverse.md index fe69c17..4ec4d36 100644 --- a/docs/plans/2026-11-03-coup-adverse.md +++ b/docs/plans/2026-11-03-coup-adverse.md @@ -2,7 +2,7 @@ - **Date** : 2026-11-03 - **Étape** : 0d (retour de Pierre sur le plateau publié) -- **Statut** : en cours +- **Statut** : terminé - **Exécutant** : sous-agent general-purpose, dans un worktree isolé ## Objectif @@ -74,4 +74,72 @@ tests, difficultés, écarts au plan. ## Résultat -À remplir à la clôture. +Terminé le 2026-11-03, branche `coup-adverse-travail`. + +**Domaine** (`app/src/domain/game/game.ts`) : `Game.lastMoveSteps()` donne le +dernier coup joué demi-coup par demi-coup (`MoveSteps` : camp, demi-coups, +plateaux successifs, pions frappés). Les plateaux intermédiaires viennent du +moteur : `checkHalfMoves` sur les premiers demi-coups depuis la position +d'avant le coup, puis `board`. Un pion frappé se lit sur le plateau du moteur +(la barre adverse grossit), aucune règle n'est rejouée. Le format des +événements ne change pas. + +**Interface** : +- `board-model.ts` : `moveTrace()` calcule les traces (une flèche par + demi-coup, pions arrivés, pions partis, pions frappés). Un pion qui fait + étape (13/7 7/4) ne laisse rien sur la case d'étape. Testé sous Node. +- `board-view.ts` : traces dessinées en laiton (`--last-move`, comme le + demande la charte) : flèches avec pointe, pions arrivés entourés, pion + fantôme en pointillés à la case de départ, pion frappé entouré en + pointillés sur la barre. Les petits points orange ont disparu. La + géométrie des piles est regroupée dans `slotCenter()`. Le pion du dessus de + chaque endroit porte `data-checker`. `slideChecker()` le fait glisser avec + `Element.animate` (API du navigateur, sans bibliothèque). +- `app.ts` : après le tour de l'ordinateur, le plateau est redessiné à + chaque étape et le pion arrivé glisse en 0,4 s depuis sa case de départ. Un + pion frappé glisse en même temps vers la barre. Ensuite viennent les traces + et le bouton « Revoir ». Tout s'efface à l'action suivante (lancer, + doubler, prendre, passer, nouvelle partie, toucher un pion). Avec + `prefers-reduced-motion: reduce`, les traces s'affichent directement. + L'animation commence une fois le moteur fini : le temps affiché reste celui + du moteur. Les touches sont ignorées pendant l'animation (au plus 0,4 s par + demi-coup). Une partie reprise juste après le coup de l'ordinateur montre + ses traces sans l'animer. +- Textes `replay` et `replayTitle` dans `texts.ts`, bouton `#replay` dans + `web/index.html`. + +**Scénario** « Voir le coup de l'ordinateur » dans +`docs/scenarios/jouer-une-partie.feature`. Il part d'une position où le seul +coup permis de l'ordinateur avec 3-1 frappe votre blot (13/10* 10/9), avec le +videau chez vous pour qu'il ne double pas. Ses étapes vérifient les plateaux +case par case, le pion frappé qui part sur la barre et un second appel +identique (« revoir »). + +**Tests** : +- `app/tests/move-steps.test.ts` (vrai moteur) : coup simple, pion frappé, + coup depuis la barre qui frappe en rentrant, sortie jusqu'à la fin de + partie, coup de l'ordinateur, aucun coup. +- `app/tests/ui.test.ts` : traces d'un coup simple, d'un pion qui fait + étape, d'une rentrée avec un pion frappé, d'une sortie. +- `tests/browser/play.mjs` : après chaque action, les traces sont là si le + dernier coup est celui de l'ordinateur (autant de flèches que de + demi-coups, au moins un pion entouré et un fantôme, « Revoir » visible), et + absentes sinon. « Revoir » est essayé deux fois. La première fois, des + animations tournent et les flèches disparaissent pendant l'animation, puis + les mêmes traces reviennent ; une capture `coup-adverse` est prise. La + seconde fois, en mouvement réduit, aucune animation ne tourne et les traces + reviennent. +- Résultats : `make lint` sans problème ; `make test` 10/10 ; `make + test-browser` vert sous Chromium (coup de l'ordinateur montré 26 fois) et + Firefox (43 fois), sans violation CSP ni erreur dans la page. + +**Limites** : +- Aucune des deux parties du navigateur n'a vu l'ordinateur frapper (0 pion + frappé signalé). L'animation vers la barre n'est donc vérifiée que par le + scénario et les tests unitaires (plateaux et traces), pas dans un + navigateur. +- Firefox a affiché un tour de l'ordinateur à 4,4 s au plus. Ce temps est + mesuré avant l'animation, qui n'y est pour rien : c'est le moteur sous + Firefox sur le PC de 2011. Chromium : 0,6 s au plus. +- Sur une case de plus de cinq pions, c'est le cinquième pion dessiné qui + glisse (le nombre reste affiché sur la pile). From 39c85172b44d25b5d916dc055f6b6821747a6452 Mon Sep 17 00:00:00 2001 From: Pierre MARTIN Date: Tue, 3 Nov 2026 02:07:27 +0100 Subject: [PATCH 4/7] =?UTF-8?q?Aucun=20coup=20possible=20:=20le=20joueur?= =?UTF-8?q?=20continue=20lui-m=C3=AAme?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le tour du joueur ne passe plus tout seul quand son jet ne permet aucun coup : le domaine le dit (cannotMove) et le joueur laisse passer son tour (skipTurn, bouton « Continuer »). Les dés d'un jet sans coup, du joueur comme de l'ordinateur, restent affichés barrés avec un message jusqu'à l'action suivante. Scénario « Aucun coup possible avec votre jet ». Co-Authored-By: Claude Opus 5.5 --- app/src/domain/game/game.ts | 31 +++++++--- app/src/ui/app.ts | 74 ++++++++++++++++++----- app/src/ui/board-view.ts | 10 ++- app/src/ui/texts.ts | 5 +- app/tests/full-game.test.ts | 7 ++- app/tests/game.test.ts | 23 ++++++- app/tests/steps/jouer-une-partie.steps.ts | 38 ++++++++++++ docs/scenarios/jouer-une-partie.feature | 12 ++++ tests/browser/play.mjs | 28 ++++++++- web/app.css | 9 +++ web/index.html | 1 + 11 files changed, 209 insertions(+), 29 deletions(-) diff --git a/app/src/domain/game/game.ts b/app/src/domain/game/game.ts index 08e41f4..f6bb4f7 100644 --- a/app/src/domain/game/game.ts +++ b/app/src/domain/game/game.ts @@ -244,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 { - 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 { + 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 { + this.#expect("player", "moving"); + if (!(await this.cannotMove())) { + throw new Error("vous avez un coup possible : jouez-le"); + } + await this.#playMove("player", [], null); } /** diff --git a/app/src/ui/app.ts b/app/src/ui/app.ts index a2b1dca..9c2e8de 100644 --- a/app/src/ui/app.ts +++ b/app/src/ui/app.ts @@ -52,6 +52,7 @@ export function startApp(options: AppOptions): void { take: byId("take"), drop: byId("drop"), cancel: byId("cancel"), + skip: byId("skip"), replay: byId("replay"), again: byId("again"), newGame: byId("new-game"), @@ -67,6 +68,7 @@ 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; @@ -80,6 +82,8 @@ export function startApp(options: AppOptions): void { let targets = new Map>(); 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 ») @@ -98,6 +102,7 @@ export function startApp(options: AppOptions): void { 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; } @@ -117,7 +122,7 @@ export function startApp(options: AppOptions): void { const model = boardModel(sides); if (!animating) { renderBoard(ui.board, model, { - dice: shownDice(game), + dice: shownDice(game, stuck), cube: state.cube, movable: new Set(targets.keys()), selected, @@ -129,22 +134,41 @@ export function startApp(options: AppOptions): void { }); } 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"); show(ui.replay, shownMove !== null); - for (const button of [ui.roll, ui.take, ui.drop, ui.replay, ui.again, ui.newGame]) { + 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 @@ -192,7 +216,7 @@ export function startApp(options: AppOptions): void { if (game === null || window.matchMedia("(prefers-reduced-motion: reduce)").matches) { return; } - const decor = { dice: shownDice(game), cube: game.state.cube, ...NO_MARKS }; + const decor = { dice: shownDice(game, false), cube: game.state.cube, ...NO_MARKS }; const draw = (i: number) => { const boards = steps.boards[i]; if (boards !== undefined) { @@ -267,10 +291,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 = () => @@ -359,7 +386,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.replay, 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; @@ -403,15 +439,25 @@ 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 })); +} + +/** 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 diff --git a/app/src/ui/board-view.ts b/app/src/ui/board-view.ts index 422d62e..e742699 100644 --- a/app/src/ui/board-view.ts +++ b/app/src/ui/board-view.ts @@ -17,6 +17,8 @@ import { type BoardModel, type MoveTrace, type Place, pointSlot } from "./board- 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. */ @@ -470,7 +472,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", { @@ -480,6 +485,9 @@ 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 }); + } }); } diff --git a/app/src/ui/texts.ts b/app/src/ui/texts.ts index d785aee..fa01cb4 100644 --- a/app/src/ui/texts.ts +++ b/app/src/ui/texts.ts @@ -37,6 +37,7 @@ export const TEXTS = { take: "Prendre", drop: "Passer", cancel: "Annuler", + skip: "Continuer", replay: "Revoir", replayTitle: "Revoir le dernier coup de l'ordinateur", newGame: "Nouvelle partie", @@ -59,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)}).`, diff --git a/app/tests/full-game.test.ts b/app/tests/full-game.test.ts index 23a4c69..627e979 100644 --- a/app/tests/full-game.test.ts +++ b/app/tests/full-game.test.ts @@ -36,7 +36,12 @@ async function playToTheEnd(game: Game, engine: Awaited { +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 }), diff --git a/app/tests/steps/jouer-une-partie.steps.ts b/app/tests/steps/jouer-une-partie.steps.ts index 181430f..0cb5b91 100644 --- a/app/tests/steps/jouer-une-partie.steps.ts +++ b/app/tests/steps/jouer-une-partie.steps.ts @@ -33,6 +33,11 @@ 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 @@ -193,6 +198,39 @@ 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", () => { diff --git a/docs/scenarios/jouer-une-partie.feature b/docs/scenarios/jouer-une-partie.feature index 8e32874..5e5e2d1 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: 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 diff --git a/tests/browser/play.mjs b/tests/browser/play.mjs index daf72dd..1f1f03a 100644 --- a/tests/browser/play.mjs +++ b/tests/browser/play.mjs @@ -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; } @@ -153,8 +153,18 @@ async function shownTraces() { /** 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 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)}`); @@ -230,6 +240,7 @@ let reloaded = false; let computerMs = []; let tracesSeen = 0; let replays = 0; +let skips = 0; for (let actions = 0; ; actions++) { check(actions < 800, "la partie ne finit pas"); const todo = await waitForPlayer(); @@ -261,7 +272,17 @@ 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"); @@ -314,6 +335,7 @@ check(countMoves(journal, "player") >= 3 && countMoves(journal, "computer") >= 3 check(reloaded, "la reprise après rechargement n'a pas été essayée"); check(replays === 2, "le coup de l'ordinateur n'a pas été revu"); 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`); diff --git a/web/app.css b/web/app.css index 25e032d..cc01530 100644 --- a/web/app.css +++ b/web/app.css @@ -259,6 +259,15 @@ button:disabled { .pip-player { fill: var(--pip-player); } .pip-computer { fill: var(--pip-computer); } +/* 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 { diff --git a/web/index.html b/web/index.html index e09253b..70734e6 100644 --- a/web/index.html +++ b/web/index.html @@ -30,6 +30,7 @@ + From 984d6cf0605f310b4b015509fba5444ffaceb3fc Mon Sep 17 00:00:00 2001 From: Pierre MARTIN Date: Tue, 3 Nov 2026 02:11:08 +0100 Subject: [PATCH 5/7] =?UTF-8?q?Toucher=20les=20d=C3=A9s=20pour=20les=20lan?= =?UTF-8?q?cer,=20le=20videau=20pour=20doubler?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Quand vous pouvez lancer, vos dés en attente apparaissent dans la moitié gauche du plateau ; quand vous pouvez doubler, le videau est souligné. Les toucher fait comme les boutons, qui restent. Cibles d'au moins 44 px, avec rôle et nom accessibles. Co-Authored-By: Claude Opus 5.5 --- app/src/ui/app.ts | 14 +++++++ app/src/ui/board-view.ts | 83 +++++++++++++++++++++++++++++++++++++--- tests/browser/play.mjs | 28 ++++++++++++-- web/app.css | 13 +++++++ 4 files changed, 129 insertions(+), 9 deletions(-) diff --git a/app/src/ui/app.ts b/app/src/ui/app.ts index 9c2e8de..21c535a 100644 --- a/app/src/ui/app.ts +++ b/app/src/ui/app.ts @@ -131,6 +131,10 @@ export function startApp(options: AppOptions): void { shownMove === null ? null : moveTrace(shownMove.side, shownMove.halfMoves, shownMove.hits), + // Mêmes conditions que les boutons « Lancer les dés » et + // « Doubler ». + canRoll: !busy && state.turn === "player" && state.phase === "rolling", + canDouble: !busy && canDouble, }); } ui.pips.textContent = TEXTS.pipCounts(model.pipCount.player, model.pipCount.computer); @@ -343,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; diff --git a/app/src/ui/board-view.ts b/app/src/ui/board-view.ts index e742699..4b58493 100644 --- a/app/src/ui/board-view.ts +++ b/app/src/ui/board-view.ts @@ -12,6 +12,7 @@ import type { Cube } from "../domain/game/cube.js"; import type { Side } from "../domain/game/side.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 { @@ -32,6 +33,10 @@ export interface BoardDecor { readonly targets: ReadonlySet; /** Les traces du dernier coup de l'ordinateur, tant qu'on les montre. */ readonly trace: MoveTrace | null; + /** Le joueur peut lancer les dés : ses dés en attente se touchent. */ + readonly canRoll: boolean; + /** 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. */ @@ -40,8 +45,14 @@ export const NO_MARKS = { selected: null, targets: new Set(), trace: null, + canRoll: false, + canDouble: false, } as const; +/** Taille minimale d'une cible à toucher, en unités du dessin : 44 px quand + * le plateau est le plus petit (640 unités sur 360 px de large). */ +const MIN_TARGET = 80; + // Géométrie, en unités du viewBox (le SVG s'adapte à la largeur de l'écran). const WIDTH = 640; const HEIGHT = 520; @@ -142,10 +153,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); } @@ -493,18 +512,70 @@ function drawDice(svg: SVGSVGElement, dice: readonly ShownDie[]): void { /** 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 diff --git a/tests/browser/play.mjs b/tests/browser/play.mjs index 1f1f03a..4ef20f5 100644 --- a/tests/browser/play.mjs +++ b/tests/browser/play.mjs @@ -175,6 +175,20 @@ async function checkTraces() { return traces; } +/** La zone à toucher d'une action du plateau (dés ou videau) : un bouton + * accessible, d'au moins 44 px de côté. Renvoie son sélecteur. */ +async function touchTarget(action, label) { + const selector = `[data-action="${action}"]`; + const zone = page.locator(selector); + check((await zone.count()) === 1, `zone ${action} absente du plateau`); + check((await zone.getAttribute("role")) === "button" && + (await zone.getAttribute("aria-label")) === label, `zone ${action} sans rôle accessible`); + const box = await zone.boundingBox(); + check(box.width >= 44 && box.height >= 44, + `zone ${action} trop petite : ${Math.round(box.width)} x ${Math.round(box.height)} px`); + return selector; +} + /** 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) { @@ -241,6 +255,7 @@ 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(); @@ -289,15 +304,20 @@ for (let actions = 0; ; actions++) { } 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) { @@ -334,6 +354,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"); +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) { diff --git a/web/app.css b/web/app.css index cc01530..dc8ad38 100644 --- a/web/app.css +++ b/web/app.css @@ -259,6 +259,19 @@ button:disabled { .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; } From 9b79b54880599ce09eeb1a4b850eaf7ab6c74ab4 Mon Sep 17 00:00:00 2001 From: Pierre MARTIN Date: Tue, 3 Nov 2026 02:12:56 +0100 Subject: [PATCH 6/7] =?UTF-8?q?D=C3=A9s=20et=20videau=20:=20cibles=20de=20?= =?UTF-8?q?44=20px=20sur=20le=20plateau=20de=20344=20px?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- app/src/ui/board-view.ts | 7 +++--- docs/plans/2026-11-03-coup-adverse.md | 36 +++++++++++++++++++++++++++ 2 files changed, 40 insertions(+), 3 deletions(-) diff --git a/app/src/ui/board-view.ts b/app/src/ui/board-view.ts index 4b58493..c6ad52e 100644 --- a/app/src/ui/board-view.ts +++ b/app/src/ui/board-view.ts @@ -49,9 +49,10 @@ export const NO_MARKS = { canDouble: false, } as const; -/** Taille minimale d'une cible à toucher, en unités du dessin : 44 px quand - * le plateau est le plus petit (640 unités sur 360 px de large). */ -const MIN_TARGET = 80; +/** 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; diff --git a/docs/plans/2026-11-03-coup-adverse.md b/docs/plans/2026-11-03-coup-adverse.md index 4ec4d36..9ced53d 100644 --- a/docs/plans/2026-11-03-coup-adverse.md +++ b/docs/plans/2026-11-03-coup-adverse.md @@ -64,6 +64,42 @@ Contraintes : Rapport attendu : ce qui a été fait, captures d'écran (chemins), résultat des tests, difficultés, écarts au plan. +## Ajout : aucun coup possible + +Demandé par Pierre en cours de tâche (inspiré de l'application CBG). + +1. Quand le jet du joueur ne permet aucun coup, le tour ne passe plus tout + seul à l'ordinateur. Les dés restent affichés, estompés ou barrés, avec un + message clair (« Aucun coup possible avec 6-6. ») et un bouton + « Continuer ». Le tour passe à l'ordinateur seulement quand le joueur le + touche. +2. Quand l'ordinateur ne peut pas jouer son jet, ses dés et un message + (« L'ordinateur ne peut pas jouer 5-5. ») restent visibles jusqu'à la + prochaine action du joueur, comme les traces d'un coup. +3. Le domaine reste la seule source, sans règle réimplémentée ; textes dans + `texts.ts`. +4. Scénario « Aucun coup possible avec votre jet » avec ses étapes sur le + domaine et des dés imposés. Dans `play.mjs`, si le cas survient, vérifier + que « Continuer » est demandé, sans forcer de cas artificiel. + +Les changements CSS se limitent aux nouvelles classes : un autre agent +travaille sur la charte. + +## Ajout : toucher les dés et le videau + +Demandé par Pierre en cours de tâche. + +1. Toucher les dés lance les dés quand c'est au joueur de lancer (même effet + que « Lancer les dés »). Sans dés à lancer affichés, la zone des dés du + joueur doit être touchable (dés « en attente »). +2. Toucher le videau propose de doubler quand le joueur en a le droit (mêmes + conditions, venues du domaine). Le reste du temps, toucher dés ou videau ne + fait rien. +3. Les boutons restent. Dés et videau touchables : cible d'au moins 44 px, + retour visuel discret, `role="button"` et `aria-label`. +4. Tests : dans `play.mjs`, lancer au moins une fois en touchant les dés et + doubler au moins une fois en touchant le videau. + ## Critères de réussite - Dans Chromium et Firefox sans interface, après chaque coup de l'ordinateur : From a08c997ac17c541a6c4d85601e1e3cb2a1efa1e5 Mon Sep 17 00:00:00 2001 From: Pierre MARTIN Date: Tue, 3 Nov 2026 04:57:34 +0100 Subject: [PATCH 7/7] =?UTF-8?q?Plan=20coup=20adverse=20:=20r=C3=A9sultat?= =?UTF-8?q?=20des=20deux=20ajouts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- docs/plans/2026-11-03-coup-adverse.md | 57 ++++++++++++++++++++++----- 1 file changed, 48 insertions(+), 9 deletions(-) diff --git a/docs/plans/2026-11-03-coup-adverse.md b/docs/plans/2026-11-03-coup-adverse.md index 9ced53d..395997b 100644 --- a/docs/plans/2026-11-03-coup-adverse.md +++ b/docs/plans/2026-11-03-coup-adverse.md @@ -165,17 +165,56 @@ identique (« revoir »). les mêmes traces reviennent ; une capture `coup-adverse` est prise. La seconde fois, en mouvement réduit, aucune animation ne tourne et les traces reviennent. -- Résultats : `make lint` sans problème ; `make test` 10/10 ; `make - test-browser` vert sous Chromium (coup de l'ordinateur montré 26 fois) et - Firefox (43 fois), sans violation CSP ni erreur dans la page. +**Ajout : aucun coup possible** : +- Domaine : `Game.roll()` ne fait plus passer le tour. `Game.cannotMove()` + dit, d'après `legalMoves` du moteur, que le jet ne permet aucun coup. + `Game.skipTurn()` enregistre alors le coup vide (même événement + `MovePlayed` sans demi-coup qu'avant, format inchangé) ; il est refusé s'il + existe un coup. Le nom `skipTurn` évite la confusion avec « passer » (le + double), qui est `drop` dans le glossaire. +- Interface : bouton « Continuer » (`#skip`), message « Aucun coup possible + avec 6-5. », consigne « Touchez « Continuer » : le tour passe à + l'ordinateur. », dés estompés et barrés (`.die-blocked`, `.die-strike`). + Quand l'ordinateur ne peut pas jouer, ses dés restent barrés avec « L'ordinateur + ne peut pas jouer 5-5. » jusqu'à l'action suivante. Ces deux messages se + déduisent du journal : ils survivent à un rechargement. +- Tests : scénario « Aucun coup possible avec votre jet » (pion sur la barre + face aux six cases tenues, 6-5), tests unitaires `cannotMove`/`skipTurn` + (faux moteur), partie complète adaptée. Dans le navigateur, « Continuer » + a été demandé 1 fois (Chromium) et 3 fois (Firefox) : message et deux dés + barrés vérifiés à chaque fois. Le jet sans coup de l'ordinateur est vérifié + quand il survient (pas survenu dans ces deux parties). + +**Ajout : toucher les dés et le videau** : +- Quand vous pouvez lancer, deux dés « en attente » (faces vides, contour en + pointillés) apparaissent au milieu de la moitié gauche. La moitié droite + garde le dernier jet de l'ordinateur, qui doit rester visible. Quand vous + pouvez doubler (`Game.canDouble()`, comme le bouton), le videau est souligné. +- Les toucher clique le bouton correspondant (`#roll`, `#double`) : mêmes + conditions, même effet. Hors de ces moments, aucune zone n'est posée. +- Zones à toucher : `role="button"`, `aria-label` (« Lancer les dés », + « Doubler »), au moins 88 unités, soit 47 px sur le plateau de 344 px d'un + téléphone de 360 px. Les boutons restent pour le clavier ; les zones n'ont + pas de `tabindex`, pour ne pas doubler l'ordre de tabulation. +- Navigateur : dés touchés 10 fois (Chromium) et 14 fois (Firefox), videau + touché pour doubler, taille et rôle vérifiés ; aucune zone pendant la + saisie d'un coup. + +**Résultats finaux** (dernier commit vérifié) : `make lint` sans problème ; +`make test` 10/10 ; `make test-browser` vert sous Chromium et Firefox, sans +violation CSP ni erreur dans la page. Le coup de l'ordinateur a été montré 21 +et 30 fois. Dans Firefox, un coup revu a frappé deux pions, tous deux signalés sur la +barre. **Limites** : -- Aucune des deux parties du navigateur n'a vu l'ordinateur frapper (0 pion - frappé signalé). L'animation vers la barre n'est donc vérifiée que par le - scénario et les tests unitaires (plateaux et traces), pas dans un - navigateur. -- Firefox a affiché un tour de l'ordinateur à 4,4 s au plus. Ce temps est - mesuré avant l'animation, qui n'y est pour rien : c'est le moteur sous +- Un premier essai de `make test-browser` a échoué dans `docker build` + (« parent snapshot … does not exist »), sans lien avec le code + (probablement une autre construction Docker en parallèle). L'essai suivant + est passé. +- Firefox affiche des tours de l'ordinateur de 3 à 4,4 s au plus. Ce temps + est mesuré avant l'animation, qui n'y est pour rien : c'est le moteur sous Firefox sur le PC de 2011. Chromium : 0,6 s au plus. +- Les dés en attente sont à gauche alors que le jet lancé s'affiche à + droite : c'est un choix d'interface, à revoir avec la charte si besoin. - Sur une case de plus de cinq pions, c'est le cinquième pion dessiné qui glisse (le nombre reste affiché sur la pile).