Compare commits
27
Commits
@@ -1,7 +1,7 @@
|
||||
# CLAUDE.md — Règles du projet
|
||||
|
||||
Ce fichier est lu par Claude Code au début de chaque session. Il fixe le cadre du projet.
|
||||
Le cadrage complet est dans `docs/cadrage.md`, l'étape en cours dans `docs/etape-0.md`.
|
||||
Le cadrage complet est dans `docs/cadrage.md`, le travail en cours dans `docs/feuille-de-route.md`.
|
||||
|
||||
**En début de session**, lire `docs/organisation.md`, `docs/feuille-de-route.md`, la fin
|
||||
de `docs/journal.md`, l'index des ADR (`docs/adr/README.md`) et les plans en cours dans `docs/plans/`, puis reprendre là où en
|
||||
|
||||
+84
-14
@@ -47,6 +47,20 @@ export interface SideBoards {
|
||||
readonly computer: BoardSide;
|
||||
}
|
||||
|
||||
/**
|
||||
* Un coup joué, demi-coup par demi-coup, pour le montrer : les plateaux
|
||||
* successifs, tels que le moteur les donne.
|
||||
*/
|
||||
export interface MoveSteps {
|
||||
readonly side: Side;
|
||||
readonly halfMoves: readonly HalfMove[];
|
||||
/** boards[0] : avant le coup ; boards[i + 1] : après le demi-coup i. */
|
||||
readonly boards: readonly SideBoards[];
|
||||
/** hits[i] : le demi-coup i a frappé un pion adverse, envoyé sur la barre
|
||||
* (lu sur les plateaux du moteur, pas déduit des règles). */
|
||||
readonly hits: readonly boolean[];
|
||||
}
|
||||
|
||||
export interface GameOptions {
|
||||
/** Source des dés ; le hasard ordinaire par défaut. */
|
||||
readonly dice?: DiceSource;
|
||||
@@ -141,14 +155,45 @@ export class Game {
|
||||
const state = this.state;
|
||||
// La position est vue du camp qui a la main (le joueur pendant le jet
|
||||
// d'ouverture : la position de départ est la même pour les deux).
|
||||
const onRoll = state.turn ?? "player";
|
||||
const board = await this.#engine.board(
|
||||
this.displayedPosition,
|
||||
engineCube(state.cube, onRoll),
|
||||
return this.#sideBoards(this.displayedPosition, state.turn ?? "player", state.cube);
|
||||
}
|
||||
|
||||
/**
|
||||
* Le dernier coup joué de la partie, demi-coup par demi-coup (null s'il
|
||||
* n'y en a pas). Les plateaux intermédiaires viennent du moteur
|
||||
* (checkHalfMoves, puis board) : aucune règle n'est rejouée ici.
|
||||
*/
|
||||
async lastMoveSteps(): Promise<MoveSteps | null> {
|
||||
const events = this.events;
|
||||
const index = events.findLastIndex((e) => e.type === "MovePlayed");
|
||||
const played = events[index];
|
||||
if (played?.type !== "MovePlayed") {
|
||||
return null;
|
||||
}
|
||||
// L'état juste avant le coup : la position vue de celui qui joue, et
|
||||
// son jet.
|
||||
const before = replay(events.slice(0, index));
|
||||
const roll = rollToPlay(before);
|
||||
const positions = [before.position];
|
||||
for (let i = 1; i <= played.halfMoves.length; i++) {
|
||||
const check = await this.#engine.checkHalfMoves(
|
||||
before.position,
|
||||
roll,
|
||||
played.halfMoves.slice(0, i),
|
||||
);
|
||||
if (check.position === null) {
|
||||
throw new Error("coup enregistré refusé par le moteur");
|
||||
}
|
||||
positions.push(check.position);
|
||||
}
|
||||
const boards = await Promise.all(
|
||||
positions.map((p) => this.#sideBoards(p, played.side, before.cube)),
|
||||
);
|
||||
return onRoll === "player"
|
||||
? { player: board.onRoll, computer: board.opponent }
|
||||
: { player: board.opponent, computer: board.onRoll };
|
||||
const opponent = otherSide(played.side);
|
||||
const hits = played.halfMoves.map(
|
||||
(_, i) => (boards[i + 1]?.[opponent].bar ?? 0) > (boards[i]?.[opponent].bar ?? 0),
|
||||
);
|
||||
return { side: played.side, halfMoves: played.halfMoves, boards, hits };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -199,15 +244,32 @@ export class Game {
|
||||
|
||||
// ------------------------------------------------------------- Joueur
|
||||
|
||||
/** Le joueur lance les dés. S'il ne peut pas jouer, son tour passe. */
|
||||
/** Le joueur lance les dés. S'il ne peut pas jouer (cannotMove), son
|
||||
* tour ne passe qu'une fois qu'il l'a constaté (skipTurn). */
|
||||
async roll(): Promise<void> {
|
||||
const state = this.#expect("player", "rolling");
|
||||
const roll = rollDice(this.#dice);
|
||||
this.#append({ type: "DiceRolled", side: "player", roll });
|
||||
const moves = await this.#engine.legalMoves(state.position, roll);
|
||||
if (moves.length === 0) {
|
||||
await this.#playMove("player", [], null);
|
||||
this.#expect("player", "rolling");
|
||||
this.#append({ type: "DiceRolled", side: "player", roll: rollDice(this.#dice) });
|
||||
}
|
||||
|
||||
/** Le joueur a lancé les dés, mais le moteur ne lui donne aucun coup
|
||||
* légal : il ne peut que laisser passer son tour. */
|
||||
async cannotMove(): Promise<boolean> {
|
||||
const state = this.state;
|
||||
if (state.turn !== "player" || state.phase !== "moving") {
|
||||
return false;
|
||||
}
|
||||
const moves = await this.#engine.legalMoves(state.position, rollToPlay(state));
|
||||
return moves.length === 0;
|
||||
}
|
||||
|
||||
/** Le joueur, qui ne peut pas jouer son jet, laisse passer son tour.
|
||||
* Erreur s'il a un coup possible. */
|
||||
async skipTurn(): Promise<void> {
|
||||
this.#expect("player", "moving");
|
||||
if (!(await this.cannotMove())) {
|
||||
throw new Error("vous avez un coup possible : jouez-le");
|
||||
}
|
||||
await this.#playMove("player", [], null);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -325,6 +387,14 @@ export class Game {
|
||||
this.#journal = appendEvent(this.#journal, event);
|
||||
}
|
||||
|
||||
/** Le plateau d'une position vue de onRoll, décodé par le moteur. */
|
||||
async #sideBoards(position: PositionId, onRoll: Side, cube: Cube): Promise<SideBoards> {
|
||||
const board = await this.#engine.board(position, engineCube(cube, onRoll));
|
||||
return onRoll === "player"
|
||||
? { player: board.onRoll, computer: board.opponent }
|
||||
: { player: board.opponent, computer: board.onRoll };
|
||||
}
|
||||
|
||||
/** Vérifie que c'est bien à side d'agir, dans l'une des phases. */
|
||||
#expect(side: Side, ...phases: GameState["phase"][]): GameState {
|
||||
const state = this.state;
|
||||
|
||||
+198
-45
@@ -10,14 +10,17 @@
|
||||
// son tour ; le plateau est ensuite redessiné à partir de l'état de la partie.
|
||||
|
||||
import type { GameJournal } from "../domain/game/events.js";
|
||||
import type { Game, SideBoards } from "../domain/game/game.js";
|
||||
import type { Game, MoveSteps, SideBoards } from "../domain/game/game.js";
|
||||
import { formatHalfMoves, type HalfMove } from "../domain/game/position.js";
|
||||
import type { Side } from "../domain/game/side.js";
|
||||
import { boardModel, moveTargets, type Place, placeOf } from "./board-model.js";
|
||||
import { renderBoard, type ShownDie } from "./board-view.js";
|
||||
import { otherSide, type Side } from "../domain/game/side.js";
|
||||
import { boardModel, moveTargets, moveTrace, type Place, placeOf } from "./board-model.js";
|
||||
import { checkerSpot, NO_MARKS, renderBoard, type ShownDie, slideChecker } from "./board-view.js";
|
||||
import { showHelp } from "./help.js";
|
||||
import { TEXTS } from "./texts.js";
|
||||
|
||||
/** Durée du glissement d'un pion, pour chaque demi-coup de l'ordinateur. */
|
||||
const SLIDE_MS = 400;
|
||||
|
||||
export interface AppOptions {
|
||||
/** Résolue quand le moteur est chargé, rejetée s'il ne peut pas l'être. */
|
||||
readonly engineReady: Promise<unknown>;
|
||||
@@ -49,6 +52,8 @@ export function startApp(options: AppOptions): void {
|
||||
take: byId<HTMLButtonElement>("take"),
|
||||
drop: byId<HTMLButtonElement>("drop"),
|
||||
cancel: byId<HTMLButtonElement>("cancel"),
|
||||
skip: byId<HTMLButtonElement>("skip"),
|
||||
replay: byId<HTMLButtonElement>("replay"),
|
||||
again: byId<HTMLButtonElement>("again"),
|
||||
newGame: byId<HTMLButtonElement>("new-game"),
|
||||
helpButton: byId<HTMLButtonElement>("help-button"),
|
||||
@@ -63,6 +68,9 @@ export function startApp(options: AppOptions): void {
|
||||
ui.take.textContent = TEXTS.take;
|
||||
ui.drop.textContent = TEXTS.drop;
|
||||
ui.cancel.textContent = TEXTS.cancel;
|
||||
ui.skip.textContent = TEXTS.skip;
|
||||
ui.replay.textContent = TEXTS.replay;
|
||||
ui.replay.title = TEXTS.replayTitle;
|
||||
ui.again.textContent = TEXTS.newGame;
|
||||
ui.newGame.textContent = TEXTS.newGame;
|
||||
ui.helpButton.textContent = TEXTS.help;
|
||||
@@ -74,22 +82,35 @@ export function startApp(options: AppOptions): void {
|
||||
let targets = new Map<Place, Map<Place, HalfMove>>();
|
||||
let selected: Place | null = null;
|
||||
let canDouble = false;
|
||||
// Le joueur a lancé les dés mais ne peut pas jouer : il doit continuer.
|
||||
let stuck = false;
|
||||
let busy = true;
|
||||
let info = "";
|
||||
// Le dernier coup de l'ordinateur, montré (traces, bouton « Revoir »)
|
||||
// jusqu'à la prochaine action du joueur ; null : rien à montrer.
|
||||
let shownMove: MoveSteps | null = null;
|
||||
// Pendant l'animation d'un coup, render() laisse le plateau à l'animation.
|
||||
let animating = false;
|
||||
|
||||
// ------------------------------------------------------------ Dessin
|
||||
|
||||
/** Recalcule ce qui demande le moteur, puis redessine. */
|
||||
async function refresh(): Promise<void> {
|
||||
/** Recalcule ce qui demande le moteur, sans redessiner. */
|
||||
async function update(): Promise<void> {
|
||||
if (game === null) {
|
||||
return;
|
||||
}
|
||||
sides = await game.displayedBoard();
|
||||
targets = moveTargets(await game.nextHalfMoves());
|
||||
canDouble = await game.canDouble();
|
||||
stuck = await game.cannotMove();
|
||||
if (selected !== null && !targets.has(selected)) {
|
||||
selected = null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Recalcule ce qui demande le moteur, puis redessine. */
|
||||
async function refresh(): Promise<void> {
|
||||
await update();
|
||||
render();
|
||||
}
|
||||
|
||||
@@ -99,30 +120,59 @@ export function startApp(options: AppOptions): void {
|
||||
}
|
||||
const state = game.state;
|
||||
const model = boardModel(sides);
|
||||
renderBoard(ui.board, model, {
|
||||
dice: shownDice(game),
|
||||
cube: state.cube,
|
||||
movable: new Set(targets.keys()),
|
||||
selected,
|
||||
targets: new Set(selected === null ? [] : (targets.get(selected)?.keys() ?? [])),
|
||||
lastMove: lastComputerMove(game),
|
||||
});
|
||||
if (!animating) {
|
||||
renderBoard(ui.board, model, {
|
||||
dice: shownDice(game, stuck),
|
||||
cube: state.cube,
|
||||
movable: new Set(targets.keys()),
|
||||
selected,
|
||||
targets: new Set(selected === null ? [] : (targets.get(selected)?.keys() ?? [])),
|
||||
trace:
|
||||
shownMove === null
|
||||
? null
|
||||
: moveTrace(shownMove.side, shownMove.halfMoves, shownMove.hits),
|
||||
// Mêmes conditions que les boutons « Lancer les dés » et
|
||||
// « Doubler ».
|
||||
canRoll: !busy && state.turn === "player" && state.phase === "rolling",
|
||||
canDouble: !busy && canDouble,
|
||||
});
|
||||
}
|
||||
ui.pips.textContent = TEXTS.pipCounts(model.pipCount.player, model.pipCount.computer);
|
||||
ui.info.textContent = info;
|
||||
ui.prompt.textContent = prompt(game, selected !== null, canDouble);
|
||||
// Un jet sans coup possible reste annoncé jusqu'à l'action suivante,
|
||||
// même après un rechargement de la page.
|
||||
if (stuck && state.roll !== null) {
|
||||
ui.info.textContent = TEXTS.youCannotMove(state.roll);
|
||||
} else if (computerCouldNotMove(game)) {
|
||||
ui.info.textContent = TEXTS.computerCannotMove(rollBefore(game));
|
||||
} else {
|
||||
ui.info.textContent = info;
|
||||
}
|
||||
ui.prompt.textContent = stuck
|
||||
? TEXTS.pressContinue
|
||||
: prompt(game, selected !== null, canDouble);
|
||||
ui.prompt.classList.toggle("thinking", state.turn === "computer" && isPlaying(game));
|
||||
|
||||
const player = state.turn === "player";
|
||||
show(ui.roll, player && state.phase === "rolling");
|
||||
show(ui.double, player && state.phase === "rolling");
|
||||
ui.double.disabled = busy || !canDouble;
|
||||
show(ui.cancel, player && state.phase === "moving");
|
||||
show(ui.cancel, player && state.phase === "moving" && !stuck);
|
||||
show(ui.skip, stuck);
|
||||
ui.cancel.disabled = busy || game.pendingHalfMoves.length === 0;
|
||||
const doubled = state.turn === "computer" && state.phase === "doubled";
|
||||
show(ui.take, doubled);
|
||||
show(ui.drop, doubled);
|
||||
show(ui.again, state.phase === "over");
|
||||
for (const button of [ui.roll, ui.take, ui.drop, ui.again, ui.newGame]) {
|
||||
show(ui.replay, shownMove !== null);
|
||||
for (const button of [
|
||||
ui.roll,
|
||||
ui.take,
|
||||
ui.drop,
|
||||
ui.skip,
|
||||
ui.replay,
|
||||
ui.again,
|
||||
ui.newGame,
|
||||
]) {
|
||||
button.disabled = busy;
|
||||
}
|
||||
// Pour le style et pour les tests dans un navigateur : l'écran
|
||||
@@ -142,14 +192,73 @@ export function startApp(options: AppOptions): void {
|
||||
}
|
||||
}
|
||||
|
||||
/** L'ordinateur joue tant que c'est son tour (il s'arrête s'il double). */
|
||||
async function computerTurns(): Promise<void> {
|
||||
/** L'ordinateur joue tant que c'est son tour (il s'arrête s'il double).
|
||||
* Son dernier coup, s'il est la dernière chose jouée, devient le coup à
|
||||
* montrer. Renvoie vrai si l'ordinateur a joué. */
|
||||
async function computerTurns(): Promise<boolean> {
|
||||
let played = false;
|
||||
while (game !== null && game.state.turn === "computer" && isPlaying(game)) {
|
||||
await refresh();
|
||||
const start = performance.now();
|
||||
await game.playComputerTurn();
|
||||
info = describeComputerTurn(game, performance.now() - start);
|
||||
save();
|
||||
played = true;
|
||||
}
|
||||
if (game !== null) {
|
||||
shownMove = await computerMoveToShow(game);
|
||||
}
|
||||
return played;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fait glisser les pions du coup de l'ordinateur, un demi-coup après
|
||||
* l'autre, à partir des plateaux intermédiaires donnés par le domaine. Rien
|
||||
* si le système demande moins d'animations : les traces suffisent.
|
||||
*/
|
||||
async function animateMove(steps: MoveSteps): Promise<void> {
|
||||
if (game === null || window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
|
||||
return;
|
||||
}
|
||||
const decor = { dice: shownDice(game, false), cube: game.state.cube, ...NO_MARKS };
|
||||
const draw = (i: number) => {
|
||||
const boards = steps.boards[i];
|
||||
if (boards !== undefined) {
|
||||
renderBoard(ui.board, boardModel(boards), decor);
|
||||
}
|
||||
};
|
||||
animating = true;
|
||||
render(); // messages et boutons ; le plateau est à l'animation
|
||||
try {
|
||||
for (const [i, halfMove] of steps.halfMoves.entries()) {
|
||||
const from = placeOf(steps.side, halfMove.from);
|
||||
const to = placeOf(steps.side, halfMove.to);
|
||||
draw(i);
|
||||
const start = checkerSpot(ui.board, from);
|
||||
const victim = steps.hits[i] === true ? checkerSpot(ui.board, to) : null;
|
||||
draw(i + 1);
|
||||
await Promise.all([
|
||||
start && slideChecker(ui.board, to, start, SLIDE_MS),
|
||||
victim &&
|
||||
slideChecker(ui.board, `bar-${otherSide(steps.side)}`, victim, SLIDE_MS),
|
||||
]);
|
||||
}
|
||||
} finally {
|
||||
animating = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Rend la main au joueur : recalcule, anime le coup à montrer (si
|
||||
* animate), puis redessine avec ses traces. */
|
||||
async function settle(animate: boolean): Promise<void> {
|
||||
try {
|
||||
await update();
|
||||
if (animate && shownMove !== null) {
|
||||
await animateMove(shownMove);
|
||||
}
|
||||
} finally {
|
||||
busy = false;
|
||||
render();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -162,6 +271,7 @@ export function startApp(options: AppOptions): void {
|
||||
busy = true;
|
||||
selected = null;
|
||||
targets = new Map();
|
||||
shownMove = null;
|
||||
render();
|
||||
try {
|
||||
await action(game);
|
||||
@@ -170,8 +280,7 @@ export function startApp(options: AppOptions): void {
|
||||
} catch (error) {
|
||||
info = TEXTS.actionFailed(error instanceof Error ? error.message : String(error));
|
||||
} finally {
|
||||
busy = false;
|
||||
await refresh();
|
||||
await settle(true);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -186,10 +295,13 @@ export function startApp(options: AppOptions): void {
|
||||
act(async (g) => {
|
||||
info = "";
|
||||
await g.roll();
|
||||
const last = g.events.at(-1);
|
||||
if (last?.type === "MovePlayed" && last.halfMoves.length === 0) {
|
||||
info = TEXTS.youCannotMove(rollBefore(g));
|
||||
}
|
||||
});
|
||||
|
||||
// Aucun coup possible : le joueur laisse passer son tour.
|
||||
ui.skip.onclick = () =>
|
||||
act(async (g) => {
|
||||
info = "";
|
||||
await g.skipTurn();
|
||||
});
|
||||
|
||||
ui.double.onclick = () =>
|
||||
@@ -214,6 +326,7 @@ export function startApp(options: AppOptions): void {
|
||||
if (!busy && game !== null) {
|
||||
game.cancelHalfMoves();
|
||||
selected = null;
|
||||
shownMove = null;
|
||||
void refresh();
|
||||
}
|
||||
};
|
||||
@@ -234,6 +347,16 @@ export function startApp(options: AppOptions): void {
|
||||
// Toucher le plateau : un pion jouable, puis une de ses cases d'arrivée.
|
||||
ui.board.addEventListener("click", (event) => {
|
||||
const target = event.target as Element | null;
|
||||
// Toucher les dés en attente ou le videau : comme leurs boutons.
|
||||
const action = target?.closest("[data-action]")?.getAttribute("data-action");
|
||||
if (action === "roll" && !ui.roll.hidden && !ui.roll.disabled) {
|
||||
ui.roll.click();
|
||||
return;
|
||||
}
|
||||
if (action === "double" && !ui.double.hidden && !ui.double.disabled) {
|
||||
ui.double.click();
|
||||
return;
|
||||
}
|
||||
const place = target?.closest("[data-place]")?.getAttribute("data-place") as Place | null;
|
||||
if (busy || place == null) {
|
||||
return;
|
||||
@@ -247,9 +370,22 @@ export function startApp(options: AppOptions): void {
|
||||
return;
|
||||
}
|
||||
selected = targets.has(place) && place !== selected ? place : null;
|
||||
if (selected !== null) {
|
||||
shownMove = null; // toucher un pion : le coup de l'ordinateur s'efface
|
||||
}
|
||||
render();
|
||||
});
|
||||
|
||||
// Revoir le dernier coup de l'ordinateur, puis ses traces.
|
||||
ui.replay.onclick = async () => {
|
||||
if (busy || shownMove === null) {
|
||||
return;
|
||||
}
|
||||
busy = true;
|
||||
selected = null;
|
||||
await settle(true);
|
||||
};
|
||||
|
||||
ui.helpButton.onclick = () => {
|
||||
ui.game.hidden = true;
|
||||
ui.help.hidden = false;
|
||||
@@ -264,7 +400,16 @@ export function startApp(options: AppOptions): void {
|
||||
// ---------------------------------------------------------- Démarrage
|
||||
|
||||
ui.prompt.textContent = TEXTS.loading;
|
||||
for (const button of [ui.roll, ui.double, ui.take, ui.drop, ui.cancel, ui.again]) {
|
||||
for (const button of [
|
||||
ui.roll,
|
||||
ui.double,
|
||||
ui.take,
|
||||
ui.drop,
|
||||
ui.cancel,
|
||||
ui.skip,
|
||||
ui.replay,
|
||||
ui.again,
|
||||
]) {
|
||||
button.hidden = true;
|
||||
}
|
||||
ui.newGame.disabled = true;
|
||||
@@ -276,12 +421,13 @@ export function startApp(options: AppOptions): void {
|
||||
save();
|
||||
}
|
||||
// Comme après une action du joueur : l'ordinateur joue si c'est
|
||||
// à lui (partie nouvelle ou reprise pendant son tour).
|
||||
// à lui (partie nouvelle ou reprise pendant son tour). Une partie
|
||||
// reprise juste après son coup le montre, sans l'animer.
|
||||
let played = false;
|
||||
try {
|
||||
await computerTurns();
|
||||
played = await computerTurns();
|
||||
} finally {
|
||||
busy = false;
|
||||
await refresh();
|
||||
await settle(played);
|
||||
}
|
||||
},
|
||||
(error: unknown) => {
|
||||
@@ -307,29 +453,36 @@ function rollBefore(game: Game): readonly number[] {
|
||||
}
|
||||
|
||||
/** Les dés à montrer : le dernier jet, de la couleur de qui l'a lancé
|
||||
* (au jet d'ouverture, un dé chacun). */
|
||||
function shownDice(game: Game): ShownDie[] {
|
||||
* (au jet d'ouverture, un dé chacun). Ils sont barrés si le jet ne permet
|
||||
* aucun coup : le joueur est bloqué (stuck), ou l'ordinateur vient de passer
|
||||
* son tour sans bouger. */
|
||||
function shownDice(game: Game, stuck: boolean): ShownDie[] {
|
||||
const rolled = game.events.findLast((e) => e.type === "DiceRolled");
|
||||
if (rolled?.type !== "DiceRolled") {
|
||||
return [];
|
||||
}
|
||||
const blocked = stuck || computerCouldNotMove(game);
|
||||
const sides: Side[] =
|
||||
rolled.side === null ? ["player", "computer"] : [rolled.side, rolled.side];
|
||||
return rolled.roll.map((value, i) => ({ value, side: sides[i] ?? "player" }));
|
||||
return rolled.roll.map((value, i) => ({ value, side: sides[i] ?? "player", blocked }));
|
||||
}
|
||||
|
||||
/** Les cases de départ et d'arrivée du dernier coup, s'il est de
|
||||
* l'ordinateur. */
|
||||
function lastComputerMove(game: Game): Set<Place> {
|
||||
const played = game.events.findLast((e) => e.type === "MovePlayed");
|
||||
const places = new Set<Place>();
|
||||
if (played?.type === "MovePlayed" && played.side === "computer") {
|
||||
for (const h of played.halfMoves) {
|
||||
places.add(placeOf("computer", h.from));
|
||||
places.add(placeOf("computer", h.to));
|
||||
}
|
||||
/** La dernière chose jouée (fin de partie mise à part) est un tour de
|
||||
* l'ordinateur qui n'a pas pu jouer son jet. */
|
||||
function computerCouldNotMove(game: Game): boolean {
|
||||
const last = game.events.findLast((e) => e.type !== "GameEnded");
|
||||
return last?.type === "MovePlayed" && last.side === "computer" && last.halfMoves.length === 0;
|
||||
}
|
||||
|
||||
/** Le dernier coup de l'ordinateur, demi-coup par demi-coup, s'il est la
|
||||
* dernière chose jouée (fin de partie mise à part) et qu'il a déplacé des
|
||||
* pions ; sinon null. */
|
||||
async function computerMoveToShow(game: Game): Promise<MoveSteps | null> {
|
||||
const last = game.events.findLast((e) => e.type !== "GameEnded");
|
||||
if (last?.type !== "MovePlayed" || last.side !== "computer" || last.halfMoves.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return places;
|
||||
return game.lastMoveSteps();
|
||||
}
|
||||
|
||||
/** Ce qu'a fait l'ordinateur pendant son tour. */
|
||||
|
||||
@@ -82,6 +82,50 @@ export function moveTargets(halfMoves: readonly HalfMove[]): Map<Place, Map<Plac
|
||||
return targets;
|
||||
}
|
||||
|
||||
/** Ce qui reste dessiné d'un coup une fois joué. */
|
||||
export interface MoveTrace {
|
||||
/** Le camp qui a joué. */
|
||||
readonly side: Side;
|
||||
/** Une flèche par demi-coup, de sa case de départ à sa case d'arrivée. */
|
||||
readonly arrows: readonly { readonly from: Place; readonly to: Place }[];
|
||||
/** Par endroit, le nombre de pions arrivés et restés là (entourés). */
|
||||
readonly landed: ReadonlyMap<Place, number>;
|
||||
/** Par endroit, le nombre de pions partis (un pion fantôme chacun). */
|
||||
readonly left: ReadonlyMap<Place, number>;
|
||||
/** Le nombre de pions adverses frappés, envoyés sur la barre. */
|
||||
readonly hits: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Les traces d'un coup de side : ses demi-coups, et pour chacun s'il a frappé.
|
||||
* Un pion qui fait étape sur une case (13/7 7/4) n'y laisse ni fantôme ni
|
||||
* cercle : seuls comptent le départ et l'arrivée de chaque pion.
|
||||
*/
|
||||
export function moveTrace(
|
||||
side: Side,
|
||||
halfMoves: readonly HalfMove[],
|
||||
hits: readonly boolean[],
|
||||
): MoveTrace {
|
||||
const balance = new Map<Place, number>();
|
||||
const arrows = halfMoves.map((h) => {
|
||||
const from = placeOf(side, h.from);
|
||||
const to = placeOf(side, h.to);
|
||||
balance.set(from, (balance.get(from) ?? 0) - 1);
|
||||
balance.set(to, (balance.get(to) ?? 0) + 1);
|
||||
return { from, to };
|
||||
});
|
||||
const landed = new Map<Place, number>();
|
||||
const left = new Map<Place, number>();
|
||||
for (const [place, n] of balance) {
|
||||
if (n > 0) {
|
||||
landed.set(place, n);
|
||||
} else if (n < 0) {
|
||||
left.set(place, -n);
|
||||
}
|
||||
}
|
||||
return { side, arrows, landed, left, hits: hits.filter(Boolean).length };
|
||||
}
|
||||
|
||||
/** Où se trouve une case sur le dessin : rangée du haut ou du bas, et
|
||||
* colonne de 0 (à gauche) à 11 (à droite), sans compter la barre. */
|
||||
export function pointSlot(number: number): { top: boolean; column: number } {
|
||||
|
||||
+300
-60
@@ -11,12 +11,15 @@
|
||||
|
||||
import type { Cube } from "../domain/game/cube.js";
|
||||
import type { Side } from "../domain/game/side.js";
|
||||
import { type BoardModel, type Place, pointSlot } from "./board-model.js";
|
||||
import { type BoardModel, type MoveTrace, type Place, pointSlot } from "./board-model.js";
|
||||
import { TEXTS } from "./texts.js";
|
||||
|
||||
/** Un dé affiché, de la couleur du camp qui l'a lancé. */
|
||||
export interface ShownDie {
|
||||
readonly value: number;
|
||||
readonly side: Side;
|
||||
/** Le jet ne permet aucun coup : le dé est estompé et barré. */
|
||||
readonly blocked: boolean;
|
||||
}
|
||||
|
||||
/** Ce qui s'ajoute aux pions : dés, videau, mises en évidence. */
|
||||
@@ -28,10 +31,29 @@ export interface BoardDecor {
|
||||
/** Le pion touché, dont on montre les cases d'arrivée. */
|
||||
readonly selected: Place | null;
|
||||
readonly targets: ReadonlySet<Place>;
|
||||
/** Les cases de départ et d'arrivée du dernier coup de l'ordinateur. */
|
||||
readonly lastMove: ReadonlySet<Place>;
|
||||
/** Les traces du dernier coup de l'ordinateur, tant qu'on les montre. */
|
||||
readonly trace: MoveTrace | null;
|
||||
/** Le joueur peut lancer les dés : ses dés en attente se touchent. */
|
||||
readonly canRoll: boolean;
|
||||
/** Le joueur peut doubler : le videau se touche. */
|
||||
readonly canDouble: boolean;
|
||||
}
|
||||
|
||||
/** Ce qui ne s'ajoute pas aux pions pendant l'animation d'un coup. */
|
||||
export const NO_MARKS = {
|
||||
movable: new Set<Place>(),
|
||||
selected: null,
|
||||
targets: new Set<Place>(),
|
||||
trace: null,
|
||||
canRoll: false,
|
||||
canDouble: false,
|
||||
} as const;
|
||||
|
||||
/** Taille minimale d'une cible à toucher, en unités du dessin : au moins
|
||||
* 44 px sur un téléphone de 360 px de large, où le plateau en fait 344
|
||||
* (640 unités). */
|
||||
const MIN_TARGET = 88;
|
||||
|
||||
// Géométrie, en unités du viewBox (le SVG s'adapte à la largeur de l'écran).
|
||||
const WIDTH = 640;
|
||||
const HEIGHT = 520;
|
||||
@@ -73,10 +95,51 @@ function columnLeft(column: number): number {
|
||||
return column < 6 ? FRAME + column * POINT_WIDTH : RIGHT_HALF + (column - 6) * POINT_WIDTH;
|
||||
}
|
||||
|
||||
/** Centre du i-ième pion d'une pile, depuis le bord du plateau. */
|
||||
function stackY(top: boolean, index: number): number {
|
||||
const offset = RADIUS + 1 + index * STEP;
|
||||
return top ? FRAME + offset : HEIGHT - FRAME - offset;
|
||||
/** Un point du dessin. */
|
||||
export interface Spot {
|
||||
readonly x: number;
|
||||
readonly y: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Centre du pion numéro index (0 : le premier posé) d'un endroit : sur une
|
||||
* case, depuis le bord du plateau ; sur la barre, depuis le milieu (le
|
||||
* joueur dans la moitié haute, où il rentre, l'ordinateur en bas) ; dans la
|
||||
* sortie, une tranche de pion vu de profil (l'ordinateur en haut).
|
||||
*/
|
||||
function slotCenter(place: Place, index: number): Spot {
|
||||
if (place === "bar-player" || place === "bar-computer") {
|
||||
const offset = RADIUS + 4 + Math.min(index, MAX_STACK) * STEP;
|
||||
return {
|
||||
x: BAR_LEFT + BAR_WIDTH / 2,
|
||||
y: place === "bar-player" ? MIDDLE - offset : MIDDLE + offset,
|
||||
};
|
||||
}
|
||||
if (place === "off-player" || place === "off-computer") {
|
||||
const y =
|
||||
place === "off-computer"
|
||||
? FRAME + index * OFF_SLAB
|
||||
: HEIGHT - FRAME - (index + 1) * OFF_SLAB;
|
||||
return { x: TRAY_LEFT + TRAY_WIDTH / 2, y: y + OFF_SLAB / 2 };
|
||||
}
|
||||
const { top, column } = pointSlot(Number(place.slice(1)));
|
||||
const offset = RADIUS + 1 + Math.min(index, MAX_STACK) * STEP;
|
||||
return {
|
||||
x: columnLeft(column) + POINT_WIDTH / 2,
|
||||
y: top ? FRAME + offset : HEIGHT - FRAME - offset,
|
||||
};
|
||||
}
|
||||
|
||||
/** Le nombre de pions de side à un endroit. */
|
||||
function countAt(board: BoardModel, side: Side, place: Place): number {
|
||||
if (place === `bar-${side}`) {
|
||||
return board.bar[side];
|
||||
}
|
||||
if (place === `off-${side}`) {
|
||||
return board.off[side];
|
||||
}
|
||||
const point = board.points.find((p) => p.place === place);
|
||||
return point?.owner === side ? point.count : 0;
|
||||
}
|
||||
|
||||
/** Redessine tout le plateau dans svg. */
|
||||
@@ -91,7 +154,18 @@ export function renderBoard(svg: SVGSVGElement, board: BoardModel, decor: BoardD
|
||||
drawBar(svg, hits, board, decor);
|
||||
drawTray(svg, hits, board, decor);
|
||||
drawDice(svg, decor.dice);
|
||||
drawCube(svg, decor.cube);
|
||||
const cube = drawCube(svg, decor.cube);
|
||||
if (decor.trace !== null) {
|
||||
drawTrace(svg, board, decor.trace);
|
||||
}
|
||||
if (decor.canRoll) {
|
||||
const center = waitingDice(svg);
|
||||
addAction(hits, "roll", TEXTS.roll, center, 2 * DIE + 8 + MIN_TARGET / 2);
|
||||
}
|
||||
if (decor.canDouble) {
|
||||
cube.classList.add("can-double");
|
||||
addAction(hits, "double", TEXTS.double, cubeCenter(decor.cube), MIN_TARGET);
|
||||
}
|
||||
// Les zones à toucher passent devant tout le reste.
|
||||
svg.append(hits);
|
||||
}
|
||||
@@ -153,16 +227,7 @@ function drawPoint(
|
||||
});
|
||||
}
|
||||
if (point.owner !== null) {
|
||||
const y = (i: number) => stackY(top, i);
|
||||
drawStack(svg, point.owner, point.count, center, y, point.place, decor);
|
||||
}
|
||||
if (decor.lastMove.has(point.place)) {
|
||||
add(svg, "circle", {
|
||||
class: "last-move",
|
||||
cx: center,
|
||||
cy: top ? MIDDLE - 12 : MIDDLE + 12,
|
||||
r: 5,
|
||||
});
|
||||
drawStack(svg, point.owner, point.count, point.place, decor);
|
||||
}
|
||||
addHit(hits, point.place, decor, {
|
||||
x: left,
|
||||
@@ -172,14 +237,13 @@ function drawPoint(
|
||||
});
|
||||
}
|
||||
|
||||
/** Une pile de pions, le i-ième centré en (cx, y(i)) ; le dernier porte le
|
||||
* nombre total s'il y en a trop pour les montrer tous. */
|
||||
/** Une pile de pions ; le dernier porte le nombre total s'il y en a trop
|
||||
* pour les montrer tous. Le pion du dessus porte son endroit dans
|
||||
* data-checker : c'est lui qu'on fait glisser quand un pion arrive. */
|
||||
function drawStack(
|
||||
svg: SVGSVGElement,
|
||||
side: Side,
|
||||
count: number,
|
||||
cx: number,
|
||||
y: (index: number) => number,
|
||||
place: Place,
|
||||
decor: BoardDecor,
|
||||
): void {
|
||||
@@ -192,15 +256,13 @@ function drawStack(
|
||||
} else if (last && decor.movable.has(place)) {
|
||||
classes.push("movable");
|
||||
}
|
||||
const cy = y(i);
|
||||
add(svg, "circle", { class: classes.join(" "), cx, cy, r: RADIUS });
|
||||
const { x, y } = slotCenter(place, i);
|
||||
const checker = add(svg, "circle", { class: classes.join(" "), cx: x, cy: y, r: RADIUS });
|
||||
if (last) {
|
||||
checker.setAttribute("data-checker", place);
|
||||
}
|
||||
if (last && count > MAX_STACK) {
|
||||
add(
|
||||
svg,
|
||||
"text",
|
||||
{ class: `checker-count count-${side}`, x: cx, y: cy + 6 },
|
||||
String(count),
|
||||
);
|
||||
add(svg, "text", { class: `checker-count count-${side}`, x, y: y + 6 }, String(count));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -213,14 +275,10 @@ function drawBar(svg: SVGSVGElement, hits: Element, board: BoardModel, decor: Bo
|
||||
width: BAR_WIDTH,
|
||||
height: HEIGHT,
|
||||
});
|
||||
const cx = BAR_LEFT + BAR_WIDTH / 2;
|
||||
// Les pions du joueur sur la barre entrent en haut à droite : on les met
|
||||
// dans la moitié haute, en partant du milieu ; ceux de l'ordinateur en bas.
|
||||
for (const side of ["player", "computer"] as const) {
|
||||
const up = side === "player";
|
||||
const y = (i: number) =>
|
||||
up ? MIDDLE - RADIUS - 4 - i * STEP : MIDDLE + RADIUS + 4 + i * STEP;
|
||||
drawStack(svg, side, board.bar[side], cx, y, `bar-${side}`, decor);
|
||||
drawStack(svg, side, board.bar[side], `bar-${side}`, decor);
|
||||
}
|
||||
addHit(hits, "bar-player", decor, { x: BAR_LEFT, y: 0, width: BAR_WIDTH, height: MIDDLE });
|
||||
}
|
||||
@@ -228,17 +286,12 @@ function drawBar(svg: SVGSVGElement, hits: Element, board: BoardModel, decor: Bo
|
||||
function drawTray(svg: SVGSVGElement, hits: Element, board: BoardModel, decor: BoardDecor): void {
|
||||
// Pions sortis, vus de profil : l'ordinateur en haut, le joueur en bas.
|
||||
for (const side of ["player", "computer"] as const) {
|
||||
for (let i = 0; i < board.off[side]; i++) {
|
||||
const y =
|
||||
side === "computer" ? FRAME + i * OFF_SLAB : HEIGHT - FRAME - (i + 1) * OFF_SLAB;
|
||||
add(svg, "rect", {
|
||||
class: `checker checker-${side}`,
|
||||
x: TRAY_LEFT + 4,
|
||||
y: y + 1,
|
||||
width: TRAY_WIDTH - 8,
|
||||
height: OFF_SLAB - 2,
|
||||
rx: 3,
|
||||
});
|
||||
const count = board.off[side];
|
||||
for (let i = 0; i < count; i++) {
|
||||
const slab = addSlab(svg, `checker checker-${side}`, slotCenter(`off-${side}`, i));
|
||||
if (i === count - 1) {
|
||||
slab.setAttribute("data-checker", `off-${side}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (decor.targets.has("off-player")) {
|
||||
@@ -251,14 +304,6 @@ function drawTray(svg: SVGSVGElement, hits: Element, board: BoardModel, decor: B
|
||||
rx: 4,
|
||||
});
|
||||
}
|
||||
if (decor.lastMove.has("off-computer")) {
|
||||
add(svg, "circle", {
|
||||
class: "last-move",
|
||||
cx: TRAY_LEFT + TRAY_WIDTH / 2,
|
||||
cy: MIDDLE - 70,
|
||||
r: 5,
|
||||
});
|
||||
}
|
||||
addHit(hits, "off-player", decor, {
|
||||
x: TRAY_LEFT,
|
||||
y: MIDDLE,
|
||||
@@ -267,6 +312,143 @@ function drawTray(svg: SVGSVGElement, hits: Element, board: BoardModel, decor: B
|
||||
});
|
||||
}
|
||||
|
||||
/** Une tranche de pion sorti, centrée en spot. */
|
||||
function addSlab(svg: Element, cssClass: string, spot: Spot): SVGElement {
|
||||
return add(svg, "rect", {
|
||||
class: cssClass,
|
||||
x: TRAY_LEFT + 4,
|
||||
y: spot.y - OFF_SLAB / 2 + 1,
|
||||
width: TRAY_WIDTH - 8,
|
||||
height: OFF_SLAB - 2,
|
||||
rx: 3,
|
||||
});
|
||||
}
|
||||
|
||||
/** Un cercle de rayon r autour d'un pion (un cadre pour un pion sorti). */
|
||||
function addRing(svg: Element, cssClass: string, place: Place, spot: Spot, r: number): void {
|
||||
if (place.startsWith("off-")) {
|
||||
addSlab(svg, cssClass, spot);
|
||||
} else {
|
||||
add(svg, "circle", { class: cssClass, cx: spot.x, cy: spot.y, r });
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------ Coup de l'ordinateur
|
||||
|
||||
/**
|
||||
* Les traces d'un coup : un pion fantôme (contour seul) là où chaque pion
|
||||
* est parti, un cercle autour de chaque pion arrivé et de chaque pion frappé
|
||||
* (sur la barre), et une flèche par demi-coup.
|
||||
*/
|
||||
function drawTrace(svg: SVGSVGElement, board: BoardModel, trace: MoveTrace): void {
|
||||
const side = trace.side;
|
||||
for (const [place, n] of trace.left) {
|
||||
// Au-dessus des pions restés sur la case.
|
||||
const remaining = countAt(board, side, place);
|
||||
for (let k = 0; k < n; k++) {
|
||||
addRing(svg, "trace-ghost", place, slotCenter(place, remaining + k), RADIUS - 1);
|
||||
}
|
||||
}
|
||||
for (const [place, n] of trace.landed) {
|
||||
const top = topIndex(board, side, place);
|
||||
for (let k = 0; k < n && top - k >= 0; k++) {
|
||||
addRing(svg, "trace-landed", place, slotCenter(place, top - k), RADIUS + 3);
|
||||
}
|
||||
}
|
||||
const opponent: Side = side === "player" ? "computer" : "player";
|
||||
const bar: Place = `bar-${opponent}`;
|
||||
const top = topIndex(board, opponent, bar);
|
||||
for (let k = 0; k < trace.hits && top - k >= 0; k++) {
|
||||
addRing(svg, "trace-hit", bar, slotCenter(bar, top - k), RADIUS + 3);
|
||||
}
|
||||
const defs = add(svg, "defs", {});
|
||||
const marker = add(defs, "marker", {
|
||||
id: "trace-head",
|
||||
viewBox: "0 0 10 10",
|
||||
refX: 8,
|
||||
refY: 5,
|
||||
markerWidth: 3.5,
|
||||
markerHeight: 3.5,
|
||||
orient: "auto",
|
||||
});
|
||||
add(marker, "path", { class: "trace-head", d: "M0,0 L10,5 L0,10 z" });
|
||||
const departures = new Map<Place, number>();
|
||||
for (const { from, to } of trace.arrows) {
|
||||
// De la place libre laissée par le pion (son fantôme) à son pion
|
||||
// d'arrivée.
|
||||
const k = departures.get(from) ?? 0;
|
||||
departures.set(from, k + 1);
|
||||
const start = slotCenter(from, countAt(board, side, from) + k);
|
||||
const end = slotCenter(to, Math.max(topIndex(board, side, to), 0));
|
||||
drawArrow(svg, start, end, to.startsWith("off-") ? OFF_SLAB : RADIUS + 4);
|
||||
}
|
||||
}
|
||||
|
||||
/** L'indice du pion du dessus dessiné à un endroit (-1 s'il n'y en a pas). */
|
||||
function topIndex(board: BoardModel, side: Side, place: Place): number {
|
||||
const count = countAt(board, side, place);
|
||||
return place.startsWith("off-") ? count - 1 : Math.min(count, MAX_STACK) - 1;
|
||||
}
|
||||
|
||||
/** Une flèche de start vers end, arrêtée à gap du centre d'arrivée. */
|
||||
function drawArrow(svg: SVGSVGElement, start: Spot, end: Spot, gap: number): void {
|
||||
const dx = end.x - start.x;
|
||||
const dy = end.y - start.y;
|
||||
const length = Math.hypot(dx, dy);
|
||||
const keep = length > 2 * gap ? (length - gap) / length : 0.5;
|
||||
add(svg, "line", {
|
||||
class: "trace-arrow",
|
||||
x1: start.x,
|
||||
y1: start.y,
|
||||
x2: start.x + dx * keep,
|
||||
y2: start.y + dy * keep,
|
||||
"marker-end": "url(#trace-head)",
|
||||
});
|
||||
}
|
||||
|
||||
/** Le centre du pion du dessus d'un endroit, sur le plateau tel qu'il est
|
||||
* dessiné (null s'il n'y a pas de pion). */
|
||||
export function checkerSpot(svg: SVGSVGElement, place: Place): Spot | null {
|
||||
const checker = svg.querySelector(`[data-checker="${place}"]`);
|
||||
if (checker === null) {
|
||||
return null;
|
||||
}
|
||||
const number = (name: string) => Number(checker.getAttribute(name));
|
||||
if (checker.tagName === "circle") {
|
||||
return { x: number("cx"), y: number("cy") };
|
||||
}
|
||||
return { x: number("x") + number("width") / 2, y: number("y") + number("height") / 2 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Fait glisser le pion du dessus de place depuis from jusqu'à sa position
|
||||
* dessinée, en ms millisecondes (animation du navigateur, sans bibliothèque).
|
||||
* Résolue à la fin du glissement.
|
||||
*/
|
||||
export async function slideChecker(
|
||||
svg: SVGSVGElement,
|
||||
place: Place,
|
||||
from: Spot,
|
||||
ms: number,
|
||||
): Promise<void> {
|
||||
const checker = svg.querySelector(`[data-checker="${place}"]`);
|
||||
const to = checkerSpot(svg, place);
|
||||
if (checker === null || to === null || typeof checker.animate !== "function") {
|
||||
return;
|
||||
}
|
||||
// Devant les autres pions pendant le trajet, mais derrière les zones à
|
||||
// toucher, toujours dessinées en dernier.
|
||||
svg.insertBefore(checker, svg.lastChild);
|
||||
const animation = checker.animate(
|
||||
[
|
||||
{ transform: `translate(${from.x - to.x}px, ${from.y - to.y}px)` },
|
||||
{ transform: "translate(0px, 0px)" },
|
||||
],
|
||||
{ duration: ms, easing: "ease-in-out" },
|
||||
);
|
||||
await animation.finished.catch(() => undefined);
|
||||
}
|
||||
|
||||
// Position des points d'un dé, en fractions de son côté.
|
||||
const PIPS: Record<number, [number, number][]> = {
|
||||
1: [[0.5, 0.5]],
|
||||
@@ -310,7 +492,10 @@ function drawDice(svg: SVGSVGElement, dice: readonly ShownDie[]): void {
|
||||
dice.forEach((die, i) => {
|
||||
const x = center - total / 2 + i * (DIE + gap);
|
||||
const y = MIDDLE - DIE / 2;
|
||||
const group = add(svg, "g", { class: "die", "data-value": die.value });
|
||||
const group = add(svg, "g", {
|
||||
class: die.blocked ? "die die-blocked" : "die",
|
||||
"data-value": die.value,
|
||||
});
|
||||
add(group, "rect", { class: `die-${die.side}`, x, y, width: DIE, height: DIE, rx: 6 });
|
||||
for (const [px, py] of PIPS[die.value] ?? []) {
|
||||
add(group, "circle", {
|
||||
@@ -320,23 +505,78 @@ function drawDice(svg: SVGSVGElement, dice: readonly ShownDie[]): void {
|
||||
r: 3.5,
|
||||
});
|
||||
}
|
||||
if (die.blocked) {
|
||||
add(group, "line", { class: "die-strike", x1: x, y1: y + DIE, x2: x + DIE, y2: y });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Le videau, dans le plateau de sortie : au milieu s'il est au centre,
|
||||
* sinon du côté de son propriétaire. Au centre, il montre 64. */
|
||||
function drawCube(svg: SVGSVGElement, cube: Cube): void {
|
||||
const shift = cube.owner === "computer" ? -48 : cube.owner === "player" ? 48 : 0;
|
||||
const x = TRAY_LEFT + (TRAY_WIDTH - CUBE) / 2;
|
||||
const y = MIDDLE + shift - CUBE / 2;
|
||||
function drawCube(svg: SVGSVGElement, cube: Cube): SVGElement {
|
||||
const center = cubeCenter(cube);
|
||||
const x = center.x - CUBE / 2;
|
||||
const y = center.y - CUBE / 2;
|
||||
const group = add(svg, "g", { class: "cube", "data-owner": cube.owner ?? "center" });
|
||||
add(group, "rect", { class: "cube-face", x, y, width: CUBE, height: CUBE, rx: 4 });
|
||||
add(
|
||||
group,
|
||||
"text",
|
||||
{ class: "cube-value", x: x + CUBE / 2, y: y + CUBE / 2 + 6 },
|
||||
{ class: "cube-value", x: center.x, y: center.y + 6 },
|
||||
String(cube.owner === null ? 64 : cube.value),
|
||||
);
|
||||
return group;
|
||||
}
|
||||
|
||||
/** Le centre du videau dans le plateau de sortie. */
|
||||
function cubeCenter(cube: Cube): Spot {
|
||||
const shift = cube.owner === "computer" ? -48 : cube.owner === "player" ? 48 : 0;
|
||||
return { x: TRAY_LEFT + TRAY_WIDTH / 2, y: MIDDLE + shift };
|
||||
}
|
||||
|
||||
/** Les dés du joueur en attente d'être lancés, faces vides, au milieu de la
|
||||
* moitié gauche (la droite garde le dernier jet de l'ordinateur). Renvoie
|
||||
* leur centre. */
|
||||
function waitingDice(svg: SVGSVGElement): Spot {
|
||||
const center = { x: FRAME + HALF_WIDTH / 2, y: MIDDLE };
|
||||
const gap = 8;
|
||||
const group = add(svg, "g", { class: "die die-waiting" });
|
||||
for (const x of [center.x - gap / 2 - DIE, center.x + gap / 2]) {
|
||||
add(group, "rect", {
|
||||
class: "die-player",
|
||||
x,
|
||||
y: MIDDLE - DIE / 2,
|
||||
width: DIE,
|
||||
height: DIE,
|
||||
rx: 6,
|
||||
});
|
||||
}
|
||||
return center;
|
||||
}
|
||||
|
||||
/**
|
||||
* Une zone à toucher qui fait la même chose qu'un bouton (lancer les dés,
|
||||
* doubler) : au moins MIN_TARGET de côté, centrée sur ce qu'on touche, avec
|
||||
* un rôle et un nom accessibles. Les boutons restent pour le clavier.
|
||||
*/
|
||||
function addAction(
|
||||
hits: Element,
|
||||
action: string,
|
||||
label: string,
|
||||
center: Spot,
|
||||
width: number,
|
||||
): void {
|
||||
add(hits, "rect", {
|
||||
class: "hit action",
|
||||
"data-action": action,
|
||||
role: "button",
|
||||
"aria-label": label,
|
||||
// Sans déborder du dessin à droite (le videau est contre le bord).
|
||||
x: Math.min(center.x - width / 2, WIDTH - width),
|
||||
y: center.y - MIN_TARGET / 2,
|
||||
width,
|
||||
height: MIN_TARGET,
|
||||
});
|
||||
}
|
||||
|
||||
/** Une zone à toucher. Elle porte son état (can-move, can-land) pour les
|
||||
|
||||
+5
-2
@@ -37,6 +37,9 @@ export const TEXTS = {
|
||||
take: "Prendre",
|
||||
drop: "Passer",
|
||||
cancel: "Annuler",
|
||||
skip: "Continuer",
|
||||
replay: "Revoir",
|
||||
replayTitle: "Revoir le dernier coup de l'ordinateur",
|
||||
newGame: "Nouvelle partie",
|
||||
help: "Aide",
|
||||
back: "Retour à la partie",
|
||||
@@ -57,8 +60,8 @@ export const TEXTS = {
|
||||
yourMove: (dice: readonly number[]) =>
|
||||
`Jouez ${roll(dice)} : touchez un pion, puis sa case d'arrivée.`,
|
||||
pickTarget: "Touchez une case d'arrivée en surbrillance, ou un autre pion.",
|
||||
youCannotMove: (dice: readonly number[]) =>
|
||||
`Vous ne pouvez pas jouer ${roll(dice)} : votre tour passe.`,
|
||||
youCannotMove: (dice: readonly number[]) => `Aucun coup possible avec ${roll(dice)}.`,
|
||||
pressContinue: "Touchez « Continuer » : le tour passe à l'ordinateur.",
|
||||
thinking: "L'ordinateur réfléchit…",
|
||||
computerMoved: (dice: readonly number[], move: string, ms: number) =>
|
||||
`L'ordinateur a joué ${roll(dice)} : ${move} (${seconds(ms)}).`,
|
||||
|
||||
@@ -36,7 +36,12 @@ async function playToTheEnd(game: Game, engine: Awaited<ReturnType<typeof loadNo
|
||||
} else {
|
||||
assert.ok(state.roll !== null);
|
||||
const [first] = await engine.legalMoves(state.position, state.roll);
|
||||
assert.ok(first !== undefined, "le joueur devrait avoir un coup");
|
||||
if (first === undefined) {
|
||||
// Aucun coup possible : le joueur laisse passer son tour.
|
||||
assert.equal(await game.cannotMove(), true);
|
||||
await game.skipTurn();
|
||||
continue;
|
||||
}
|
||||
for (const halfMove of first.halfMoves) {
|
||||
assert.equal(await game.playHalfMove(halfMove), true);
|
||||
}
|
||||
|
||||
+22
-1
@@ -86,19 +86,40 @@ test("un coup se saisit demi-coup par demi-coup ; un demi-coup refusé ne change
|
||||
assert.equal(game.state.position, "SWAPPED");
|
||||
});
|
||||
|
||||
test("sans coup possible, le tour passe", async () => {
|
||||
test("sans coup possible, le tour passe quand le joueur le laisse passer", async () => {
|
||||
const engine = fakeEngine({ legalMoves: async () => [] });
|
||||
const game = Game.start(engine, {
|
||||
dice: fixedDice([6, 6]),
|
||||
setup: { position: "BLOCKED", firstTurn: "player" },
|
||||
});
|
||||
await game.roll();
|
||||
// Le tour ne passe pas tout seul : le jet reste à « jouer ».
|
||||
assert.equal(game.state.phase, "moving");
|
||||
assert.equal(game.state.turn, "player");
|
||||
assert.equal(await game.cannotMove(), true);
|
||||
await game.skipTurn();
|
||||
const last = game.events.at(-1);
|
||||
assert.ok(last?.type === "MovePlayed");
|
||||
assert.deepEqual(last.halfMoves, []);
|
||||
assert.equal(game.state.turn, "computer");
|
||||
});
|
||||
|
||||
test("le joueur ne peut pas laisser passer son tour s'il a un coup", async () => {
|
||||
const engine = fakeEngine({
|
||||
legalMoves: async () => [
|
||||
{ notation: "8/5", halfMoves: [{ from: 8, to: 5 }], position: "P" },
|
||||
],
|
||||
});
|
||||
const game = Game.start(engine, {
|
||||
dice: fixedDice([3, 1]),
|
||||
setup: { position: "X", firstTurn: "player" },
|
||||
});
|
||||
await game.roll();
|
||||
assert.equal(await game.cannotMove(), false);
|
||||
await assert.rejects(game.skipTurn(), /coup possible/);
|
||||
assert.equal(game.state.phase, "moving");
|
||||
});
|
||||
|
||||
test("l'ordinateur double si le moteur le recommande ; le joueur prend", async () => {
|
||||
const engine = fakeEngine({
|
||||
cubeDecision: async () => ({ available: true, double: true, take: true }),
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -7,10 +7,16 @@
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import { Before, BeforeAll, Given, setDefaultTimeout, Then, When } from "@cucumber/cucumber";
|
||||
import type { Engine, Move } from "../../src/domain/game/engine.js";
|
||||
import type { BoardSide, Engine, Move } from "../../src/domain/game/engine.js";
|
||||
import type { GameEvent } from "../../src/domain/game/events.js";
|
||||
import { Game, STANDARD_PLY } from "../../src/domain/game/game.js";
|
||||
import { type PositionId, parseHalfMoves, START_POSITION } from "../../src/domain/game/position.js";
|
||||
import { Game, type MoveSteps, STANDARD_PLY } from "../../src/domain/game/game.js";
|
||||
import {
|
||||
BAR,
|
||||
OFF,
|
||||
type PositionId,
|
||||
parseHalfMoves,
|
||||
START_POSITION,
|
||||
} from "../../src/domain/game/position.js";
|
||||
import { fixedDice } from "../../src/domain/game/roll.js";
|
||||
import type { GameState } from "../../src/domain/game/state.js";
|
||||
import { loadNodeEngine } from "../support/node-engine.js";
|
||||
@@ -27,11 +33,32 @@ const COMPUTER_DOUBLES = "6O5uAAC4uzsAAA";
|
||||
// l'ordinateur n'en a sorti aucun.
|
||||
const PLAYER_ALMOST_DONE = "/38AAABQAAAAAA";
|
||||
|
||||
// Le joueur, au trait : un pion sur la barre, quatorze sur sa case 6.
|
||||
// L'ordinateur tient ses six premières cases (vos cases 19 à 24), où votre
|
||||
// pion devrait rentrer.
|
||||
const PLAYER_SHUT_OUT = "t+0OAADg/wcAQA";
|
||||
|
||||
// L'ordinateur, au trait : un pion sur sa case 13, quatorze sur sa case 1.
|
||||
// Vous : un blot sur votre case 15 (sa case 10), deux pions sur votre case
|
||||
// 13 (sa case 12), douze sur votre case 6. Avec 3-1, son seul coup permis
|
||||
// est 13/10* 10/9 : il frappe votre blot.
|
||||
const COMPUTER_MUST_HIT = "4P8BEwD/PwAEAA";
|
||||
|
||||
let engine: Engine;
|
||||
let game: Game;
|
||||
let accepted: boolean;
|
||||
let positionBefore: PositionId;
|
||||
let computerTurn: { before: GameState; ms: number };
|
||||
let computerMove: MoveSteps;
|
||||
|
||||
/** Le nombre de pions d'un camp à une case d'un demi-coup (barre, sortie ou
|
||||
* 1 à 24, dans sa numérotation). */
|
||||
function checkersAt(side: BoardSide, point: number): number {
|
||||
if (point === BAR) {
|
||||
return side.bar;
|
||||
}
|
||||
return point === OFF ? side.off : (side.points[point - 1] ?? 0);
|
||||
}
|
||||
|
||||
BeforeAll(async () => {
|
||||
engine = await loadNodeEngine();
|
||||
@@ -171,6 +198,93 @@ Then(
|
||||
},
|
||||
);
|
||||
|
||||
// ------------------------------------------------- Aucun coup possible
|
||||
|
||||
Given("votre pion sur la barre ne peut pas rentrer", () => {
|
||||
// Les six cases où il pourrait rentrer sont tenues par l'ordinateur.
|
||||
game = Game.start(engine, {
|
||||
setup: { position: PLAYER_SHUT_OUT, firstTurn: "player" },
|
||||
dice: fixedDice([6, 5]),
|
||||
});
|
||||
});
|
||||
|
||||
When("vous lancez les dés", async () => {
|
||||
await game.roll();
|
||||
});
|
||||
|
||||
Then("aucun coup n'est possible avec votre jet", async () => {
|
||||
assert.deepEqual(game.state.roll, [6, 5]);
|
||||
assert.equal(await game.cannotMove(), true);
|
||||
assert.deepEqual(await game.nextHalfMoves(), []);
|
||||
});
|
||||
|
||||
Then("c'est toujours à vous", () => {
|
||||
assert.equal(game.state.turn, "player");
|
||||
assert.equal(game.state.phase, "moving");
|
||||
assert.equal(lastEvent().type, "DiceRolled");
|
||||
});
|
||||
|
||||
When("vous continuez", async () => {
|
||||
await game.skipTurn();
|
||||
const event = lastEvent();
|
||||
assert.ok(event.type === "MovePlayed" && event.side === "player");
|
||||
assert.deepEqual(event.halfMoves, []);
|
||||
});
|
||||
|
||||
// ---------------------------------------------- Voir le coup de l'ordinateur
|
||||
|
||||
Given("un de vos pions est seul à portée de l'ordinateur", () => {
|
||||
// L'ordinateur tirera 3-1. Le videau est chez vous : il ne peut pas
|
||||
// doubler, il joue.
|
||||
game = Game.start(engine, {
|
||||
setup: {
|
||||
position: COMPUTER_MUST_HIT,
|
||||
firstTurn: "computer",
|
||||
cube: { value: 2, owner: "player" },
|
||||
},
|
||||
dice: fixedDice([3, 1]),
|
||||
});
|
||||
});
|
||||
|
||||
When("l'ordinateur a joué", async () => {
|
||||
await game.playComputerTurn();
|
||||
const played = lastEvent();
|
||||
assert.ok(played.type === "MovePlayed" && played.side === "computer");
|
||||
const steps = await game.lastMoveSteps();
|
||||
assert.ok(steps !== null);
|
||||
computerMove = steps;
|
||||
});
|
||||
|
||||
Then("son coup vous est montré case par case", async () => {
|
||||
const { side, halfMoves, boards } = computerMove;
|
||||
assert.equal(side, "computer");
|
||||
assert.ok(halfMoves.length > 0);
|
||||
assert.equal(boards.length, halfMoves.length + 1);
|
||||
// Chaque plateau ne diffère du précédent que d'un pion de l'ordinateur,
|
||||
// parti de la case de départ du demi-coup pour sa case d'arrivée.
|
||||
for (const [i, h] of halfMoves.entries()) {
|
||||
const before = boards[i]?.computer;
|
||||
const after = boards[i + 1]?.computer;
|
||||
assert.ok(before !== undefined && after !== undefined);
|
||||
assert.equal(checkersAt(after, h.from), checkersAt(before, h.from) - 1);
|
||||
assert.equal(checkersAt(after, h.to), checkersAt(before, h.to) + 1);
|
||||
}
|
||||
// Le dernier est le plateau de la partie.
|
||||
assert.deepEqual(boards.at(-1), await game.displayedBoard());
|
||||
});
|
||||
|
||||
Then("votre pion frappé part sur la barre", () => {
|
||||
const { boards, hits } = computerMove;
|
||||
const hit = hits.indexOf(true);
|
||||
assert.ok(hit >= 0, "aucun pion frappé");
|
||||
assert.equal(boards[hit]?.player.bar, 0);
|
||||
assert.equal(boards[hit + 1]?.player.bar, 1);
|
||||
});
|
||||
|
||||
Then("vous pouvez revoir son coup", async () => {
|
||||
assert.deepEqual(await game.lastMoveSteps(), computerMove);
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------------ Videau
|
||||
|
||||
Given("l'ordinateur vous propose de doubler", async () => {
|
||||
|
||||
+51
-2
@@ -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");
|
||||
|
||||
@@ -52,3 +52,9 @@ construire ou tester ; rien n'est embarqué dans l'application.
|
||||
Validés par Pierre avec l'ADR 0009, dans Docker, jamais embarqués :
|
||||
TypeScript (`tsc`, Apache-2.0), Biome (MIT ou Apache-2.0),
|
||||
`@cucumber/cucumber` (MIT), `dependency-cruiser` (MIT).
|
||||
|
||||
### 2026-11-03 — Playwright pour les tests dans le navigateur
|
||||
|
||||
Validé par Pierre : Playwright 1.48.2 (Apache-2.0), image
|
||||
`mcr.microsoft.com/playwright:v1.48.2-noble`, utilisé seulement par
|
||||
`make test-browser` (Chromium et Firefox sans écran). Rien n'est embarqué.
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
# 0011. Leçons numérotées, conseil du maître après le coup, bilan de fin de partie
|
||||
|
||||
- **Statut** : accepté
|
||||
- **Date** : 2026-11-03
|
||||
- **Décideurs** : Pierre
|
||||
|
||||
## Contexte
|
||||
|
||||
Le cadrage (`docs/cadrage.md`) prévoit pour la V1 un coach en direct, un écran
|
||||
de fin de partie et des leçons par concept, sans en fixer la forme. Les
|
||||
maquettes des écrans (https://claude.ai/artifact/CQw6vbDpi6XQqVWQa4sANe),
|
||||
faites avec la charte graphique (ADR 0010), en ont proposé une. Pierre en a
|
||||
retenu trois éléments le 2026-11-03.
|
||||
|
||||
Un point des maquettes contredisait le cadrage : le conseil apparaissait avant
|
||||
que le joueur ne joue, en donnant la réponse. Pierre a tranché pour un
|
||||
commentaire après le coup.
|
||||
|
||||
## Décision
|
||||
|
||||
- **Leçons numérotées** : un parcours de leçons dans un ordre fixe
|
||||
(« Leçon 1 », « Leçon 2 »…). Chaque leçon affiche son état (à commencer,
|
||||
« 3 exercices sur 5 », terminée) et sa progression. L'accueil propose de
|
||||
continuer la leçon en cours.
|
||||
- **Conseil du maître** : c'est la forme du coach en direct du cadrage. Il
|
||||
commente le coup **après** que le joueur l'a joué, jamais avant. Il donne
|
||||
l'appréciation du moteur avec un mot et une icône (bon coup, imprécision,
|
||||
erreur), puis une phrase du maître qui dit quel coup il aurait joué et
|
||||
pourquoi, avec un lien pour en savoir plus. L'écart d'équité n'apparaît que
|
||||
sur demande. Il reste désactivable, comme le prévoit le cadrage.
|
||||
- **Bilan de fin de partie** : le résultat, le décompte des coups par
|
||||
appréciation (bons coups, imprécisions, erreurs), « le coup à revoir »
|
||||
(la plus grosse erreur, avec sa position, le coup joué et le meilleur coup)
|
||||
et le prompt prêt à copier pour une IA.
|
||||
|
||||
Les maquettes de référence sont les écrans « Plateau de jeu », « Leçon et
|
||||
aide » et « Fin de partie » du canevas ci-dessus.
|
||||
|
||||
## Alternatives écartées
|
||||
|
||||
- **Conseil avant le coup, avec la réponse** : le joueur n'essaie plus par
|
||||
lui-même, et on n'apprend pas en recopiant.
|
||||
- **Indice avant le coup, sans la réponse, en plus du commentaire après** :
|
||||
écarté pour l'instant au profit d'un seul mécanisme simple. L'« indice » de
|
||||
la liste des fonctions du cadrage reste à concevoir séparément.
|
||||
|
||||
## Conséquences
|
||||
|
||||
- Le commentaire après le coup demande une analyse du coup joué par le moteur
|
||||
(classement des coups et écart avec le meilleur). Elle doit tenir dans le
|
||||
budget de temps du téléphone, en arrière-plan, sans retarder le tour
|
||||
suivant.
|
||||
- Les seuils entre « bon coup », « imprécision » et « erreur » sont à fixer,
|
||||
en s'appuyant sur ceux de GNU Backgammon (doubtful, bad, very bad).
|
||||
- Les phrases du maître doivent rester exactes : elles s'appuient sur les
|
||||
coups et les chiffres du moteur, jamais sur une explication inventée.
|
||||
- Les leçons sont des contenus sous CC BY-SA 4.0, à écrire et à faire relire
|
||||
par un joueur fort (cadrage, risque « contenus longs à produire ») ; leur
|
||||
progression est stockée localement.
|
||||
@@ -0,0 +1,99 @@
|
||||
# 0012. Mode immersif : le plateau en paysage, sur demande
|
||||
|
||||
- **Statut** : accepté
|
||||
- **Date** : 2026-11-03
|
||||
- **Décideurs** : Pierre
|
||||
|
||||
## Contexte
|
||||
|
||||
Sur l'écran de jeu en portrait, le plateau n'occupe qu'environ 35 % de l'écran
|
||||
d'un téléphone (pions de 24 px sur un écran de 390 px de large). Pierre le
|
||||
trouve peu immersif et a demandé un mode où le plateau prend le maximum de
|
||||
l'écran.
|
||||
|
||||
Deux pistes ont été maquettées
|
||||
(https://claude.ai/artifact/CQw6vbDpi6XQqVWQa4sANe, rangée « Mode immersif ») :
|
||||
le téléphone en paysage, plateau sur toute la hauteur ; ou le portrait avec le
|
||||
plateau tourné d'un quart de tour. Toutes deux donnent environ 57 % de l'écran
|
||||
au plateau et des pions de 30 px : le petit côté de l'écran limite la taille
|
||||
des pions dans les deux cas.
|
||||
|
||||
## Décision
|
||||
|
||||
- Un **mode immersif en paysage** : le plateau occupe toute la hauteur, dans
|
||||
son orientation habituelle. À gauche : le menu, les comptes de points et la
|
||||
consigne. À droite : l'aide et les actions. Le fond est en noyer jusqu'aux
|
||||
bords de l'écran.
|
||||
- On y entre **par un geste explicite** : un bouton « Plein écran » sur l'écran
|
||||
de jeu en portrait. On en sort par le menu ou par le retour du téléphone.
|
||||
- Le bouton force la vue paysage même si le téléphone est tenu droit ou si sa
|
||||
rotation automatique est verrouillée : passage en plein écran
|
||||
(`requestFullscreen`), puis verrouillage de l'orientation
|
||||
(`screen.orientation.lock("landscape")`).
|
||||
- Si le verrouillage est refusé (navigateur ou système qui ne le permet pas,
|
||||
iPhone), l'app affiche la vue paysage tournée d'un quart de tour dans l'écran
|
||||
portrait. Le résultat est le même en main.
|
||||
- L'écran de jeu en portrait reste la vue par défaut, pour jouer d'une main.
|
||||
|
||||
## Alternatives écartées
|
||||
|
||||
- **Plateau tourné en portrait** : il change la lecture du plateau (numéros de
|
||||
côté, camps à gauche et à droite) et risque de dérouter les débutants habitués
|
||||
à une vraie table.
|
||||
- **Bascule automatique quand on tourne le téléphone** : elle ne marche pas si
|
||||
la rotation est verrouillée, et elle surprend en cours de partie. Elle reste
|
||||
possible en plus du bouton, plus tard.
|
||||
|
||||
## Conséquences
|
||||
|
||||
- Le rendu du plateau doit s'adapter à deux mises en page (portrait et paysage)
|
||||
sans changer le domaine ni le moteur : seule la vue change (ADR 0007).
|
||||
- `screen.orientation.lock` n'est accepté qu'en plein écran, et pas partout :
|
||||
la solution de repli par rotation CSS est obligatoire, et elle doit être
|
||||
testée (`make test-browser`).
|
||||
- Le plein écran masque la barre d'état du téléphone, ce qui libère encore un
|
||||
peu de place.
|
||||
|
||||
## Amendements
|
||||
|
||||
### 2026-11-03 — Vue paysage dès la rotation, plateau = écran
|
||||
|
||||
Décidé par Pierre après son premier essai sur le téléphone, où la vue paysage
|
||||
à deux colonnes (plateau limité à 70 % de la largeur, texte à droite) lui a
|
||||
paru « vraiment pas optimale ».
|
||||
|
||||
- Dès que le téléphone est en paysage, même sans plein écran, l'app applique
|
||||
la mise en page « plateau = écran » sur la hauteur réelle (`100dvh`, barre
|
||||
d'adresse comprise). La mise en page à deux colonnes disparaît.
|
||||
- Dans cette vue, les contrôles sont dans le plateau : comptes de points sur la
|
||||
barre, videau au milieu de la barre, dés et consigne dans la bande centrale,
|
||||
pictogrammes (menu, annuler, plein écran) dans la colonne des sorties
|
||||
(maquette « Immersif : plein écran, contrôles dans le plateau » du canevas
|
||||
ci-dessus). La géométrie du plateau suit les proportions de l'écran ; les
|
||||
numéros des flèches font au moins 12 px.
|
||||
- Le bouton « Plein écran » garde son rôle : plein écran, verrouillage en
|
||||
paysage, repli par rotation CSS.
|
||||
|
||||
La bascule automatique écartée plus haut était celle qui forçait
|
||||
l'orientation ; ici, seule la mise en page suit l'orientation choisie par le
|
||||
joueur.
|
||||
|
||||
### 2026-11-03 — Placement des contrôles en paysage, après la revue UX
|
||||
|
||||
La revue UX de l'étape 0e.2 a montré deux conflits dans le placement décrit
|
||||
par l'amendement précédent : la moitié basse de la colonne des sorties est une
|
||||
cible de jeu (toucher fait sortir un pion), et les pions frappés s'empilent
|
||||
depuis le milieu de la barre. Arbitrage de la session design, sur délégation
|
||||
de Pierre :
|
||||
|
||||
- une colonne de commandes à gauche, dans le cadre : menu en haut, videau au
|
||||
milieu (il monte ou descend selon son propriétaire), « Annuler le
|
||||
demi-coup » en bas, remplacé par « Revoir » quand le coup de l'ordinateur
|
||||
est montré ; « Quitter le plein écran » passe dans le menu ;
|
||||
- la colonne des sorties reste réservée aux pions sortis ;
|
||||
- les comptes de points sont des pastilles aux deux bouts de la barre, aux
|
||||
couleurs du camp, en 13 px.
|
||||
|
||||
Maquettes : rangée « Révision UX (étape 0e) : paysage » du canevas
|
||||
(variantes thème sombre, barre d'adresse visible et fin de partie). Règles de
|
||||
comportement : `docs/identite/charte/interaction.md`.
|
||||
@@ -17,3 +17,5 @@ l'ancien passe au statut « remplacé par NNNN ». Voir l'ADR 0001.
|
||||
| [0008](0008-glossaire-et-scenarios-source-unique.md) | Glossaire et scénarios : une source unique pour le code, l'aide et les tests | accepté |
|
||||
| [0009](0009-socle-pwa-typescript.md) | Socle : PWA en TypeScript, moteur en WebAssembly | accepté |
|
||||
| [0010](0010-identite-visuelle.md) | Identité visuelle : symbole, logotype, palette | accepté |
|
||||
| [0011](0011-lecons-conseil-et-bilan.md) | Leçons numérotées, conseil du maître après le coup, bilan de fin de partie | accepté |
|
||||
| [0012](0012-mode-immersif.md) | Mode immersif : le plateau en paysage, sur demande | accepté |
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
# Étape 0 — Prototype technique
|
||||
|
||||
**But** : prouver que la chaîne technique fonctionne avant d'investir dans le design, et trancher entre deux socles : PWA (moteur en WebAssembly) ou Android natif.
|
||||
|
||||
**Règle** : chaque sous-étape se termine par une démonstration que le développeur peut lancer lui-même avec une commande `make`.
|
||||
|
||||
---
|
||||
|
||||
## 0a. Le moteur en ligne de commande
|
||||
|
||||
**Objectif** : interroger le moteur GNU Backgammon sur une position, depuis le PC.
|
||||
|
||||
À faire :
|
||||
1. Récupérer les sources du moteur (choisir entre gnubg, gnubg-nn ou le moteur extrait par CBG ; justifier le choix dans un ADR, `docs/adr/`).
|
||||
2. Définir une **interface C minimale** du moteur (`engine/include/engine.h`, identifiants en anglais : ADR 0007), indépendante de toute interface graphique :
|
||||
- initialiser le moteur (charger réseaux, base de fin de partie, table d'équité de match) ;
|
||||
- lister les coups légaux pour une position et des dés ;
|
||||
- donner le meilleur coup et le classement des coups avec leur équité, pour un niveau d'évaluation donné ;
|
||||
- donner la décision de videau (pas de double, double/prend, double/passe, avec équités) ;
|
||||
- évaluer une position (probabilités de gain, gammon, backgammon).
|
||||
Les positions entrent et sortent au format **GNU Position ID + Match ID**.
|
||||
3. Écrire `engine-cli`, un petit programme qui expose ces fonctions en ligne de commande, avec une sortie JSON.
|
||||
4. Générer un jeu de **positions de référence** (au moins 30 : ouvertures, contact, course, sortie, décisions de videau) avec le GNU Backgammon de bureau dans Docker, et stocker les résultats attendus dans `tests/reference/`.
|
||||
5. `make test` compare `engine-cli` aux références (même meilleur coup, équités à une tolérance près, à fixer).
|
||||
|
||||
**Démo** : `make engine && ./build/engine-cli bestmove <position_id> <match_id> 31` affiche le meilleur coup en JSON ; `make test` passe.
|
||||
|
||||
---
|
||||
|
||||
## 0b. Le test WebAssembly (décisif)
|
||||
|
||||
**Objectif** : savoir si le moteur tourne assez vite et léger dans le navigateur du téléphone.
|
||||
|
||||
À faire :
|
||||
1. Compiler le même moteur et la même interface C en WebAssembly avec Emscripten (dans Docker).
|
||||
2. Charger le moteur dans un **Web Worker** (jamais sur le fil d'affichage).
|
||||
3. Page HTML minimale : un champ Position ID, des dés, un bouton « meilleur coup », l'affichage du résultat et du temps de calcul.
|
||||
4. Rejouer les positions de référence dans le navigateur : les résultats doivent concorder avec 0a.
|
||||
5. `make deploy-web` sert la page sur le réseau local pour l'ouvrir depuis le Fairphone 4.
|
||||
6. `make bench` (ou une page de mesure) produit un petit rapport : temps par coup selon le niveau d'évaluation, taille téléchargée, mémoire utilisée.
|
||||
|
||||
**Critères de réussite (à ajuster après mesure)** :
|
||||
|
||||
| Mesure sur le Fairphone 4 | Seuil |
|
||||
| --- | --- |
|
||||
| Meilleur coup, niveau rapide (0-ply GNU) | moins de 0,3 s |
|
||||
| Meilleur coup, niveau fort (2-ply GNU) | moins de 3 s |
|
||||
| Décision de videau, niveau fort | moins de 3 s |
|
||||
| Taille totale à télécharger (wasm + données, compressé) | moins de 40 Mo |
|
||||
| Résultats identiques aux références de 0a | 100 % |
|
||||
|
||||
**Démo** : la page ouverte sur le Fairphone donne le meilleur coup d'une position de référence, avec le temps de calcul affiché.
|
||||
|
||||
---
|
||||
|
||||
## 0c. Android natif (seulement si 0b échoue)
|
||||
|
||||
**Objectif** : la même démonstration en application Android.
|
||||
|
||||
À faire :
|
||||
1. Compiler le moteur pour arm64 avec le NDK (dans Docker), en s'appuyant sur la configuration de CBG.
|
||||
2. Application Android minimale, construite en ligne de commande (Gradle sans Android Studio, `-j2`, démon Gradle limité en mémoire).
|
||||
3. Même écran de test et mêmes mesures qu'en 0b.
|
||||
4. `make deploy` installe l'APK sur le Fairphone via adb.
|
||||
|
||||
---
|
||||
|
||||
## 0d. Plateau minimal et décision
|
||||
|
||||
1. Sur le socle retenu, un **plateau minimal** : une partie complète jouable contre le moteur, coups et videau décidés par le moteur, dés tirés par l'app.
|
||||
2. Rédiger un ADR (`docs/adr/`) : socle retenu (PWA ou Android natif), mesures obtenues, raisons, risques restants.
|
||||
|
||||
**Critère de fin de l'étape 0** : 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.
|
||||
@@ -0,0 +1,55 @@
|
||||
# Étape 1 — V1 « Jouer et comprendre »
|
||||
|
||||
Document temporaire : il décrit l'étape en cours et sera supprimé à sa clôture,
|
||||
après report de ce qui est durable dans les ADR et le journal.
|
||||
|
||||
**Critère de fin** (cadrage) : un débutant apprend et joue seul ; un joueur de
|
||||
club comprend ses erreurs. Livré avec un plan de test (`docs/guide-test-etape-1.md`)
|
||||
que Pierre déroule sur le Fairphone 4.
|
||||
|
||||
**Préalable** : les sous-étapes 0e (retours du premier test) de la feuille de
|
||||
route, dont la revue UX validée par Pierre.
|
||||
|
||||
## Périmètre
|
||||
|
||||
Repris du cadrage (modules, écrans clés, IA générative en V1) et des ADR 0010,
|
||||
0011 et 0012. Ce qui n'y figure pas est hors de l'étape 1.
|
||||
|
||||
| Sous-étape | Contenu | Dépend de |
|
||||
| --- | --- | --- |
|
||||
| 1a. Moteur pour l'analyse | Niveaux de jeu (plis et bruit de GNU Backgammon : débutant à expert), classement des coups d'un jet avec équités, analyse d'un coup joué et d'une décision de videau, chance d'un jet ; exposés par `engine.h`, `engine-cli` et le Worker, avec positions de référence comparées à gnubg de bureau | — |
|
||||
| 1b. Parties enregistrées | Stockage IndexedDB derrière l'interface de stockage existante : plusieurs parties, reprise automatique, branches (« Rejouer d'ici » crée une branche, l'originale reste intacte et rechargeable), export SGF avec variantes | — |
|
||||
| 1c. Match et réglages | Partie libre ou match en N points, règle Crawford, règle Jacoby, choix du niveau de l'ordinateur, conseil du maître activable ou non ; écran de nouvelle partie | 1a, 1b |
|
||||
| 1d. Conseil du maître | Après chaque coup du joueur, jamais avant (ADR 0011) : appréciation du moteur (bon coup, imprécision, erreur, gaffe) avec mot, icône et couleur, phrase du maître fondée sur les chiffres du moteur, coup recommandé montré sur demande ; aussi pour les décisions de videau ; calcul en arrière-plan sans retarder le jeu | 1a |
|
||||
| 1e. Fin de partie et analyse | Bilan (ADR 0011) : résultat, coups par appréciation, taux d'erreur comparé à la chance, « le coup à revoir » ; écran d'analyse : graphe d'équité (toucher un point ouvre la position), liste des erreurs filtrable, visualiseur pas à pas coup joué contre meilleur coup, « Rejouer d'ici » | 1a, 1b, 1d |
|
||||
| 1f. Prompts à copier | Gabarits versionnés et testés (`docs/prompts/` ou équivalent, source unique), bouton « Demander à une IA » sur le plateau, le conseil du maître, l'analyse et la fin de partie ; le moteur décide, l'IA explique | 1d, 1e |
|
||||
| 1g. Accueil, tutoriel et leçons | Accueil (reprendre, nouvelle partie, prochaine leçon, progression résumée), tutoriel des règles interactif et illustré (0e.5), cinq premières leçons numérotées (ADR 0011) avec positions et « à vous de jouer », progression locale ; contenus CC BY-SA 4.0 en brouillon, à relire par un joueur fort | 0e.5, 1a |
|
||||
| 1h. Progression | Historique des parties et courbe du taux d'erreur | 1b, 1e |
|
||||
| 1i. Plateau soigné | Dessin du plateau aligné sur la charte (`docs/identite/charte/board.js`), animations des pions, glisser-déposer en plus du toucher, double toucher pour le seul coup évident | 0e.3 |
|
||||
| 1j. Plan de test | `docs/guide-test-etape-1.md` : parcours du débutant et du joueur de club sur le téléphone, avec ce qu'il faut rapporter | toutes |
|
||||
|
||||
## Orientation donnée par Pierre
|
||||
|
||||
- **Un coach bienveillant, mis en avant mais discret** (2026-11-03) : le
|
||||
conseil du maître et les conseils sont une signature de l'app, présents à
|
||||
chaque partie, sans jamais interrompre le jeu ni culpabiliser le joueur. Le
|
||||
ton des phrases du maître est encourageant ; l'espace qu'il occupe est
|
||||
réservé dans la mise en page (revue UX 0e.2), pas en fenêtre surgissante.
|
||||
|
||||
## Questions pour Pierre (ne bloquent pas le démarrage)
|
||||
|
||||
- **Indice** : le cadrage le liste dans le module Jeu, l'ADR 0011 l'a laissé
|
||||
« à concevoir séparément ». Il n'est pas dans l'étape 1 sans décision.
|
||||
- **Questionnaire de premier lancement** (choix du parcours) : le parcours 1
|
||||
seul existe en V1 ; proposé : un simple choix « Je découvre » / « Je connais
|
||||
les règles » qui oriente vers le tutoriel ou la partie.
|
||||
- **Seuils des appréciations** : proposés d'après ceux de GNU Backgammon
|
||||
(douteux 0,04, mauvais 0,08, très mauvais 0,16 d'équité), à calibrer.
|
||||
- **Relecture des leçons** par un joueur fort de l'Atelier du Huit.
|
||||
|
||||
## Ordre de réalisation
|
||||
|
||||
1a et 1b d'abord (fondations, sans conflit avec l'interface), en parallèle des
|
||||
sous-étapes 0e. Puis 1c, 1d, 1e, 1f dans cet ordre ; 1g après la validation des
|
||||
maquettes d'accueil et de tutoriel par Pierre ; 1h et 1i ; 1j en dernier. Chaque
|
||||
sous-étape a son plan dans `docs/plans/` et sa merge request.
|
||||
+18
-16
@@ -3,34 +3,36 @@
|
||||
Le travail restant, dans l'ordre. Une ligne réalisée est retirée : ce qui a été
|
||||
fait se lit dans les merge requests et le journal, ce qui a été décidé dans les
|
||||
ADR (`docs/adr/`). Les versions et leur contenu sont définis dans
|
||||
`docs/cadrage.md` ; le détail de l'étape en cours est dans `docs/etape-0.md`.
|
||||
`docs/cadrage.md`.
|
||||
|
||||
Légende : 🔄 en cours · ⏳ à venir · ⏸ conditionnel
|
||||
|
||||
## Étape 0 — Prototype technique
|
||||
## Étape 0 — Prototype technique : terminée
|
||||
|
||||
Acquis : socle du dépôt, organisation (ADR 0001, 0003, 0006), source du moteur
|
||||
(ADR 0005 : gnubg officiel, commit 14b6798), gnubg de référence et 32 positions
|
||||
de référence (0a.2), moteur embarqué et `engine-cli` (0a.3), comparaison
|
||||
automatique aux références, tolérance 0,002 (0a.4), moteur en WebAssembly avec
|
||||
page de test, page de mesure et guide de test (0b), mesures sur le Fairphone 4 et
|
||||
socle PWA en TypeScript (ADR 0009 ; 0c Android natif abandonnée), domaine
|
||||
« partie » en journal d'événements avec scénario exécuté sur le vrai moteur et
|
||||
garde-fous d'architecture (0d.1), déploiement sur https://ustagammon.sans.pub.
|
||||
Close le 2026-11-03 : partie complète jouée par Pierre sur le Fairphone 4, coups
|
||||
de l'ordinateur rapides, tous les tests au vert. Acquis : moteur gnubg officiel
|
||||
(ADR 0005) comparé à 32 positions de référence, moteur en WebAssembly, socle
|
||||
PWA en TypeScript (ADR 0009 ; Android natif abandonné), domaine « partie » en
|
||||
journal d'événements, plateau jouable hors ligne avec la charte graphique,
|
||||
publié sur https://ustagammon.sans.pub. Le détail est dans le journal et les
|
||||
merge requests.
|
||||
|
||||
## Retours du premier test sur téléphone (avant l'étape 1)
|
||||
|
||||
Demandes de Pierre du 2026-11-03, traitées avant d'ouvrir l'étape 1.
|
||||
|
||||
| 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+ | Appliquer la charte graphique à l'application (agent designer UI) : demande de Pierre | 🔄 | `plans/2026-11-03-charte-ui.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.
|
||||
| 0e.1 | Saisie du coup : pion à destination unique joué en un toucher, coup à valider et annulable demi-coup par demi-coup, dés joués marqués, dés des deux côtés touchables, videau confirmé ; pages de test du moteur retirées | 🔄 | `plans/2026-11-03-saisie-du-coup.md` |
|
||||
| 0e.3 | Révision UX arbitrée (session « identité », sur délégation de Pierre) : portrait stable, bandeaux de compte de points, menu en feuille et nouvelle partie annulable, vibrations, écran allumé, paysage « plateau = écran » dès la rotation (ADR 0012 amendé) | ⏳ | `plans/2026-11-03-revision-ux.md` |
|
||||
| 0e.4 | Position dans l'adresse : chaque position du plateau a un lien direct (nouvel ADR pour le format) | ⏳ | |
|
||||
| 0e.5 | Page d'accueil, installation en un toucher (« Ajouter à l'écran d'accueil », proposée par l'app), tutoriel des règles très didactique avec plateaux illustrés, aide réécrite sur un ton plus humain ; textes à valider par Pierre | ⏳ | |
|
||||
|
||||
## Étapes suivantes
|
||||
|
||||
| Étape | Contenu | État |
|
||||
| --- | --- | --- |
|
||||
| 1. V1 Jouer et comprendre | Plateau soigné, niveaux, coach, analyse, branches, tutoriel, prompts, sauvegarde | ⏳ |
|
||||
| 1. V1 Jouer et comprendre | Détail et ordre dans `docs/etape-1.md` : moteur pour l'analyse, parties enregistrées et branches, match et réglages, conseil du maître, fin de partie et analyse, prompts, accueil, tutoriel et leçons, progression, plateau soigné, plan de test | 🔄 |
|
||||
| 2. V1.5 Apprendre | Parcours 2, exercices, progression | ⏳ |
|
||||
| 3. V2 Perfectionnement et IA | Parcours 3 et 4, rollouts, révision espacée | ⏳ |
|
||||
|
||||
|
||||
@@ -1,171 +0,0 @@
|
||||
# Guide de test : Ustagammon sur le Fairphone 4 (étape 0)
|
||||
|
||||
Document temporaire de l'étape 0 : il sera supprimé avec `docs/etape-0.md`.
|
||||
|
||||
Deux tests sur le téléphone :
|
||||
|
||||
- **Étape 0d : jouer une partie** (section suivante), sur le site publié ;
|
||||
- **Étape 0b : mesurer le moteur** (fait le 2026-11-02), sur une page
|
||||
servie par le PC.
|
||||
|
||||
## Étape 0d : jouer une partie sur le téléphone
|
||||
|
||||
Le but : installer l'application sur le Fairphone 4, jouer une partie
|
||||
complète contre l'ordinateur, vérifier qu'elle marche sans réseau, et
|
||||
rapporter ce que vous avez constaté. Comptez une demi-heure.
|
||||
|
||||
### Ouvrir et installer l'application
|
||||
|
||||
1. Sur le Fairphone, ouvrez Chrome et allez sur https://ustagammon.sans.pub.
|
||||
2. Attendez que le plateau s'affiche et que le message « Chargement du
|
||||
moteur… » laisse place à une consigne (par exemple « À vous : lancez les
|
||||
dés. » ou « L'ordinateur réfléchit… »). La première fois, le moteur se
|
||||
télécharge (environ 7 Mo) : quelques secondes en Wi-Fi.
|
||||
3. Installez l'application : menu de Chrome (les trois points), puis
|
||||
« Installer l'application » ou « Ajouter à l'écran d'accueil ». Une icône
|
||||
(deux pions sur du bois) apparaît sur l'écran d'accueil.
|
||||
4. Fermez Chrome et ouvrez Ustagammon depuis cette icône : l'application
|
||||
s'ouvre en plein écran, sans barre d'adresse.
|
||||
|
||||
### Jouer une partie complète
|
||||
|
||||
Suivez le scénario « Jouer une partie contre l'ordinateur »
|
||||
(`docs/scenarios/jouer-une-partie.feature`), qui est aussi l'aide de
|
||||
l'application : bouton « Aide », en haut.
|
||||
|
||||
1. Une partie commence d'elle-même au premier lancement, par le jet
|
||||
d'ouverture (un dé chacun) : le message dit qui commence. « Nouvelle
|
||||
partie », en haut, en recommence une.
|
||||
2. Quand c'est à vous : touchez « Lancer les dés » (ou « Doubler », pour
|
||||
proposer de doubler avant de lancer).
|
||||
3. Pour jouer : touchez un pion entouré de vert, puis l'une des cases
|
||||
d'arrivée en surbrillance ; recommencez pour chaque dé. Pour sortir un
|
||||
pion, touchez le bac à droite, en bas. « Annuler » remet les pions du coup
|
||||
en cours à leur place.
|
||||
4. Le coup est envoyé dès qu'il est complet ; l'ordinateur joue ensuite, et
|
||||
le message dit son coup et son temps de calcul, par exemple
|
||||
« L'ordinateur a joué 6-4 : 24/18 13/9 (0,3 s) ». Les petits points
|
||||
orange marquent ses cases de départ et d'arrivée (dans sa numérotation :
|
||||
sa case 24 est votre case 1).
|
||||
5. Si l'ordinateur vous propose de doubler : « Prendre » ou « Passer ».
|
||||
Essayez au moins une fois de doubler vous-même.
|
||||
6. En fin de partie, le message donne le gagnant, la sorte de victoire
|
||||
(simple, gammon ou backgammon) et les points.
|
||||
|
||||
En cours de partie, fermez l'application (balayez-la dans la liste des
|
||||
applications ouvertes), puis rouvrez-la : la partie doit reprendre là où vous
|
||||
en étiez.
|
||||
|
||||
### Vérifier le hors ligne
|
||||
|
||||
1. Après avoir ouvert l'application au moins une fois (attendez une
|
||||
vingtaine de secondes après le premier chargement, le temps que tout soit
|
||||
mis de côté), fermez-la.
|
||||
2. Passez le téléphone en mode avion.
|
||||
3. Rouvrez Ustagammon depuis son icône : le plateau, l'ordinateur et l'aide
|
||||
doivent fonctionner comme avant.
|
||||
4. Repassez en mode normal.
|
||||
|
||||
### Ce qu'il faut rapporter
|
||||
|
||||
Dans la conversation avec Claude Code :
|
||||
|
||||
- les **temps des coups de l'ordinateur** : ceux affichés dans les messages
|
||||
(le plus long que vous avez vu), et votre ressenti (attente gênante ou
|
||||
non) ;
|
||||
- si la partie est allée **jusqu'au bout**, et son résultat ;
|
||||
- si la **reprise** après fermeture et le **mode avion** ont marché ;
|
||||
- tout ce qui gêne : plateau trop petit, case difficile à toucher, texte
|
||||
incompréhensible, bouton qui ne répond pas, couleurs peu lisibles (thème
|
||||
clair ou sombre du téléphone) ;
|
||||
- une **capture d'écran** (bouton marche + volume bas) de tout problème.
|
||||
|
||||
Si vous le pouvez, refaites l'essai dans Firefox sur le téléphone (sans
|
||||
l'installer) et dites si les temps de l'ordinateur sont nettement plus longs.
|
||||
|
||||
## Étape 0b : mesurer le moteur
|
||||
|
||||
Le but : ouvrir sur le téléphone une page web où le moteur de backgammon
|
||||
calcule, puis lancer une mesure et coller son rapport dans la conversation.
|
||||
Comptez un quart d'heure.
|
||||
|
||||
### 1. Préparer le PC
|
||||
|
||||
1. Ouvrez un terminal dans le dossier du projet (`~/Code/ustagammon`).
|
||||
2. Si ce n'est pas déjà fait, tapez `make setup` puis Entrée. Cela prépare les
|
||||
outils. La première fois, c'est long ; ensuite, c'est immédiat.
|
||||
3. Le PC et le téléphone doivent être sur **le même Wi-Fi** (la même box).
|
||||
|
||||
### 2. Lancer la page
|
||||
|
||||
1. Tapez `make deploy` puis Entrée.
|
||||
2. La première fois, le moteur se compile : quelques minutes.
|
||||
3. Le terminal affiche deux adresses, par exemple :
|
||||
|
||||
```
|
||||
Application : http://192.168.1.21:8080/
|
||||
Test du moteur : http://192.168.1.21:8080/moteur/
|
||||
Page de mesure : http://192.168.1.21:8080/moteur/bench.html
|
||||
```
|
||||
|
||||
Vos chiffres peuvent être différents : notez ceux de votre écran.
|
||||
4. Laissez ce terminal ouvert pendant tout le test. Pour arrêter la page à la
|
||||
fin : Ctrl+C dans le terminal.
|
||||
|
||||
### 3. Essayer un meilleur coup
|
||||
|
||||
1. Sur le Fairphone, ouvrez le navigateur (Chrome ou Firefox).
|
||||
2. Tapez l'adresse du **test du moteur** dans la barre d'adresse, exactement
|
||||
comme affichée (avec `http://` et `:8080`). Les pages de test sont aussi
|
||||
publiées : https://ustagammon.sans.pub/moteur/ et
|
||||
https://ustagammon.sans.pub/moteur/bench.html.
|
||||
3. Attendez le message « Moteur prêt, chargé en … ms ».
|
||||
4. Dans « Exemple », choisissez une position, par exemple « middlegame-1 ».
|
||||
5. Dans « Niveau », choisissez « fort (2 plis) ».
|
||||
6. Touchez « Meilleur coup ».
|
||||
7. La page affiche le coup conseillé et le **temps de calcul**. Notez ce temps
|
||||
si vous voulez, il sera aussi dans le rapport.
|
||||
|
||||
### 4. Lancer la mesure
|
||||
|
||||
1. En bas de la page, touchez « Page de mesure » (ou tapez l'adresse de la
|
||||
page de mesure).
|
||||
2. Touchez « Lancer la mesure ».
|
||||
3. Attendez « Mesure terminée » : de une à quelques minutes. Pendant ce temps,
|
||||
gardez l'écran allumé et la page ouverte, sans changer d'application.
|
||||
4. Un tableau résume les résultats : « OK » si le seuil est respecté,
|
||||
« dépassé » sinon.
|
||||
|
||||
### 5. Envoyer le rapport
|
||||
|
||||
1. Touchez « Copier les résultats ».
|
||||
2. Si la page dit « Rapport copié », c'est bon.
|
||||
3. Sinon, le texte du rapport est sélectionné : faites un appui long dessus,
|
||||
puis « Copier ».
|
||||
4. Collez le rapport dans la conversation avec Claude Code (sur le téléphone,
|
||||
ou envoyez-le vous sur le PC).
|
||||
|
||||
Si vous en avez le temps, refaites la mesure une deuxième fois (rechargez la
|
||||
page de mesure) et collez aussi ce second rapport : cela montre si les temps
|
||||
sont stables. Si les deux navigateurs sont installés, faites la mesure dans
|
||||
Chrome **et** dans Firefox : sur le PC, Firefox s'est montré nettement plus
|
||||
lent, et le rapport indique le navigateur utilisé.
|
||||
|
||||
### Si la page ne s'ouvre pas
|
||||
|
||||
- **Vérifiez l'adresse** : `http://` (et non `https://`), les chiffres exacts,
|
||||
et `:8080` à la fin.
|
||||
- **Vérifiez le Wi-Fi** : le téléphone doit être sur le même réseau que le PC,
|
||||
pas sur la 4G ni sur un Wi-Fi invité.
|
||||
- **Vérifiez que la page tourne** : sur le PC, ouvrez
|
||||
`http://localhost:8080/` dans un navigateur. Si elle ne s'ouvre pas non
|
||||
plus, relancez `make deploy` et lisez le message d'erreur.
|
||||
- **Le pare-feu du PC** peut bloquer le téléphone. S'il est actif
|
||||
(`sudo ufw status` affiche « Status: active »), autorisez le port le temps
|
||||
du test : `sudo ufw allow 8080/tcp`, puis à la fin `sudo ufw delete allow
|
||||
8080/tcp`.
|
||||
- **« Le moteur n'a pas pu se charger »** : notez le message exact et
|
||||
collez-le dans la conversation. Essayez aussi l'autre navigateur (Chrome ou
|
||||
Firefox), à jour.
|
||||
- **Le port 8080 est déjà pris** : un autre programme l'utilise. Lancez
|
||||
`make deploy WEB_PORT=8081` et utilisez `:8081` dans l'adresse.
|
||||
@@ -14,6 +14,7 @@ Décisions : ADR 0010. Fichiers du logo et des icônes : `../logo/`.
|
||||
| Fichier | Contenu |
|
||||
| --- | --- |
|
||||
| `guide-de-marque.md` | Règles d'usage : ton, couleurs, typographie, formes, mouvement, icônes, composants |
|
||||
| `interaction.md` | Comportement : réponse immédiate, annuler plutôt que confirmer, mouvement, vibrations, clavier, mises en page de l'écran de jeu (portrait, paysage, ordinateur), pictogrammes |
|
||||
| `tokens.json` | Toutes les valeurs, thèmes clair et sombre, avec une note d'usage par couleur |
|
||||
| `bundle.css` | Styles des composants ; mêmes noms de classes que `web/app.css` |
|
||||
| `board.js` | Dessin de référence du plateau (repère 640 × 520), pour comparaison |
|
||||
|
||||
@@ -56,6 +56,10 @@ Sobre et utile : un pion glisse vers sa flèche (150 à 250 ms, sans rebond), le
|
||||
- Icônes d'interface : traits simples de 2px à bouts ronds, dessinés en SVG dans le code (pas de police d'icônes, pas d'émoji). Les seules icônes du système pour l'instant : les trois appréciations de coups (coche, point d'interrogation, croix) et la copie (deux feuillets).
|
||||
- Dés, pions et videau sont dessinés en SVG avec les couleurs du plateau, jamais en images.
|
||||
|
||||
## Interaction
|
||||
|
||||
Règles de comportement (réponse immédiate, annuler plutôt que confirmer, mouvement, vibrations, clavier, mises en page de l’écran de jeu) : section « Interaction ».
|
||||
|
||||
## Composants
|
||||
|
||||
Plateau (`Board`) et ses pièces (`Checker`, `Die`, `DoublingCube`), boutons (`Button`), barre haute (`TopBar`), zone de consigne (`GamePrompt`), appréciation d'un coup (`MoveVerdict`), fiche de glossaire (`GlossaryCard`), carte de leçon (`LessonCard`) et « Demander à une IA » (`AskAI`). Chacun a sa fiche avec ses règles d'usage et un aperçu dans les deux thèmes.
|
||||
Plateau (`Board`) et ses pièces (`Checker`, `Die`, `DoublingCube`), boutons (`Button`), barre haute (`TopBar`), zone de consigne (`GamePrompt`), appréciation d’un coup (`MoveVerdict`), pictogrammes (`Icons`), fiche de glossaire (`GlossaryCard`), carte de leçon (`LessonCard`) et « Demander à une IA » (`AskAI`). Chacun a sa fiche avec ses règles d'usage et un aperçu dans les deux thèmes.
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
# Interaction
|
||||
|
||||
Ustagammon doit se sentir **net et rapide**, comme un outil bien réglé : chaque toucher répond tout de suite, rien ne saute, rien n'interrompt la partie sans raison. La chaleur vient de la matière et du ton ; la précision vient du comportement.
|
||||
|
||||
## Principes
|
||||
|
||||
1. **Réponse immédiate.** Un toucher produit un effet visible en moins de 100 ms : le pion part, le bouton s'enfonce. Le moteur calcule ensuite, en arrière-plan ; l'interface ne l'attend jamais pour réagir.
|
||||
2. **Rien ne bouge sous le doigt.** Les zones de l'écran de jeu ont une hauteur fixe : seuls leurs contenus changent. Une ligne vide garde sa place.
|
||||
3. **Annuler plutôt que confirmer.** Une action se fait tout de suite et se rattrape pendant 10 s par un bandeau « Reprendre » (nouvelle partie, abandon). Une boîte de confirmation n'est permise que pour une décision de jeu irréversible qui engage la partie : prendre ou passer un double, doubler.
|
||||
4. **Une seule action principale par moment**, en brique, à droite, sous le pouce.
|
||||
5. **Le joueur garde la main.** L'app ne joue jamais à sa place : quand aucun coup n'est possible, les dés se barrent et le joueur passe en les touchant (demande explicite de Pierre).
|
||||
6. **Le maître parle après le coup, discrètement** (ADR 0011) : une ligne réservée sous le plateau, qui s'ouvre sur l'explication si on la touche. Jamais de fenêtre qui s'impose.
|
||||
7. **Le sens passe toujours par deux canaux** : couleur et mot, vibration et marque visible, raccourci et bouton.
|
||||
|
||||
## Mouvement
|
||||
|
||||
| Jeton | Valeur | Usage |
|
||||
| --- | --- | --- |
|
||||
| `duration-instant` | 90ms | Retour d'appui d'un bouton, apparition du contour d'un pion jouable. |
|
||||
| `duration-fast` | 160ms | Pion qui glisse d'une flèche à l'autre, dés qui se posent, bandeaux. |
|
||||
| `duration-moderate` | 240ms | Feuille du menu qui monte, panneau de fin de partie, bascule de mise en page. |
|
||||
| `easing-standard` | cubic-bezier(0.2, 0, 0, 1) | Tout ce qui se déplace : départ franc, arrivée douce. |
|
||||
| `easing-exit` | cubic-bezier(0.4, 0, 1, 1) | Ce qui disparaît. |
|
||||
|
||||
- Pas de rebond, pas d'élastique, pas de fondu de plus de 240 ms.
|
||||
- Les coups de l'ordinateur se jouent demi-coup par demi-coup, 160 ms par pion, 120 ms de pause entre deux : on suit le coup sans l'attendre.
|
||||
- Le bouton enfoncé descend de 1 px et s'assombrit de 6 %, sans ombre.
|
||||
- `prefers-reduced-motion` : les déplacements deviennent des apparitions instantanées ; les dés ne roulent pas.
|
||||
|
||||
## Vibrations
|
||||
|
||||
Activées par défaut, désactivables dans le menu (« Vibrations »). Elles doublent un événement visible, jamais seules. Rien ne vibre pour un toucher de bouton ni pour le lancer des dés.
|
||||
|
||||
| Moment | Motif (ms) |
|
||||
| --- | --- |
|
||||
| Votre pion se pose | `12` |
|
||||
| Un pion est frappé | `25, 40, 25` |
|
||||
| L'ordinateur propose de doubler | `40, 60, 40` |
|
||||
| Fin de partie gagnée | `30, 50, 30, 50, 60` |
|
||||
| Fin de partie perdue | `60` |
|
||||
|
||||
Les durées se règlent en main sur le Fairphone 4. Une seule vibration à la fois : la dernière annule la précédente.
|
||||
|
||||
## Clavier (ordinateur)
|
||||
|
||||
Chaque action a son raccourci, affiché sur le bouton en petite touche (`kbd`) quand un clavier est détecté. `?` ouvre la liste.
|
||||
|
||||
| Touche | Action |
|
||||
| --- | --- |
|
||||
| `Espace` | Lancer les dés ; jouer le coup quand il est complet ; passer quand aucun coup n'est possible |
|
||||
| `←` `→` | Choisir un pion jouable (le focus se voit : anneau `focus`) |
|
||||
| `↑` `↓` | Choisir sa case d'arrivée |
|
||||
| `Entrée` | Poser le pion |
|
||||
| `Z` | Annuler le demi-coup (aussi `Ctrl+Z`) |
|
||||
| `D` | Doubler |
|
||||
| `P` / `N` | Prendre / passer, quand l'ordinateur double |
|
||||
| `F` | Plein écran |
|
||||
| `M` | Menu |
|
||||
| `?` | Aide et raccourcis |
|
||||
| `Échap` | Fermer le menu, l'aide ou la bulle ouverte ; ne touche jamais à la partie |
|
||||
|
||||
Le plateau entier se joue au clavier : c'est aussi ce qui le rend accessible aux lecteurs d'écran.
|
||||
|
||||
## Pictogrammes
|
||||
|
||||
Trait de 2 px à bouts ronds, sur une grille de 20 × 20, `currentColor`, dessinés en SVG dans le code. Le jeu complet : menu, aide, plein écran, quitter le plein écran, annuler (flèche de retour), revoir (flèche circulaire), fermer, chevron, copier, et les trois appréciations (coche, point d'interrogation, croix dans un cercle). En portrait, la barre haute affiche un libellé de 13 px sous chaque pictogramme ; en paysage et sur ordinateur, le pictogramme seul avec son `aria-label`.
|
||||
|
||||
## Mises en page de l'écran de jeu
|
||||
|
||||
- **Portrait** : barre haute, bandeau de l'ordinateur, plateau, bandeau du joueur (teinté `accent-soft` quand c'est à lui), ligne du maître ou du dernier coup, consigne, et barre d'actions collée en bas. Hauteurs fixes.
|
||||
- **Paysage** (dès que le téléphone est tourné ; ADR 0012) : le plateau est l'écran. Colonne de commandes à gauche dans le cadre (menu, videau, annuler ou revoir), sorties à droite, comptes de points en pastilles aux deux bouts de la barre, consigne et dés dans la bande centrale. Numéros des flèches à 12 px au moins.
|
||||
- **Ordinateur** : plateau à gauche, colonne à droite avec les bandeaux des joueurs, la liste des coups, la consigne, les actions et leurs raccourcis.
|
||||
@@ -526,5 +526,38 @@
|
||||
},
|
||||
"meta": {
|
||||
"source": "Brief créatif + ADR 0010 + web/app.css (noms des variables du plateau)"
|
||||
},
|
||||
"duration": {
|
||||
"tokens": [
|
||||
{
|
||||
"name": "duration-instant",
|
||||
"value": "90ms",
|
||||
"usage": "Retour d'appui d'un bouton, contour d'un pion jouable."
|
||||
},
|
||||
{
|
||||
"name": "duration-fast",
|
||||
"value": "160ms",
|
||||
"usage": "Pion qui glisse, dés qui se posent, bandeaux."
|
||||
},
|
||||
{
|
||||
"name": "duration-moderate",
|
||||
"value": "240ms",
|
||||
"usage": "Feuille du menu, panneau de fin de partie, bascule de mise en page. Jamais plus long."
|
||||
}
|
||||
]
|
||||
},
|
||||
"easing": {
|
||||
"tokens": [
|
||||
{
|
||||
"name": "easing-standard",
|
||||
"value": "cubic-bezier(0.2, 0, 0, 1)",
|
||||
"usage": "Tout ce qui se déplace : départ franc, arrivée douce."
|
||||
},
|
||||
{
|
||||
"name": "easing-exit",
|
||||
"value": "cubic-bezier(0.4, 0, 1, 1)",
|
||||
"usage": "Ce qui disparaît."
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -110,3 +110,29 @@ Une entrée datée par session : ce qui a été fait, décidé, et la suite.
|
||||
- Les maquettes proposent des éléments de produit qui ne sont pas encore
|
||||
décidés (leçons, « Conseil du maître », bilan de fin de partie) : à valider
|
||||
par Pierre avant toute mise en œuvre.
|
||||
|
||||
## 2026-11-03 (matin) : fin de l'étape 0, retours du test sur téléphone
|
||||
|
||||
**Fait**
|
||||
- Pierre a joué une partie complète sur le Fairphone 4 : « très rapide de la
|
||||
part de l'ordinateur ». Avec les tests au vert, le critère de fin de l'étape 0
|
||||
est atteint. Le guide de test et `docs/etape-0.md`, temporaires, sont
|
||||
supprimés ; leurs décisions sont dans les ADR 0005 et 0009.
|
||||
- Playwright validé par Pierre (amendement de l'ADR 0004, MR !20).
|
||||
- Douze retours de Pierre après l'essai, plus l'installation en un toucher et
|
||||
les retours haptiques, rangés en sous-étapes 0e dans la feuille de route
|
||||
(MR !20). Lancés : la saisie du coup (0e.1) et une revue UX par un designer
|
||||
UI (0e.2).
|
||||
- Coordination avec la session « identité » : elle propose et maquette le
|
||||
design (mode immersif, compte de points, accueil, tutoriel), Pierre tranche,
|
||||
la session technique implémente. Numéros des flèches à 12 px minimum.
|
||||
- Décision de Pierre : la vue paysage « plateau = écran » s'applique dès la
|
||||
rotation du téléphone, sans plein écran (amendement de l'ADR 0012).
|
||||
- Mode avion vérifié par Pierre sur le téléphone : l'application fonctionne
|
||||
hors ligne.
|
||||
- Étape 1 découpée dans `docs/etape-1.md` (sous-étapes 1a à 1j).
|
||||
- Nouvel objectif fixé par Pierre : traiter les retours 0e (les siens et ceux
|
||||
du designer), puis réaliser l'étape 1, livrée avec un plan de test.
|
||||
|
||||
**Reste à faire**
|
||||
- Firefox sur le téléphone reste à mesurer.
|
||||
|
||||
@@ -1,105 +0,0 @@
|
||||
# Appliquer la charte graphique à l'application
|
||||
|
||||
- **Date** : 2026-11-03
|
||||
- **Étape** : 0d (finition demandée par Pierre)
|
||||
- **Statut** : terminé
|
||||
- **Exécutant** : sous-agent general-purpose dans le rôle d'un designer UI, dans un worktree isolé
|
||||
|
||||
## Objectif
|
||||
|
||||
L'application publiée (https://ustagammon.sans.pub) adopte l'identité
|
||||
d'Ustagammon (ADR 0010, `docs/identite/`) et gagne en qualité d'interface :
|
||||
lisible, chaleureuse, agréable au toucher, dans les deux thèmes et les deux
|
||||
orientations. Les choix d'identité sont déjà faits par Pierre : il s'agit de
|
||||
les appliquer fidèlement et de soigner l'ergonomie, pas de les réinventer.
|
||||
|
||||
## Consignes
|
||||
|
||||
1. **Appliquer la charte** en suivant `docs/identite/charte/README.md` (section
|
||||
« Appliquer la charte à `web/` ») : couleurs des deux thèmes depuis
|
||||
`tokens.json`, police Alegreya Sans embarquée (OFL, licence jointe, ajoutée
|
||||
au cache du service worker), composants de `bundle.css` (boutons, focus,
|
||||
zone de consigne, filet des pions), logo horizontal dans la barre haute,
|
||||
icônes et favicon de `docs/identite/logo/`, `theme-color` du manifeste.
|
||||
Lire aussi `guide-de-marque.md` (ton, formes, mouvement).
|
||||
2. **Passe de designer UI** sur les écrans existants (plateau, consignes et
|
||||
boutons, fin de partie, aide, thème sombre, paysage) :
|
||||
- hiérarchie visuelle claire : la consigne du moment ressort, l'information
|
||||
secondaire (compte de points, dernier coup) reste discrète ;
|
||||
- cibles tactiles d'au moins 44 à 48 px, espacements réguliers, rien qui
|
||||
déborde en 360 px de large ni en paysage ;
|
||||
- contrastes conformes aux tokens (déjà vérifiés par la charte) ; anneau de
|
||||
focus visible au clavier ;
|
||||
- page d'aide agréable à lire (titres, glossaire en liste de définitions) ;
|
||||
- respecter `prefers-reduced-motion`.
|
||||
Lister dans le rapport chaque amélioration et sa raison.
|
||||
3. **Périmètre, pour éviter les conflits** : un autre agent ajoute en parallèle
|
||||
l'animation et les traces du coup de l'ordinateur (`board-view.ts`,
|
||||
`app.ts`, nouvelles classes CSS de traces). Ne modifie pas la géométrie ni
|
||||
la logique de `board-view.ts` et `app.ts` ; agis par le CSS, les variables,
|
||||
`index.html`, `texts.ts` (sans changer le sens des textes) et les fichiers
|
||||
statiques. Si une amélioration demande de toucher au dessin du plateau,
|
||||
décris-la dans le rapport au lieu de la faire. Prévois les couleurs des
|
||||
traces dans les variables (`--last-move` en laiton selon les tokens).
|
||||
4. **Tests** : `make lint`, `make test` (dont `tests/test_site.sh`, à mettre à
|
||||
jour pour la police, les icônes et le service worker) et `make test-browser`
|
||||
verts ; captures d'écran avant et après (portrait clair, portrait sombre,
|
||||
paysage, aide, fin de partie) dans le dossier de travail temporaire.
|
||||
5. `CREDITS.md` : Alegreya Sans (OFL) et le logo (CC BY-SA 4.0).
|
||||
|
||||
Contraintes :
|
||||
- `CLAUDE.md`, ADR 0007 à 0010 : simplicité, pas de framework CSS, identifiants
|
||||
en anglais, commentaires et textes en français ;
|
||||
- aucune modification de l'identité elle-même (couleurs, logo, police) : en cas
|
||||
de doute ou de manque dans la charte, le signaler dans le rapport ;
|
||||
- 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 <noreply@anthropic.com>`.
|
||||
|
||||
Rapport attendu : ce qui a été appliqué, les améliorations d'interface et leurs
|
||||
raisons, les captures avant et après (chemins), ce qui demande de toucher au
|
||||
plateau, les écarts à la charte, le résultat des tests.
|
||||
|
||||
## Critères de réussite
|
||||
|
||||
- L'application suit la charte dans les deux thèmes (couleurs, police, logo,
|
||||
icônes, composants), hors ligne compris.
|
||||
- Rien ne déborde en 360 × 740, 412 × 915, 915 × 412 et 740 × 360.
|
||||
- `make lint`, `make test` et `make test-browser` passent.
|
||||
|
||||
## Résultat
|
||||
|
||||
Branche `charte-ui-travail`. Tests : `make lint`, `make test` (10/10) et
|
||||
`make test-browser` (Chromium et Firefox, partie complète, paysage, hors
|
||||
ligne, CSP) au vert. Contrôle supplémentaire en 360 × 740, 412 × 915,
|
||||
915 × 412 et 740 × 360, clair et sombre, dans les deux navigateurs : aucun
|
||||
défilement horizontal, rien hors de l'écran, aucun bouton sous 44 px.
|
||||
|
||||
**Appliqué** : couleurs des deux thèmes depuis `tokens.json` (brique en
|
||||
accent, laiton pour `--last-move`) ; Alegreya Sans en cinq graisses dans
|
||||
`web/fonts/` (woff2, sous-ensemble latin, 143 Ko au lieu de 1,3 Mo en TTF,
|
||||
licence `OFL.txt`), mise en cache par le service worker comme tout le site ;
|
||||
boutons ronds, anneau de focus, zone de consigne ; logo dans la barre haute
|
||||
(version sombre en thème sombre) ; icônes, favicon et icône Apple copiés de
|
||||
`docs/identite/logo/` par `make site` (une seule source ; anciennes icônes
|
||||
et `scripts/make-icons.py` supprimés) ; `theme-color` `#4A2E1F`.
|
||||
|
||||
**Passe d'interface** : consigne dans un cadre `accent-soft` (neutre pendant
|
||||
que l'ordinateur réfléchit, `body.busy` dès le chargement) ; résultat de fin
|
||||
de partie en taille titre ; compte de points et dernier coup en texte
|
||||
secondaire ; plateau à 8 px des bords en portrait ; en paysage, barre haute
|
||||
dans la colonne de droite, le plateau gagne toute la hauteur (344 px au lieu
|
||||
de 304 en 740 × 360) ; cibles de 44 à 48 px, `touch-action: manipulation` ;
|
||||
aide en colonne de lecture, titres de la charte, glossaire en carte ; chiffres
|
||||
alignés sur le plateau ; `prefers-reduced-motion` respecté.
|
||||
|
||||
**Écarts à valider** : symbole seul au lieu du logo horizontal sous 392 px
|
||||
de large et dans la colonne du paysage (le logo et les deux boutons ne
|
||||
tiennent pas en 360 px) ; numéros des flèches en `muted` (clair) et
|
||||
`point-number` (sombre), parce que notre plateau les pose sur le fond et non
|
||||
sur le cadre ; chiffres alignés (`lining-nums`) en plus de `tabular-nums`.
|
||||
|
||||
**À faire dans `board-view.ts`** (hors périmètre) : filet intérieur des pions
|
||||
(classe `.checker-ring` prête), numéros sur le cadre, voile de cible en
|
||||
triangle, marque du dernier coup en barrette laiton à la base de la flèche,
|
||||
videau sur la barre, comme `docs/identite/charte/board.js`.
|
||||
@@ -0,0 +1,60 @@
|
||||
# Moteur pour l'analyse et les niveaux (1a)
|
||||
|
||||
- **Date** : 2026-11-03
|
||||
- **Étape** : 1a (`docs/etape-1.md`)
|
||||
- **Statut** : en cours
|
||||
- **Exécutant** : sous-agent general-purpose, dans un worktree
|
||||
|
||||
## Objectif
|
||||
|
||||
Donner au domaine tout ce dont l'étape 1 a besoin du moteur : niveaux de jeu de
|
||||
l'ordinateur, analyse d'un coup joué et d'une décision de videau, chance d'un
|
||||
jet. Tout vient de GNU Backgammon, comparé à gnubg de bureau.
|
||||
|
||||
## Consignes
|
||||
|
||||
ADR 0005, 0007 et la méthode de comparaison de `tests/reference/` (README).
|
||||
Ne pas modifier `engine/gnubg/` (copie fidèle). Pas de nouvelle dépendance.
|
||||
|
||||
1. **Niveaux de jeu** : les niveaux de GNU Backgammon (débutant, amateur,
|
||||
intermédiaire, avancé, expert, champion du monde…), avec leurs plis, filtres
|
||||
et bruit, repris des réglages prédéfinis de gnubg (pas inventés). Le bruit
|
||||
doit être déterministe (option « deterministic » de gnubg) pour que les
|
||||
tests soient reproductibles. API C (`engine.h`), `engine-cli`, adaptateur
|
||||
WebAssembly et interface `Engine` du domaine (ajout seulement : ne pas
|
||||
toucher au reste de `app/src/domain/game/`, qu'un autre agent modifie).
|
||||
2. **Analyse d'un coup joué** : pour une position, un jet et un coup joué,
|
||||
l'équité du coup joué, celle du meilleur coup, l'écart, et le rang du coup
|
||||
joué, au niveau d'analyse demandé (comme l'analyse de partie de gnubg).
|
||||
3. **Analyse d'une décision de videau** : équités sans double, double/prend,
|
||||
double/passe, et l'écart de la décision prise (double, pas de double,
|
||||
prendre, passer), comme gnubg.
|
||||
4. **Chance d'un jet** : la chance (luck) d'un jet au sens de gnubg, et son
|
||||
classement (très chanceux… très malchanceux) avec les seuils de gnubg.
|
||||
5. **Seuils d'appréciation** : exposer les seuils de gnubg (douteux, mauvais,
|
||||
très mauvais) comme constantes documentées ; l'interface les traduira.
|
||||
6. **Références** : ajouter des positions de référence évaluées par gnubg de
|
||||
bureau (Docker existant, `make reference`) pour l'analyse d'un coup, d'une
|
||||
décision de videau et la chance, comparées par `make test` (natif et
|
||||
WebAssembly) avec la tolérance existante. Les niveaux avec bruit : vérifier
|
||||
au moins que le coup choisi est reproductible et identique à celui de gnubg
|
||||
de bureau sur quelques positions.
|
||||
7. Glossaire : fiches pour les nouveaux termes (niveau, chance, appréciation,
|
||||
équité si absente…), reliées au code.
|
||||
8. Mesurer sous Node (`make bench` ou équivalent) le temps d'une analyse de
|
||||
coup au niveau prévu pour le conseil du maître (2 plis), pour vérifier
|
||||
qu'elle tient en arrière-plan sur le téléphone (repère : ADR 0009).
|
||||
|
||||
Machine ancienne : `-j2`, ne recompiler le moteur que si nécessaire.
|
||||
`make lint` et `make test` doivent passer. Commits fréquents ; `git add` avec
|
||||
chemins explicites ; ne pas pousser, pas de MR.
|
||||
|
||||
## Critères de réussite
|
||||
|
||||
- `engine-cli` répond aux nouvelles commandes en JSON.
|
||||
- Les nouvelles références concordent avec gnubg de bureau, natif et WebAssembly.
|
||||
- `make lint` et `make test` passent.
|
||||
|
||||
## Résultat
|
||||
|
||||
À remplir à la clôture.
|
||||
@@ -0,0 +1,57 @@
|
||||
# Parties enregistrées, branches et export SGF (1b)
|
||||
|
||||
- **Date** : 2026-11-03
|
||||
- **Étape** : 1b (`docs/etape-1.md`)
|
||||
- **Statut** : prévu (lancé après la fusion de 0e.1)
|
||||
- **Exécutant** : sous-agent general-purpose, dans un worktree
|
||||
|
||||
## Objectif
|
||||
|
||||
Garder toutes les parties du joueur sur le téléphone, avec leurs branches, et
|
||||
les exporter au format SGF de GNU Backgammon.
|
||||
|
||||
## Consignes
|
||||
|
||||
ADR 0007 (domaine pur, stockage derrière une interface), ADR 0008 (scénarios
|
||||
et glossaire). Aucune nouvelle dépendance : l'API IndexedDB du navigateur
|
||||
directement.
|
||||
|
||||
1. **Stockage IndexedDB** derrière l'interface de `app/src/storage/` : une
|
||||
liste de parties (identifiant, date de début et de dernière action,
|
||||
adversaire et niveau, score, état en cours ou finie, résultat) et le
|
||||
journal d'événements de chacune. Migration transparente de la partie
|
||||
en cours enregistrée aujourd'hui dans `localStorage`. Si IndexedDB est
|
||||
indisponible (navigation privée), repli sur le comportement actuel.
|
||||
2. **Branches** : le journal est déjà un arbre (événements avec parent,
|
||||
`goBackTo`). Exposer dans le domaine la liste des branches d'une partie
|
||||
(feuilles de l'arbre, avec le coup d'origine) et le passage de l'une à
|
||||
l'autre. « Rejouer d'ici » depuis un coup passé crée une branche ; la
|
||||
branche d'origine reste intacte et rechargeable. Pas d'écran d'analyse ici
|
||||
(1e) : un accès minimal suffit pour le test (menu « Mes parties » :
|
||||
reprendre une partie, voir ses branches).
|
||||
3. **Nouvelle partie annulable** : l'arbitrage UX (plan
|
||||
`2026-11-03-revision-ux.md`, point 3) prévoit « Reprendre l'ancienne »
|
||||
pendant 10 s. Fournir l'API de stockage qui le permet (reprendre une partie
|
||||
par son identifiant) ; l'interface viendra avec la révision UX.
|
||||
4. **Export SGF** : une partie (et ses branches en variantes) au format SGF
|
||||
de GNU Backgammon, téléchargeable depuis « Mes parties ». Vérification par
|
||||
le gnubg de bureau de référence (Docker existant) : le fichier exporté se
|
||||
charge sans erreur et ses positions successives sont les mêmes que celles
|
||||
du journal, sur des parties générées par les tests.
|
||||
5. Scénarios Gherkin : reprendre une partie parmi plusieurs, rejouer d'ici
|
||||
sans perdre l'original, exporter une partie. Fiches du glossaire pour les
|
||||
nouveaux termes (branche, SGF…).
|
||||
|
||||
`make lint`, `make test` et `make test-browser` passent (ajouter au test
|
||||
navigateur : deux parties enregistrées, reprise de la première après
|
||||
rechargement). Commits fréquents ; pas de push ni de MR.
|
||||
|
||||
## Critères de réussite
|
||||
|
||||
- Après rechargement, la liste des parties et chaque branche sont intactes.
|
||||
- Un SGF exporté se charge dans gnubg de bureau avec les mêmes positions.
|
||||
- Les trois commandes de test passent.
|
||||
|
||||
## Résultat
|
||||
|
||||
À remplir à la clôture.
|
||||
@@ -0,0 +1,63 @@
|
||||
# Révision UX : portrait stable, compte de points, menu, paysage « plateau = écran »
|
||||
|
||||
- **Date** : 2026-11-03
|
||||
- **Étape** : 0e.3 (feuille de route)
|
||||
- **Statut** : prévu (après la fusion de 0e.1, qui touche les mêmes fichiers)
|
||||
- **Exécutant** : sous-agent general-purpose, dans un worktree
|
||||
|
||||
## Objectif
|
||||
|
||||
Appliquer la revue UX (0e.2) telle qu'arbitrée par la session « identité » sur
|
||||
délégation de Pierre le 2026-11-03, et l'amendement de l'ADR 0012.
|
||||
|
||||
## Références
|
||||
|
||||
- Maquettes : https://claude.ai/artifact/CQw6vbDpi6XQqVWQa4sANe, rangées
|
||||
« Révision UX » en bas du canevas.
|
||||
- Règles : section « Interaction » du design system, copiée dans
|
||||
`docs/identite/charte/interaction.md` (durées, courbes, motifs de vibration,
|
||||
touches) et composant Icons (10 pictogrammes).
|
||||
- Rapport du designer UI : conservé dans la description de la MR qui clôt ce
|
||||
plan.
|
||||
|
||||
## Décisions à appliquer
|
||||
|
||||
1. **Portrait stable** (maquette « Portrait révisé ») :
|
||||
- une barre haute avec le symbole et trois pictogrammes, libellés de 13 px dessous : Aide, Plein écran, Menu ;
|
||||
- le bandeau de l'ordinateur, puis le plateau, puis le bandeau du joueur ;
|
||||
- une ligne réservée de 44 px : le dernier coup de l'ordinateur avec ↻, ou la ligne du maître ;
|
||||
- la consigne ;
|
||||
- une barre d'actions collée en bas, l'action secondaire à gauche et la principale à droite (« Jouer ce coup »). Toucher les dés valide aussi ;
|
||||
- la durée de calcul affichée disparaît.
|
||||
2. **Compte de points** :
|
||||
- en portrait, des bandeaux. Les chiffres sont en 20 px gras, alignés. Le bandeau de celui qui a la main est en accent-soft (« Vous · à vous de jouer »). L'écart s'écrit « mène de 8 », en muted, du côté de celui qui mène. Toucher le chiffre ouvre une bulle avec le texte de la fiche du glossaire (source unique, ADR 0008) ;
|
||||
- en paysage, des pastilles aux bouts de la barre.
|
||||
3. **Menu en feuille** qui monte du bas :
|
||||
- il contient Nouvelle partie, Plein écran, Aide et glossaire, Thème, et les interrupteurs Vibrations et « Écran allumé pendant la partie » ;
|
||||
- « Nouvelle partie » agit sans confirmation. Un bandeau « Nouvelle partie commencée. Reprendre l'ancienne » reste 10 s avec une barre de temps, et l'ancienne partie se restaure depuis son journal ;
|
||||
- plus de `window.confirm`.
|
||||
4. **Pas de passage automatique du tour** quand aucun coup n'est possible : les dés sont barrés avec le message, et le joueur passe en touchant les dés (ou Espace). Ajouter `overscroll-behavior: none` et `user-select: none`. L'écran reste allumé pendant une partie (Wake Lock), activé par défaut, avec un interrupteur.
|
||||
5. **Paysage, « le plateau est l'écran »** (ADR 0012 amendé) :
|
||||
- la vue s'applique dès la rotation, sur `100dvh` ;
|
||||
- la géométrie suit l'écran, par une fonction pure testée unitairement, d'après les formules du composant Board des maquettes ;
|
||||
- une colonne de commandes à gauche dans le cadre : menu en haut, videau au milieu, ↶ en bas, remplacé par ↻ quand le coup de l'ordinateur est montré ;
|
||||
- les sorties restent libres ;
|
||||
- numéros des flèches à 12 px au moins ;
|
||||
- la fin de partie s'affiche dans un panneau centré sur un voile ;
|
||||
- le plein écran s'ouvre sur un geste (pictogramme ou menu), avec `requestFullscreen` puis `screen.orientation.lock` et un repli par rotation CSS. Ce n'est pas le réglage par défaut de l'app installée.
|
||||
6. **Vibrations** (`navigator.vibrate`, si disponible) aux quatre moments, avec les motifs d'`interaction.md` : pion posé, pion frappé, double proposé par l'ordinateur, fin de partie. Elles sont activées par défaut, rien au lancer des dés, et elles doublent toujours un signal visible.
|
||||
7. **Pictogrammes** du composant Icons, en SVG dans le site (CSP : pas de style en ligne).
|
||||
8. **Consigne** : forme longue pendant les trois premières parties, puis forme courte.
|
||||
9. **Mouvement** :
|
||||
- durées de 90, 160 et 240 ms, avec la courbe cubic-bezier(0.2, 0, 0, 1) ;
|
||||
- l'ordinateur joue pion par pion : 160 ms par pion, 120 ms de pause ;
|
||||
- `prefers-reduced-motion` est respecté.
|
||||
10. **Clavier** (priorité basse) : prévoir dans l'architecture une table d'actions commune au toucher et au clavier. Les touches d'`interaction.md` peuvent venir dans une MR ultérieure.
|
||||
|
||||
## Consignes
|
||||
|
||||
ADR 0007 : ni le domaine ni le moteur ne changent pour la mise en page. ADR 0008 : scénarios Gherkin pour les comportements visibles nouveaux (nouvelle partie annulable, menu). Aucune nouvelle dépendance. `tests/browser/play.mjs` étendu aux deux orientations, avec des captures Playwright en 412×915, 915×412 et 844×320, en thème clair et sombre, rangées dans un dossier indiqué par la session principale pour la revue design. `make lint`, `make test` et `make test-browser` passent.
|
||||
|
||||
## Résultat
|
||||
|
||||
À remplir à la clôture.
|
||||
@@ -0,0 +1,65 @@
|
||||
# Saisie du coup, videau confirmé, pages de test du moteur retirées
|
||||
|
||||
- **Date** : 2026-11-03
|
||||
- **Étape** : 0e (retours du premier test de Pierre sur le téléphone)
|
||||
- **Statut** : en cours
|
||||
- **Exécutant** : sous-agent general-purpose, dans un worktree
|
||||
|
||||
## Objectif
|
||||
|
||||
Rendre la saisie du coup aussi confortable que dans CBG, d'après les retours de
|
||||
Pierre du 2026-11-03 (points 1, 6, 7, 8, 9 et 11 de son message), et retirer du
|
||||
site les pages de test du moteur.
|
||||
|
||||
## Consignes
|
||||
|
||||
Règles de code : ADR 0007 (domaine pur, aucune règle de backgammon
|
||||
réimplémentée : tout vient du moteur via le domaine). Glossaire et scénarios :
|
||||
ADR 0008 (chaque comportement visible a son scénario Gherkin dans
|
||||
`docs/scenarios/`, chaque nouveau terme sa fiche). Aucune nouvelle dépendance.
|
||||
|
||||
1. **Pages de test du moteur retirées** (« supprimer la partie test du moteur
|
||||
totalement ») : plus de `/moteur/` sur le site ni dans `make deploy`.
|
||||
`make bench` (mesure sous Node) reste : déplacer ce dont il a besoin hors de
|
||||
`web/`. Mettre à jour `tests/test_site.sh`, le Makefile, `deploy/`, le
|
||||
service worker, `docs/guide-test-etape-0.md` et ajouter un amendement daté à
|
||||
l'ADR 0009.
|
||||
2. **Un seul coup possible, le pion avance** : quand le joueur touche un pion
|
||||
(ou une case) et qu'une seule case d'arrivée est possible pour ce pion, le
|
||||
demi-coup est joué aussitôt, sans second toucher. S'il y en a plusieurs, la
|
||||
sélection actuelle reste.
|
||||
3. **Coup à valider, annulable** : quand tous les demi-coups sont joués (ou
|
||||
qu'aucun autre n'est possible), le coup n'est pas envoyé tout de suite. Le
|
||||
joueur le valide en touchant ses dés (ou un bouton « Valider »). Tant qu'il
|
||||
ne l'a pas validé, « Annuler » défait le dernier demi-coup, un par un. Le
|
||||
journal d'événements ne reçoit `MovePlayed` qu'à la validation.
|
||||
4. **Dés joués marqués** : chaque dé déjà utilisé par un demi-coup est
|
||||
visiblement estompé. Pour un double, quatre dés, marqués un par un.
|
||||
5. **Dés de l'adversaire touchables** : toucher la zone des dés de l'un ou
|
||||
l'autre côté du plateau lance les dés quand le joueur peut lancer (et valide
|
||||
le coup en attente de validation).
|
||||
6. **Confirmation du videau** : toucher le videau ou « Doubler » ouvre une
|
||||
confirmation courte (« Doubler à 2 ? L'ordinateur pourra prendre ou
|
||||
passer. » : Doubler / Annuler) avant d'envoyer le double.
|
||||
|
||||
Tests : scénarios Gherkin mis à jour ou ajoutés (validation du coup, annulation
|
||||
demi-coup par demi-coup, confirmation du videau), tests unitaires, et
|
||||
`tests/browser/play.mjs` adapté (Playwright est validé par Pierre). Lancer
|
||||
`make lint`, `make test` et `make test-browser`.
|
||||
|
||||
Rapporter : ce qui a été fait, les écarts, les résultats des trois commandes,
|
||||
et un brouillon de description de MR (7 sections, voir `docs/organisation.md`).
|
||||
Commits fréquents sur la branche du worktree ; ne pas pousser, ne pas ouvrir de
|
||||
MR : la session principale s'en charge.
|
||||
|
||||
## Critères de réussite
|
||||
|
||||
- `make lint`, `make test` et `make test-browser` passent.
|
||||
- Dans le navigateur : un pion à destination unique avance en un toucher ; un
|
||||
coup complet attend la validation ; « Annuler » défait un demi-coup à la
|
||||
fois ; les dés joués sont estompés ; le videau demande confirmation.
|
||||
- `https://…/moteur/` n'existe plus dans le site construit (`make site`).
|
||||
|
||||
## Résultat
|
||||
|
||||
À remplir à la clôture.
|
||||
@@ -30,6 +30,30 @@ Fonctionnalité: Jouer une partie contre l'ordinateur
|
||||
Quand ses dés sont tirés
|
||||
Alors il joue le coup recommandé par le moteur en moins de 2 secondes
|
||||
|
||||
Scénario: Aucun coup possible avec votre jet
|
||||
Il arrive que les dés ne permettent de bouger aucun pion. Votre jet reste
|
||||
affiché avec un message, et le tour ne passe à l'ordinateur qu'une fois
|
||||
que vous avez touché « Continuer ».
|
||||
|
||||
Étant donné que votre pion sur la barre ne peut pas rentrer
|
||||
Quand vous lancez les dés
|
||||
Alors aucun coup n'est possible avec votre jet
|
||||
Et c'est toujours à vous
|
||||
Quand vous continuez
|
||||
Alors c'est ensuite au tour de l'ordinateur
|
||||
|
||||
Scénario: Voir le coup de l'ordinateur
|
||||
Après chaque coup de l'ordinateur, ses pions glissent un par un sur le
|
||||
plateau. Restent ensuite, jusqu'à votre prochaine action, une flèche par
|
||||
pion déplacé, ses pions arrivés entourés et un pion fantôme là où ils
|
||||
étaient. Le bouton « Revoir » rejoue son coup.
|
||||
|
||||
Étant donné qu'un de vos pions est seul à portée de l'ordinateur
|
||||
Quand l'ordinateur a joué
|
||||
Alors son coup vous est montré case par case
|
||||
Et votre pion frappé part sur la barre
|
||||
Et vous pouvez revoir son coup
|
||||
|
||||
Scénario: Prendre le double de l'ordinateur
|
||||
Étant donné que l'ordinateur vous propose de doubler
|
||||
Quand vous prenez
|
||||
|
||||
+140
-5
@@ -101,7 +101,7 @@ async function waitForPlayer() {
|
||||
const b = document.getElementById(id);
|
||||
return !b.hidden && !b.disabled;
|
||||
};
|
||||
for (const id of ["again", "take", "roll"]) {
|
||||
for (const id of ["again", "take", "skip", "roll"]) {
|
||||
if (usable(id)) {
|
||||
return id;
|
||||
}
|
||||
@@ -130,6 +130,101 @@ async function waitForPlayer() {
|
||||
throw new Error(`rien à faire pour le joueur depuis 60 s : ${screen}`);
|
||||
}
|
||||
|
||||
/** Le dernier coup de l'ordinateur, s'il est la dernière chose jouée (fin de
|
||||
* partie mise à part) et qu'il a déplacé des pions ; sinon null. */
|
||||
function computerMoveToShow(journal) {
|
||||
const last = journal.events.filter((e) => e.type !== "GameEnded").at(-1);
|
||||
return last?.type === "MovePlayed" && last.side === "computer" && last.halfMoves.length > 0
|
||||
? last
|
||||
: null;
|
||||
}
|
||||
|
||||
/** Ce que montre le plateau du coup de l'ordinateur. */
|
||||
async function shownTraces() {
|
||||
return page.evaluate(() => ({
|
||||
arrows: document.querySelectorAll(".trace-arrow").length,
|
||||
landed: document.querySelectorAll(".trace-landed").length,
|
||||
ghosts: document.querySelectorAll(".trace-ghost").length,
|
||||
hits: document.querySelectorAll(".trace-hit").length,
|
||||
replay: !document.getElementById("replay").hidden,
|
||||
}));
|
||||
}
|
||||
|
||||
/** Après un tour de l'ordinateur : ses traces sont là si son coup est la
|
||||
* dernière chose jouée, et rien sinon. */
|
||||
async function checkTraces() {
|
||||
const journal = await savedJournal();
|
||||
const move = computerMoveToShow(journal);
|
||||
const traces = await shownTraces();
|
||||
const last = journal.events.filter((e) => e.type !== "GameEnded").at(-1);
|
||||
if (last?.type === "MovePlayed" && last.side === "computer" && last.halfMoves.length === 0) {
|
||||
// L'ordinateur n'a pas pu jouer : son jet reste barré, avec un message.
|
||||
const info = await page.textContent("#info");
|
||||
const blocked = await page.locator(".die-blocked").count();
|
||||
check(/^L'ordinateur ne peut pas jouer \d-\d\.$/.test(info ?? "") && blocked === 2,
|
||||
`jet de l'ordinateur sans coup mal montré : « ${info} », ${blocked} dés barrés`);
|
||||
log(`l'ordinateur ne peut pas jouer : « ${info} »`);
|
||||
}
|
||||
if (move === null) {
|
||||
check(traces.arrows + traces.landed + traces.ghosts === 0 && !traces.replay,
|
||||
`traces sans coup de l'ordinateur à montrer : ${JSON.stringify(traces)}`);
|
||||
return null;
|
||||
}
|
||||
check(traces.arrows === move.halfMoves.length && traces.landed > 0 && traces.ghosts > 0 &&
|
||||
traces.replay, `coup de l'ordinateur mal montré : ${JSON.stringify(traces)}`);
|
||||
return traces;
|
||||
}
|
||||
|
||||
/** La zone à toucher d'une action du plateau (dés ou videau) : un bouton
|
||||
* accessible, d'au moins 44 px de côté. Renvoie son sélecteur. */
|
||||
async function touchTarget(action, label) {
|
||||
const selector = `[data-action="${action}"]`;
|
||||
const zone = page.locator(selector);
|
||||
check((await zone.count()) === 1, `zone ${action} absente du plateau`);
|
||||
check((await zone.getAttribute("role")) === "button" &&
|
||||
(await zone.getAttribute("aria-label")) === label, `zone ${action} sans rôle accessible`);
|
||||
const box = await zone.boundingBox();
|
||||
check(box.width >= 44 && box.height >= 44,
|
||||
`zone ${action} trop petite : ${Math.round(box.width)} x ${Math.round(box.height)} px`);
|
||||
return selector;
|
||||
}
|
||||
|
||||
/** Observe la page pendant ms millisecondes : le plus grand nombre
|
||||
* d'animations en cours à la fois, et le plus petit nombre de flèches. */
|
||||
async function watchReplay(ms) {
|
||||
let animations = 0;
|
||||
let arrows = Infinity;
|
||||
for (const end = Date.now() + ms; Date.now() < end; ) {
|
||||
const seen = await page.evaluate(() => ({
|
||||
animations: document.getAnimations().length,
|
||||
arrows: document.querySelectorAll(".trace-arrow").length,
|
||||
}));
|
||||
animations = Math.max(animations, seen.animations);
|
||||
arrows = Math.min(arrows, seen.arrows);
|
||||
await new Promise((resolve) => setTimeout(resolve, 40));
|
||||
}
|
||||
return { animations, arrows };
|
||||
}
|
||||
|
||||
/** « Revoir » : rejoue le coup (sans animation en mouvement réduit), puis
|
||||
* remet les mêmes traces. */
|
||||
async function checkReplay(traces, reducedMotion) {
|
||||
await page.emulateMedia({ reducedMotion: reducedMotion ? "reduce" : "no-preference" });
|
||||
await page.click("#replay");
|
||||
const seen = await watchReplay(300);
|
||||
if (reducedMotion) {
|
||||
check(seen.animations === 0, "Revoir en mouvement réduit : des pions glissent quand même");
|
||||
} else {
|
||||
check(seen.animations > 0 && seen.arrows === 0,
|
||||
`Revoir : pas d'animation sans traces (${JSON.stringify(seen)})`);
|
||||
}
|
||||
await waitForPlayer();
|
||||
const after = await shownTraces();
|
||||
check(JSON.stringify(after) === JSON.stringify(traces),
|
||||
`traces différentes après Revoir : ${JSON.stringify(after)}`);
|
||||
await page.emulateMedia({ reducedMotion: "no-preference" });
|
||||
}
|
||||
|
||||
// Garde-fou : une étape bloquée plus de deux minutes arrête tout, avec une
|
||||
// capture et l'état de l'écran.
|
||||
let lastStep = Date.now();
|
||||
@@ -157,10 +252,30 @@ let doubled = false;
|
||||
let selectionShot = false;
|
||||
let reloaded = false;
|
||||
let computerMs = [];
|
||||
let tracesSeen = 0;
|
||||
let replays = 0;
|
||||
let skips = 0;
|
||||
let rolls = 0;
|
||||
for (let actions = 0; ; actions++) {
|
||||
check(actions < 800, "la partie ne finit pas");
|
||||
const todo = await waitForPlayer();
|
||||
step(`partie, action ${actions}`);
|
||||
// Le coup de l'ordinateur est montré jusqu'à l'action suivante du
|
||||
// joueur ; deux fois, on le revoit (la seconde en mouvement réduit).
|
||||
const traces = await checkTraces();
|
||||
if (traces !== null) {
|
||||
tracesSeen++;
|
||||
if (replays < 2) {
|
||||
replays++;
|
||||
await checkReplay(traces, replays === 2);
|
||||
if (replays === 1) {
|
||||
await shot("coup-adverse");
|
||||
}
|
||||
log(`coup de l'ordinateur revu${replays === 2 ? " en mouvement réduit" : ""} : ` +
|
||||
`${traces.arrows} flèches, ${traces.landed} pions entourés, ` +
|
||||
`${traces.ghosts} fantômes, ${traces.hits} pions frappés signalés`);
|
||||
}
|
||||
}
|
||||
if (process.env.TRACE) {
|
||||
log(`${todo} : ${await page.textContent("#info")} / ${await page.textContent("#prompt")}`);
|
||||
}
|
||||
@@ -172,22 +287,37 @@ for (let actions = 0; ; actions++) {
|
||||
if (todo === "again") {
|
||||
break;
|
||||
}
|
||||
if (todo === "take") {
|
||||
if (todo === "skip") {
|
||||
// Aucun coup possible : le jet reste affiché, barré, avec un
|
||||
// message, et le tour ne passe qu'en touchant « Continuer ».
|
||||
const info = await page.textContent("#info");
|
||||
const blocked = await page.locator(".die-blocked").count();
|
||||
check(/^Aucun coup possible avec \d-\d\.$/.test(info ?? "") && blocked === 2,
|
||||
`aucun coup possible mal montré : « ${info} », ${blocked} dés barrés`);
|
||||
skips++;
|
||||
log(`aucun coup possible : « ${info} », vous continuez`);
|
||||
await page.click("#skip");
|
||||
} else if (todo === "take") {
|
||||
log("l'ordinateur double : vous prenez");
|
||||
await shot("videau-propose");
|
||||
await page.click("#take");
|
||||
} else if (todo === "roll") {
|
||||
if (!doubled && (await page.isEnabled("#double"))) {
|
||||
doubled = true;
|
||||
log("vous doublez");
|
||||
await page.click("#double");
|
||||
log("vous doublez en touchant le videau");
|
||||
await page.click(await touchTarget("double", "Doubler"));
|
||||
await waitForPlayer();
|
||||
const answer = (await page.textContent("#info")) || (await page.textContent("#prompt"));
|
||||
log(`réponse : ${answer}`);
|
||||
continue;
|
||||
}
|
||||
await page.click("#roll");
|
||||
// Une fois sur deux, en touchant les dés en attente plutôt que le
|
||||
// bouton.
|
||||
rolls++;
|
||||
await page.click(rolls % 2 === 0 ? await touchTarget("roll", "Lancer les dés") : "#roll");
|
||||
} else {
|
||||
check((await page.locator("[data-action]").count()) === 0,
|
||||
"dés ou videau touchables pendant que vous jouez votre coup");
|
||||
await page.click(".hit.can-move");
|
||||
await page.waitForSelector(".hit.can-land");
|
||||
if (!selectionShot) {
|
||||
@@ -223,6 +353,11 @@ log(
|
||||
);
|
||||
check(countMoves(journal, "player") >= 3 && countMoves(journal, "computer") >= 3, "trop peu de coups");
|
||||
check(reloaded, "la reprise après rechargement n'a pas été essayée");
|
||||
check(replays === 2, "le coup de l'ordinateur n'a pas été revu");
|
||||
check(doubled && rolls >= 2, "dés ou videau jamais touchés sur le plateau");
|
||||
log(`dés lancés ${Math.floor(rolls / 2)} fois en les touchant, videau touché pour doubler`);
|
||||
log(`coup de l'ordinateur montré ${tracesSeen} fois, et effacé à chaque action suivante`);
|
||||
log(skips > 0 ? `« Continuer » demandé ${skips} fois` : "aucun jet sans coup possible dans cette partie");
|
||||
if (computerMs.length > 0) {
|
||||
log(`temps affichés des tours de l'ordinateur : max ${Math.max(...computerMs)} ms, ` +
|
||||
`${computerMs.length} tours`);
|
||||
|
||||
+49
-1
@@ -444,13 +444,61 @@ body.busy .prompt {
|
||||
.count-computer { fill: var(--count-computer); }
|
||||
|
||||
.target-zone { fill: var(--target); }
|
||||
.last-move { fill: var(--last-move); }
|
||||
/* Le dernier coup de l'ordinateur (couleur laiton, charte) : une flèche par
|
||||
* demi-coup, les pions arrivés et frappés entourés, un pion fantôme là où
|
||||
* un pion est parti. */
|
||||
.trace-arrow {
|
||||
stroke: var(--last-move);
|
||||
stroke-width: 5;
|
||||
stroke-linecap: round;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.trace-head { fill: var(--last-move); }
|
||||
|
||||
.trace-landed,
|
||||
.trace-hit {
|
||||
fill: none;
|
||||
stroke: var(--last-move);
|
||||
stroke-width: 4;
|
||||
}
|
||||
|
||||
.trace-hit { stroke-dasharray: 6 4; }
|
||||
|
||||
.trace-ghost {
|
||||
fill: none;
|
||||
stroke: var(--last-move);
|
||||
stroke-width: 2.5;
|
||||
stroke-dasharray: 5 4;
|
||||
}
|
||||
|
||||
.die-player { fill: var(--die-player); stroke: var(--checker-player-edge); stroke-width: 1.5; }
|
||||
.die-computer { fill: var(--die-computer); stroke: var(--checker-computer-edge); stroke-width: 1.5; }
|
||||
.pip-player { fill: var(--pip-player); }
|
||||
.pip-computer { fill: var(--pip-computer); }
|
||||
|
||||
/* Dés du joueur en attente d'être lancés, et videau quand il peut doubler :
|
||||
* ils se touchent comme les boutons. */
|
||||
.die-waiting rect {
|
||||
stroke: var(--highlight);
|
||||
stroke-width: 2.5;
|
||||
stroke-dasharray: 5 4;
|
||||
}
|
||||
|
||||
.cube.can-double .cube-face {
|
||||
stroke: var(--highlight);
|
||||
stroke-width: 3;
|
||||
}
|
||||
|
||||
/* Jet sans coup possible : dés estompés et barrés. */
|
||||
.die-blocked { opacity: 0.5; }
|
||||
|
||||
.die-strike {
|
||||
stroke: var(--text);
|
||||
stroke-width: 3;
|
||||
stroke-linecap: round;
|
||||
}
|
||||
|
||||
.cube-face { fill: var(--cube); stroke: var(--checker-player-edge); stroke-width: 2; }
|
||||
|
||||
.cube-value {
|
||||
|
||||
@@ -44,6 +44,8 @@
|
||||
<button id="take" type="button" hidden></button>
|
||||
<button id="drop" type="button" class="secondary" hidden></button>
|
||||
<button id="cancel" type="button" class="secondary" hidden></button>
|
||||
<button id="skip" type="button" hidden></button>
|
||||
<button id="replay" type="button" class="secondary" hidden></button>
|
||||
<button id="again" type="button" hidden></button>
|
||||
</div>
|
||||
<noscript><p>Ustagammon a besoin de JavaScript.</p></noscript>
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
// Sur les positions de référence (tests/reference/positions.json) : temps du
|
||||
// meilleur coup à 0 et 2 plis pour les positions avec dés, temps de la
|
||||
// décision de videau à 2 plis pour les autres. Le rapport compare aux seuils
|
||||
// de l'étape 0b (docs/etape-0.md).
|
||||
// de l'étape 0b (ADR 0009).
|
||||
|
||||
// Seuils de l'étape 0b. Un temps respecte son seuil si son maximum, sur
|
||||
// toutes les positions, est en dessous (la médiane est donnée en plus).
|
||||
|
||||
Reference in New Issue
Block a user