Compare commits

..
7 Commits
37 changed files with 1083 additions and 819 deletions
+1 -1
View File
@@ -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`, le travail en cours dans `docs/feuille-de-route.md`.
Le cadrage complet est dans `docs/cadrage.md`, l'étape en cours dans `docs/etape-0.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
+1 -3
View File
@@ -107,8 +107,6 @@ lint: ## Vérifie le style et les erreurs courantes
deploy: site ## Sert l'application sur le réseau local (Ctrl+C pour arrêter)
@ip="$$(ip -4 route get 1.1.1.1 2>/dev/null | awk '{ for (i = 1; i < NF; i++) if ($$i == "src") print $$(i + 1) }')"; \
echo "Application : http://$${ip:-ADRESSE-DU-PC}:$(WEB_PORT)/"; \
echo "Test du moteur : http://$${ip:-ADRESSE-DU-PC}:$(WEB_PORT)/moteur/"; \
echo "Page de mesure : http://$${ip:-ADRESSE-DU-PC}:$(WEB_PORT)/moteur/bench.html"; \
echo "À ouvrir depuis le téléphone, sur le même Wi-Fi. Ctrl+C pour arrêter."
@docker run --rm --init -p $(WEB_PORT):8080 -v "$(CURDIR)/$(BUILD_DIR)/site:/site:ro" \
$(BUILD_C_IMAGE) python3 -m http.server 8080 --directory /site
@@ -123,7 +121,7 @@ SITE_ICONS := icone.svg icone-192.png icone-512.png icone-maskable-192.png icone
# Le site tel qu'il sera publié, reconstruit entièrement à chaque appel :
# - web/ à la racine : la page de l'application, son style, sa police, le
# manifeste, le Web Worker du moteur, et les pages de test sous moteur/ ;
# manifeste et le Web Worker du moteur ;
# - sous icons/, le logo et les icônes de la charte, copiés depuis
# docs/identite/logo/ (une seule source, ADR 0010) ;
# - l'application compilée (build/web/src/) sous app/ ;
+113 -24
View File
@@ -79,9 +79,10 @@ export class Game {
readonly #dice: DiceSource;
readonly #ply: Ply;
#journal: GameJournal = EMPTY_JOURNAL;
// Coup du joueur en cours de saisie : pas encore dans le journal.
#pending: HalfMove[] = [];
#pendingPosition: PositionId | null = null;
// Coup du joueur en cours de saisie : pas encore dans le journal (il n'y
// entre qu'à sa validation, confirmMove). Pour chaque demi-coup saisi, la
// position après lui, et si le coup est alors complet, selon le moteur.
#pending: PendingHalfMove[] = [];
private constructor(engine: Engine, options: GameOptions) {
this.#engine = engine;
@@ -141,12 +142,37 @@ export class Game {
/** Les demi-coups déjà saisis du coup en cours. */
get pendingHalfMoves(): readonly HalfMove[] {
return this.#pending;
return this.#pending.map((p) => p.halfMove);
}
/** Les demi-coups saisis forment un coup complet (le moteur le dit) : il
* n'attend plus que d'être validé (confirmMove) ou défait
* (undoLastHalfMove). */
get moveComplete(): boolean {
return this.#pending.at(-1)?.complete === true;
}
/** La position à afficher : après les demi-coups déjà saisis. */
get displayedPosition(): PositionId {
return this.#pendingPosition ?? this.state.position;
return this.#pending.at(-1)?.position ?? this.state.position;
}
/**
* Les dés du jet à jouer (quatre pour un double), et pour chacun s'il est
* déjà utilisé par un demi-coup saisi ; liste vide hors de la saisie d'un
* coup du joueur. Un demi-coup utilise le dé qui l'a produit dans
* nextHalfMoves (de from vers from - dé, ou vers la sortie) : aucune
* règle n'est jugée ici, la légalité vient du moteur.
*/
get diceToPlay(): DieToPlay[] {
const state = this.state;
if (state.turn !== "player" || state.phase !== "moving" || state.roll === null) {
return [];
}
const [a, b] = state.roll;
const dice = a === b ? [a, a, a, a] : [a, b];
const used = assignDice(dice, this.pendingHalfMoves);
return dice.map((value, i) => ({ value, used: used[i] === true }));
}
/** Le plateau affiché (après les demi-coups déjà saisis), décodé par le
@@ -205,7 +231,7 @@ export class Game {
*/
async nextHalfMoves(): Promise<HalfMove[]> {
const state = this.state;
if (state.turn !== "player" || state.phase !== "moving") {
if (state.turn !== "player" || state.phase !== "moving" || this.moveComplete) {
return [];
}
const roll = rollToPlay(state);
@@ -220,7 +246,7 @@ export class Game {
const moves = [...candidates.values()];
const checks = await Promise.all(
moves.map((h) =>
this.#engine.checkHalfMoves(state.position, roll, [...this.#pending, h]),
this.#engine.checkHalfMoves(state.position, roll, [...this.pendingHalfMoves, h]),
),
);
return moves.filter((_, i) => checks[i]?.valid === true);
@@ -274,34 +300,51 @@ export class Game {
/**
* Le joueur déplace un pion (un dé). Renvoie false si ce demi-coup ne
* mène à aucun coup légal : il est refusé et rien ne change. Quand les
* demi-coups forment un coup complet, le coup est joué.
* mène à aucun coup légal (ou si le coup est déjà complet) : il est
* refusé et rien ne change. Le coup n'est pas joué pour autant : une fois
* complet (moveComplete), il attend d'être validé (confirmMove).
*/
async playHalfMove(halfMove: HalfMove): Promise<boolean> {
const state = this.#expect("player", "moving");
const halfMoves = [...this.#pending, halfMove];
const check = await this.#engine.checkHalfMoves(
state.position,
rollToPlay(state),
halfMoves,
);
if (this.moveComplete) {
return false;
}
const check = await this.#engine.checkHalfMoves(state.position, rollToPlay(state), [
...this.pendingHalfMoves,
halfMove,
]);
if (!check.valid || check.position === null) {
return false;
}
if (check.complete) {
this.cancelHalfMoves();
await this.#playMove("player", halfMoves, check.position);
} else {
this.#pending = halfMoves;
this.#pendingPosition = check.position;
}
this.#pending = [
...this.#pending,
{ halfMove, position: check.position, complete: check.complete },
];
return true;
}
/** Oublie les demi-coups saisis du coup en cours. */
/** Défait le dernier demi-coup saisi du coup en cours (rien s'il n'y en a
* pas) : il n'a jamais été dans le journal. */
undoLastHalfMove(): void {
this.#pending = this.#pending.slice(0, -1);
}
/** Oublie tous les demi-coups saisis du coup en cours. */
cancelHalfMoves(): void {
this.#pending = [];
this.#pendingPosition = null;
}
/** Le joueur valide son coup complet : il entre dans le journal
* (MovePlayed) et le tour passe. Erreur si le coup n'est pas complet. */
async confirmMove(): Promise<void> {
this.#expect("player", "moving");
const last = this.#pending.at(-1);
if (last?.complete !== true) {
throw new Error("le coup n'est pas complet : jouez encore");
}
const halfMoves = this.pendingHalfMoves;
this.cancelHalfMoves();
await this.#playMove("player", halfMoves, last.position);
}
/** Le joueur double, avant de lancer les dés ; l'ordinateur prend ou passe
@@ -454,6 +497,52 @@ export class Game {
}
}
/** Un dé du jet à jouer, et s'il est déjà utilisé par un demi-coup saisi. */
export interface DieToPlay {
readonly value: number;
readonly used: boolean;
}
/** Un demi-coup saisi, pas encore validé. */
interface PendingHalfMove {
readonly halfMove: HalfMove;
/** La position après lui, vue du joueur. */
readonly position: PositionId;
/** Les demi-coups saisis jusqu'à lui forment un coup complet. */
readonly complete: boolean;
}
/**
* Quels dés les demi-coups ont utilisés : chaque demi-coup reçoit un dé
* différent qui le produit (de from vers from - dé, ou vers la sortie si le
* dé dépasse), comme les candidats de nextHalfMoves. Les dés sont essayés du
* plus petit au plus grand : un pion sorti l'est avec le plus petit dé
* suffisant. Si aucune répartition ne convient, les premiers dés sont
* marqués, un par demi-coup.
*/
function assignDice(dice: readonly number[], halfMoves: readonly HalfMove[]): boolean[] {
const order = dice.map((_, i) => i).sort((i, j) => (dice[i] ?? 0) - (dice[j] ?? 0));
const used = dice.map(() => false);
const fits = (h: HalfMove, die: number) => Math.max(h.from - die, OFF) === h.to;
const place = (k: number): boolean => {
const h = halfMoves[k];
if (h === undefined) {
return true;
}
for (const i of order) {
if (!used[i] && fits(h, dice[i] ?? 0)) {
used[i] = true;
if (place(k + 1)) {
return true;
}
used[i] = false;
}
}
return false;
};
return place(0) ? used : dice.map((_, i) => i < halfMoves.length);
}
/** Le jet à jouer, en phase moving. */
function rollToPlay(state: GameState): Roll {
if (state.roll === null) {
+115 -23
View File
@@ -13,8 +13,22 @@ import type { GameJournal } from "../domain/game/events.js";
import type { Game, MoveSteps, SideBoards } from "../domain/game/game.js";
import { formatHalfMoves, type HalfMove } from "../domain/game/position.js";
import { otherSide, type Side } from "../domain/game/side.js";
import { boardModel, moveTargets, moveTrace, type Place, placeOf } from "./board-model.js";
import { checkerSpot, NO_MARKS, renderBoard, type ShownDie, slideChecker } from "./board-view.js";
import {
boardModel,
moveTargets,
moveTrace,
onlyHalfMove,
type Place,
placeOf,
} from "./board-model.js";
import {
checkerSpot,
type DiceAction,
NO_MARKS,
renderBoard,
type ShownDie,
slideChecker,
} from "./board-view.js";
import { showHelp } from "./help.js";
import { TEXTS } from "./texts.js";
@@ -51,6 +65,7 @@ export function startApp(options: AppOptions): void {
double: byId<HTMLButtonElement>("double"),
take: byId<HTMLButtonElement>("take"),
drop: byId<HTMLButtonElement>("drop"),
confirm: byId<HTMLButtonElement>("confirm"),
cancel: byId<HTMLButtonElement>("cancel"),
skip: byId<HTMLButtonElement>("skip"),
replay: byId<HTMLButtonElement>("replay"),
@@ -61,12 +76,19 @@ export function startApp(options: AppOptions): void {
game: byId("game"),
help: byId("help"),
helpContent: byId("help-content"),
doubleDialog: byId<HTMLDialogElement>("double-dialog"),
doubleQuestion: byId("double-question"),
doubleConfirm: byId<HTMLButtonElement>("double-confirm"),
doubleCancel: byId<HTMLButtonElement>("double-cancel"),
};
ui.board.setAttribute("aria-label", TEXTS.boardLabel);
ui.roll.textContent = TEXTS.roll;
ui.double.textContent = TEXTS.double;
ui.take.textContent = TEXTS.take;
ui.drop.textContent = TEXTS.drop;
ui.confirm.textContent = TEXTS.confirm;
ui.doubleConfirm.textContent = TEXTS.double;
ui.doubleCancel.textContent = TEXTS.cancel;
ui.cancel.textContent = TEXTS.cancel;
ui.skip.textContent = TEXTS.skip;
ui.replay.textContent = TEXTS.replay;
@@ -131,9 +153,9 @@ export function startApp(options: AppOptions): void {
shownMove === null
? null
: moveTrace(shownMove.side, shownMove.halfMoves, shownMove.hits),
// Mêmes conditions que les boutons « Lancer les dés » et
// « Doubler ».
canRoll: !busy && state.turn === "player" && state.phase === "rolling",
// Mêmes conditions que les boutons « Lancer les dés »,
// « Valider » et « Doubler ».
diceAction: busy ? null : diceAction(game),
canDouble: !busy && canDouble,
});
}
@@ -156,6 +178,7 @@ export function startApp(options: AppOptions): void {
show(ui.roll, player && state.phase === "rolling");
show(ui.double, player && state.phase === "rolling");
ui.double.disabled = busy || !canDouble;
show(ui.confirm, player && state.phase === "moving" && game.moveComplete);
show(ui.cancel, player && state.phase === "moving" && !stuck);
show(ui.skip, stuck);
ui.cancel.disabled = busy || game.pendingHalfMoves.length === 0;
@@ -168,6 +191,7 @@ export function startApp(options: AppOptions): void {
ui.roll,
ui.take,
ui.drop,
ui.confirm,
ui.skip,
ui.replay,
ui.again,
@@ -304,11 +328,22 @@ export function startApp(options: AppOptions): void {
await g.skipTurn();
});
ui.double.onclick = () =>
act(async (g) => {
// Doubler demande confirmation : un double proposé ne se reprend pas.
ui.double.onclick = () => {
if (busy || game === null || !canDouble) {
return;
}
ui.doubleQuestion.textContent = TEXTS.confirmDouble(2 * game.state.cube.value);
ui.doubleDialog.showModal();
};
ui.doubleCancel.onclick = () => ui.doubleDialog.close();
ui.doubleConfirm.onclick = () => {
ui.doubleDialog.close();
void act(async (g) => {
await g.double();
info = g.state.phase === "over" ? "" : TEXTS.computerTakes(g.state.cube.value);
});
};
ui.take.onclick = () =>
act((g) => {
@@ -322,12 +357,23 @@ export function startApp(options: AppOptions): void {
info = "";
});
// Le coup saisi, complet, entre dans la partie : le tour passe.
ui.confirm.onclick = () =>
act(async (g) => {
info = "";
await g.confirmMove();
});
// Annuler défait le dernier demi-coup saisi, un par un. L'écran est
// occupé le temps que le moteur recalcule les coups possibles.
ui.cancel.onclick = () => {
if (!busy && game !== null) {
game.cancelHalfMoves();
game.undoLastHalfMove();
selected = null;
shownMove = null;
void refresh();
busy = true;
render();
void settle(false);
}
};
@@ -344,15 +390,30 @@ export function startApp(options: AppOptions): void {
ui.newGame.onclick = newGame;
ui.again.onclick = newGame;
// Toucher le plateau : un pion jouable, puis une de ses cases d'arrivée.
/** Joue un demi-coup du joueur ; le coup attend ensuite sa suite ou sa
* validation. */
function playHalfMove(halfMove: HalfMove): void {
void act(async (g) => {
info = "";
await g.playHalfMove(halfMove);
});
}
// Toucher le plateau : un pion jouable, puis une de ses cases d'arrivée
// (ou aucune, s'il n'en a qu'une : il y va tout de suite).
ui.board.addEventListener("click", (event) => {
const target = event.target as Element | null;
// Toucher les dés en attente ou le videau : comme leurs boutons.
// Toucher les dés (d'un côté ou de l'autre du plateau) ou le videau :
// comme leurs boutons.
const action = target?.closest("[data-action]")?.getAttribute("data-action");
if (action === "roll" && !ui.roll.hidden && !ui.roll.disabled) {
ui.roll.click();
return;
}
if (action === "confirm" && !ui.confirm.hidden && !ui.confirm.disabled) {
ui.confirm.click();
return;
}
if (action === "double" && !ui.double.hidden && !ui.double.disabled) {
ui.double.click();
return;
@@ -363,10 +424,12 @@ export function startApp(options: AppOptions): void {
}
const halfMove = selected === null ? undefined : targets.get(selected)?.get(place);
if (halfMove !== undefined) {
void act(async (g) => {
info = "";
await g.playHalfMove(halfMove);
});
playHalfMove(halfMove);
return;
}
const only = place === selected ? null : onlyHalfMove(targets, place);
if (only !== null) {
playHalfMove(only);
return;
}
selected = targets.has(place) && place !== selected ? place : null;
@@ -405,6 +468,7 @@ export function startApp(options: AppOptions): void {
ui.double,
ui.take,
ui.drop,
ui.confirm,
ui.cancel,
ui.skip,
ui.replay,
@@ -452,19 +516,44 @@ function rollBefore(game: Game): readonly number[] {
return rolled?.type === "DiceRolled" ? rolled.roll : [];
}
/** Les dés à montrer : le dernier jet, de la couleur de qui l'a lancé
* (au jet d'ouverture, un dé chacun). Ils sont barrés si le jet ne permet
* aucun coup : le joueur est bloqué (stuck), ou l'ordinateur vient de passer
* son tour sans bouger. */
/** Ce que fait un toucher sur les dés : lancer quand le joueur peut lancer,
* valider quand son coup est complet. */
function diceAction(game: Game): DiceAction | null {
const state = game.state;
if (state.turn !== "player") {
return null;
}
if (state.phase === "rolling") {
return "roll";
}
return state.phase === "moving" && game.moveComplete ? "confirm" : null;
}
/** Les dés à montrer : le dernier jet, de la couleur de qui l'a lancé (au
* jet d'ouverture, un dé chacun ; quatre pour un double). Pendant que le
* joueur saisit son coup, chaque dé déjà utilisé est estompé. Ils sont
* barrés si le jet ne permet aucun coup : le joueur est bloqué (stuck), ou
* l'ordinateur vient de passer son tour sans bouger. */
function shownDice(game: Game, stuck: boolean): ShownDie[] {
const toPlay = game.diceToPlay;
const rolled = game.events.findLast((e) => e.type === "DiceRolled");
if (rolled?.type !== "DiceRolled") {
return [];
}
const blocked = stuck || computerCouldNotMove(game);
const sides: Side[] =
rolled.side === null ? ["player", "computer"] : [rolled.side, rolled.side];
return rolled.roll.map((value, i) => ({ value, side: sides[i] ?? "player", blocked }));
// Au jet d'ouverture, un dé chacun.
const sideOf = (i: number): Side => rolled.side ?? (i === 0 ? "player" : "computer");
if (toPlay.length > 0) {
return toPlay.map((die, i) => ({
value: die.value,
side: sideOf(i),
blocked: stuck,
used: die.used,
}));
}
const blocked = computerCouldNotMove(game);
const [a, b] = rolled.roll;
const values = a === b ? [a, a, a, a] : [a, b];
return values.map((value, i) => ({ value, side: sideOf(i), blocked, used: false }));
}
/** La dernière chose jouée (fin de partie mise à part) est un tour de
@@ -519,6 +608,9 @@ function prompt(game: Game, selecting: boolean, canDouble: boolean): string {
if (state.phase === "rolling") {
return canDouble ? TEXTS.yourRoll : TEXTS.yourRollNoDouble;
}
if (state.phase === "moving" && game.moveComplete) {
return TEXTS.confirmOrUndo;
}
if (state.phase === "moving" && state.roll !== null) {
return selecting ? TEXTS.pickTarget : TEXTS.yourMove(state.roll);
}
+21
View File
@@ -82,6 +82,19 @@ export function moveTargets(halfMoves: readonly HalfMove[]): Map<Place, Map<Plac
return targets;
}
/** Le seul demi-coup possible depuis place, s'il n'a qu'une case d'arrivée
* (le pion y va dès qu'on le touche) ; null sinon. */
export function onlyHalfMove(
targets: ReadonlyMap<Place, ReadonlyMap<Place, HalfMove>>,
place: Place,
): HalfMove | null {
const tos = targets.get(place);
if (tos === undefined || tos.size !== 1) {
return null;
}
return [...tos.values()][0] ?? null;
}
/** Ce qui reste dessiné d'un coup une fois joué. */
export interface MoveTrace {
/** Le camp qui a joué. */
@@ -126,6 +139,14 @@ export function moveTrace(
return { side, arrows, landed, left, hits: hits.filter(Boolean).length };
}
/** La couleur d'une flèche (case), d'après son numéro affiché (1 à 24,
* pour le joueur) : impaire en point-a, paire en point-b (charte). Jamais
* d'après sa colonne : les cases 12 et 13, l'une au-dessus de l'autre,
* n'ont pas la même couleur. */
export function pointColor(number: number): "point-a" | "point-b" {
return number % 2 === 1 ? "point-a" : "point-b";
}
/** 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 } {
+36 -15
View File
@@ -11,7 +11,13 @@
import type { Cube } from "../domain/game/cube.js";
import type { Side } from "../domain/game/side.js";
import { type BoardModel, type MoveTrace, type Place, pointSlot } from "./board-model.js";
import {
type BoardModel,
type MoveTrace,
type Place,
pointColor,
pointSlot,
} from "./board-model.js";
import { TEXTS } from "./texts.js";
/** Un dé affiché, de la couleur du camp qui l'a lancé. */
@@ -20,8 +26,13 @@ export interface ShownDie {
readonly side: Side;
/** Le jet ne permet aucun coup : le dé est estompé et barré. */
readonly blocked: boolean;
/** Le dé est déjà utilisé par un demi-coup saisi : il est estompé. */
readonly used: boolean;
}
/** Ce que fait un toucher sur les dés : lancer, ou valider le coup saisi. */
export type DiceAction = "roll" | "confirm";
/** Ce qui s'ajoute aux pions : dés, videau, mises en évidence. */
export interface BoardDecor {
readonly dice: readonly ShownDie[];
@@ -33,8 +44,10 @@ export interface BoardDecor {
readonly targets: ReadonlySet<Place>;
/** Les traces du dernier coup de l'ordinateur, tant qu'on les montre. */
readonly trace: MoveTrace | null;
/** Le joueur peut lancer les dés : ses dés en attente se touchent. */
readonly canRoll: boolean;
/** Ce que fait un toucher sur la zone des dés, d'un côté ou de l'autre
* du plateau (null : rien). Pour « roll », les dés du joueur en attente
* sont dessinés. */
readonly diceAction: DiceAction | null;
/** Le joueur peut doubler : le videau se touche. */
readonly canDouble: boolean;
}
@@ -45,7 +58,7 @@ export const NO_MARKS = {
selected: null,
targets: new Set<Place>(),
trace: null,
canRoll: false,
diceAction: null,
canDouble: false,
} as const;
@@ -158,9 +171,16 @@ export function renderBoard(svg: SVGSVGElement, board: BoardModel, decor: BoardD
if (decor.trace !== null) {
drawTrace(svg, board, decor.trace);
}
if (decor.canRoll) {
const center = waitingDice(svg);
addAction(hits, "roll", TEXTS.roll, center, 2 * DIE + 8 + MIN_TARGET / 2);
if (decor.diceAction !== null) {
if (decor.diceAction === "roll") {
waitingDice(svg);
}
// Une zone sur chaque moitié du plateau : les dés en attente (à
// gauche) comme le dernier jet (à droite) se touchent.
const label = decor.diceAction === "roll" ? TEXTS.roll : TEXTS.confirm;
for (const x of [FRAME + HALF_WIDTH / 2, RIGHT_HALF + HALF_WIDTH / 2]) {
addAction(hits, decor.diceAction, label, { x, y: MIDDLE }, HALF_WIDTH - 2 * FRAME);
}
}
if (decor.canDouble) {
cube.classList.add("can-double");
@@ -207,7 +227,7 @@ function drawPoint(
const base = top ? FRAME : HEIGHT - FRAME;
const tip = top ? FRAME + POINT_HEIGHT : HEIGHT - FRAME - POINT_HEIGHT;
add(svg, "polygon", {
class: column % 2 === 0 ? "point-a" : "point-b",
class: pointColor(number),
points: `${left},${base} ${left + POINT_WIDTH},${base} ${center},${tip}`,
});
add(
@@ -493,7 +513,9 @@ function drawDice(svg: SVGSVGElement, dice: readonly ShownDie[]): void {
const x = center - total / 2 + i * (DIE + gap);
const y = MIDDLE - DIE / 2;
const group = add(svg, "g", {
class: die.blocked ? "die die-blocked" : "die",
class: ["die", die.blocked ? "die-blocked" : "", die.used ? "die-used" : ""]
.filter(Boolean)
.join(" "),
"data-value": die.value,
});
add(group, "rect", { class: `die-${die.side}`, x, y, width: DIE, height: DIE, rx: 6 });
@@ -535,9 +557,8 @@ function cubeCenter(cube: Cube): Spot {
}
/** Les dés du joueur en attente d'être lancés, faces vides, au milieu de la
* moitié gauche (la droite garde le dernier jet de l'ordinateur). Renvoie
* leur centre. */
function waitingDice(svg: SVGSVGElement): Spot {
* moitié gauche (la droite garde le dernier jet de l'ordinateur). */
function waitingDice(svg: SVGSVGElement): void {
const center = { x: FRAME + HALF_WIDTH / 2, y: MIDDLE };
const gap = 8;
const group = add(svg, "g", { class: "die die-waiting" });
@@ -551,13 +572,13 @@ function waitingDice(svg: SVGSVGElement): Spot {
rx: 6,
});
}
return center;
}
/**
* Une zone à toucher qui fait la même chose qu'un bouton (lancer les dés,
* doubler) : au moins MIN_TARGET de côté, centrée sur ce qu'on touche, avec
* un rôle et un nom accessibles. Les boutons restent pour le clavier.
* valider le coup, doubler) : au moins MIN_TARGET de côté, centrée sur ce
* qu'on touche, avec un rôle et un nom accessibles. Les boutons restent
* pour le clavier.
*/
function addAction(
hits: Element,
+1 -3
View File
@@ -91,10 +91,8 @@ export async function showHelp(container: HTMLElement): Promise<void> {
renderFeature(container, feature);
}
renderGlossary(container, glossary);
const about = element(container, "p", `${TEXTS.about} `);
const about = element(container, "p", TEXTS.about);
about.className = "about";
const link = element(about, "a", TEXTS.engineTestLink);
link.href = "moteur/";
container.dataset.loaded = "true";
} catch {
status.textContent = TEXTS.helpFailed;
+4 -1
View File
@@ -36,6 +36,7 @@ export const TEXTS = {
double: "Doubler",
take: "Prendre",
drop: "Passer",
confirm: "Valider",
cancel: "Annuler",
skip: "Continuer",
replay: "Revoir",
@@ -60,6 +61,8 @@ export const TEXTS = {
yourMove: (dice: readonly number[]) =>
`Jouez ${roll(dice)} : touchez un pion, puis sa case d'arrivée.`,
pickTarget: "Touchez une case d'arrivée en surbrillance, ou un autre pion.",
confirmOrUndo:
"Touchez vos dés ou « Valider » pour jouer ce coup, ou « Annuler » pour reprendre un pion.",
youCannotMove: (dice: readonly number[]) => `Aucun coup possible avec ${roll(dice)}.`,
pressContinue: "Touchez « Continuer » : le tour passe à l'ordinateur.",
thinking: "L'ordinateur réfléchit…",
@@ -67,6 +70,7 @@ export const TEXTS = {
`L'ordinateur a joué ${roll(dice)} : ${move} (${seconds(ms)}).`,
computerCannotMove: (dice: readonly number[]) =>
`L'ordinateur ne peut pas jouer ${roll(dice)}.`,
confirmDouble: (value: number) => `Doubler à ${value} ? L'ordinateur pourra prendre ou passer.`,
computerDoubles: "L'ordinateur vous propose de doubler : prenez ou passez.",
computerTakes: (value: number) =>
`L'ordinateur prend : la partie vaut maintenant ${points(value)}.`,
@@ -90,5 +94,4 @@ export const TEXTS = {
helpLoading: "Chargement de l'aide…",
helpFailed: "L'aide n'a pas pu se charger.",
about: "Ustagammon est un logiciel libre (GPLv3). L'ordinateur joue avec GNU Backgammon.",
engineTestLink: "Test du moteur",
} as const;
+2
View File
@@ -45,6 +45,8 @@ async function playToTheEnd(game: Game, engine: Awaited<ReturnType<typeof loadNo
for (const halfMove of first.halfMoves) {
assert.equal(await game.playHalfMove(halfMove), true);
}
assert.equal(game.moveComplete, true);
await game.confirmMove();
}
}
return actions;
+100
View File
@@ -78,14 +78,111 @@ test("un coup se saisit demi-coup par demi-coup ; un demi-coup refusé ne change
assert.deepEqual(game.pendingHalfMoves, [{ from: 8, to: 5 }]);
assert.equal(await game.playHalfMove({ from: 6, to: 5 }), true);
// Coup complet : il attend la validation, hors du journal.
assert.equal(game.moveComplete, true);
assert.equal(game.displayedPosition, "AFTER");
assert.equal(game.events.length, 2);
assert.equal(game.state.turn, "player");
assert.deepEqual(await game.nextHalfMoves(), []);
assert.equal(await game.playHalfMove({ from: 8, to: 7 }), false);
await game.confirmMove();
const last = game.events.at(-1);
assert.equal(last?.type, "MovePlayed");
assert.deepEqual(game.pendingHalfMoves, []);
assert.equal(game.moveComplete, false);
assert.equal(game.state.turn, "computer");
assert.equal(game.state.phase, "rolling");
assert.equal(game.state.position, "SWAPPED");
});
test("un coup incomplet ne se valide pas", async () => {
const game = Game.start(fakeEngine(), { dice: fixedDice([3, 1]) });
await assert.rejects(game.confirmMove(), /pas complet/);
await game.playHalfMove({ from: 8, to: 5 });
await assert.rejects(game.confirmMove(), /pas complet/);
assert.equal(game.events.length, 2);
});
test("Annuler défait les demi-coups un par un, sans toucher au journal", async () => {
const game = Game.start(fakeEngine(), { dice: fixedDice([3, 1]) });
await game.playHalfMove({ from: 8, to: 5 });
await game.playHalfMove({ from: 6, to: 5 });
const journal = game.journal;
game.undoLastHalfMove();
assert.deepEqual(game.pendingHalfMoves, [{ from: 8, to: 5 }]);
assert.equal(game.moveComplete, false);
assert.equal(game.displayedPosition, "HALF");
game.undoLastHalfMove();
assert.deepEqual(game.pendingHalfMoves, []);
assert.equal(game.displayedPosition, START_POSITION);
game.undoLastHalfMove(); // rien à défaire : sans effet
assert.equal(game.journal, journal);
// Le coup se ressaisit ensuite normalement.
assert.equal(await game.playHalfMove({ from: 8, to: 5 }), true);
assert.equal(await game.playHalfMove({ from: 6, to: 5 }), true);
assert.equal(game.moveComplete, true);
});
test("les dés utilisés par les demi-coups saisis sont marqués", async () => {
const game = Game.start(fakeEngine(), { dice: fixedDice([3, 1]) });
assert.deepEqual(game.diceToPlay, [
{ value: 3, used: false },
{ value: 1, used: false },
]);
await game.playHalfMove({ from: 8, to: 5 });
assert.deepEqual(game.diceToPlay, [
{ value: 3, used: true },
{ value: 1, used: false },
]);
await game.playHalfMove({ from: 6, to: 5 });
assert.deepEqual(
game.diceToPlay.map((d) => d.used),
[true, true],
);
await game.confirmMove();
assert.deepEqual(game.diceToPlay, []);
});
test("un double donne quatre dés, marqués un par un ; une sortie prend le plus petit dé suffisant", async () => {
const engine = fakeEngine({
checkHalfMoves: async () => ({ valid: true, complete: false, position: "P" }),
});
const double = Game.start(engine, {
dice: fixedDice([2, 2]),
setup: { position: "X", firstTurn: "player" },
});
await double.roll();
assert.equal(double.diceToPlay.length, 4);
await double.playHalfMove({ from: 13, to: 11 });
await double.playHalfMove({ from: 11, to: 9 });
assert.deepEqual(
double.diceToPlay.map((d) => d.used),
[true, true, false, false],
);
const bearOff = Game.start(engine, {
dice: fixedDice([6, 4]),
setup: { position: "X", firstTurn: "player" },
});
await bearOff.roll();
// 3/off : le 4 suffit, le 6 reste.
await bearOff.playHalfMove({ from: 3, to: 0 });
assert.deepEqual(bearOff.diceToPlay, [
{ value: 6, used: false },
{ value: 4, used: true },
]);
// 6/2 demande le 4 : la sortie a donc pris le 6.
await bearOff.playHalfMove({ from: 6, to: 2 });
assert.deepEqual(
bearOff.diceToPlay.map((d) => d.used),
[true, true],
);
});
test("sans coup possible, le tour passe quand le joueur le laisse passer", async () => {
const engine = fakeEngine({ legalMoves: async () => [] });
const game = Game.start(engine, {
@@ -192,6 +289,7 @@ test("annuler repart du parent ; l'action suivante ouvre une branche", async ()
const game = Game.start(fakeEngine(), { dice: fixedDice([3, 1, 6, 5, 4, 2]) });
await game.playHalfMove({ from: 8, to: 5 });
await game.playHalfMove({ from: 6, to: 5 });
await game.confirmMove();
const played = game.events.at(-1);
assert.equal(played?.type, "MovePlayed");
@@ -200,6 +298,7 @@ test("annuler repart du parent ; l'action suivante ouvre une branche", async ()
assert.equal(game.state.turn, "player");
await game.playHalfMove({ from: 8, to: 5 });
await game.playHalfMove({ from: 6, to: 5 });
await game.confirmMove();
// Deux coups accrochés au même jet : l'ancien reste dans le journal.
const moves = game.journal.events.filter((e) => e.type === "MovePlayed");
@@ -244,6 +343,7 @@ test("une partie se reprend depuis son journal ; un journal d'une autre version
const game = Game.start(fakeEngine(), { dice: fixedDice([3, 1]) });
await game.playHalfMove({ from: 8, to: 5 });
await game.playHalfMove({ from: 6, to: 5 });
await game.confirmMove();
const saved = JSON.parse(JSON.stringify(game.journal));
const resumed = Game.resume(fakeEngine(), saved);
+3
View File
@@ -42,6 +42,7 @@ async function playerMove(position: PositionId, dice: [number, number], move: st
for (const halfMove of parseHalfMoves(move)) {
assert.equal(await game.playHalfMove(halfMove), true, formatHalfMoves([halfMove]));
}
await game.confirmMove();
return game;
}
@@ -59,6 +60,7 @@ test("un coup simple : un plateau par demi-coup, sans pion frappé", async () =>
for (const halfMove of parseHalfMoves("8/5 6/5")) {
await game.playHalfMove(halfMove);
}
await game.confirmMove();
const steps = await game.lastMoveSteps();
assert.ok(steps !== null);
assert.equal(steps.side, "player");
@@ -121,6 +123,7 @@ test("le coup de l'ordinateur, vu dans sa propre numérotation", async () => {
for (const halfMove of parseHalfMoves("8/5 6/5")) {
await game.playHalfMove(halfMove);
}
await game.confirmMove();
await game.playComputerTurn();
const steps = await game.lastMoveSteps();
assert.ok(steps !== null);
+107 -2
View File
@@ -80,6 +80,15 @@ async function play(text: string): Promise<boolean> {
return true;
}
/** Joue les demi-coups puis valide le coup, complet. */
async function playAndConfirm(text: string): Promise<boolean> {
if (!(await play(text))) {
return false;
}
await game.confirmMove();
return true;
}
function lastEvent(): GameEvent {
const event = game.events.at(-1);
assert.ok(event !== undefined, "aucun événement");
@@ -160,12 +169,62 @@ Then("la position n'a pas changé", () => {
assert.equal(lastEvent().type, "DiceRolled");
});
// ------------------------------------------ Valider ou reprendre son coup
Then("votre coup attend votre validation", async () => {
assert.equal(accepted, true);
assert.equal(game.moveComplete, true);
// Rien n'est encore dans le journal, et plus aucun pion ne bouge.
assert.equal(lastEvent().type, "DiceRolled");
assert.equal(game.state.turn, "player");
assert.deepEqual(await game.nextHalfMoves(), []);
});
Then("vos dés sont tous marqués comme joués", () => {
assert.ok(game.diceToPlay.length > 0);
assert.ok(game.diceToPlay.every((die) => die.used));
});
When("vous validez votre coup", async () => {
await game.confirmMove();
});
Then("seul votre {int} est marqué comme joué", (value: number) => {
const used = game.diceToPlay.filter((die) => die.used);
assert.deepEqual(
used.map((die) => die.value),
[value],
);
});
Then("chacun de vos pions jouables n'a plus qu'une case d'arrivée", async () => {
const halfMoves = await game.nextHalfMoves();
assert.ok(halfMoves.length > 0);
const starts = halfMoves.map((h) => h.from);
assert.equal(new Set(starts).size, starts.length);
});
When("vous annulez le dernier demi-coup", () => {
game.undoLastHalfMove();
});
Then("il ne reste de joué que {word}", (text: string) => {
assert.deepEqual(game.pendingHalfMoves, parseHalfMoves(text));
assert.equal(game.moveComplete, false);
assert.equal(lastEvent().type, "DiceRolled");
});
Then("aucun de vos dés n'est marqué comme joué", () => {
assert.ok(game.diceToPlay.length > 0);
assert.ok(game.diceToPlay.every((die) => !die.used));
});
// ------------------------------------------------------- L'ordinateur joue
Given("c'est au tour de l'ordinateur", async () => {
// Vous commencez avec 3-1, puis l'ordinateur tirera 6-4.
game = Game.start(engine, { dice: fixedDice([3, 1, 6, 4]) });
assert.equal(await play("8/5 6/5"), true);
assert.equal(await playAndConfirm("8/5 6/5"), true);
assert.equal(game.state.turn, "computer");
});
@@ -287,6 +346,52 @@ Then("vous pouvez revoir son coup", async () => {
// ------------------------------------------------------------------ Videau
// La confirmation elle-même est une question de l'interface (une fenêtre
// « Doubler à 2 ? ») : ces étapes vérifient que rien n'est proposé tant que
// vous n'avez pas confirmé, et ce qui suit votre confirmation.
Given("vous pouvez doubler", async () => {
game = Game.start(engine, { setup: { position: START_POSITION, firstTurn: "player" } });
assert.equal(game.state.phase, "rolling");
});
When("vous demandez à doubler", async () => {
assert.equal(await game.canDouble(), true);
});
When("vous changez d'avis", () => {
// Vous refermez la question : la partie n'est pas touchée.
});
Then("rien n'est proposé à l'ordinateur", () => {
assert.ok(!game.events.some((e) => e.type === "CubeOffered"));
assert.equal(game.state.phase, "rolling");
assert.equal(game.state.turn, "player");
assert.deepEqual(game.state.cube, { value: 1, owner: null });
});
When("vous confirmez", async () => {
await game.double();
});
Then("l'ordinateur prend ou passe, comme le recommande le moteur", async () => {
const decision = await engine.cubeDecision(
START_POSITION,
{ value: 1, owner: "centered" },
STANDARD_PLY,
);
const offered = game.events.findIndex((e) => e.type === "CubeOffered");
const answer = game.events[offered + 1];
assert.ok(offered >= 0 && answer !== undefined);
if (decision.take) {
assert.equal(answer.type, "CubeTaken");
assert.deepEqual(game.state.cube, { value: 2, owner: "computer" });
} else {
assert.equal(answer.type, "CubeDropped");
assert.deepEqual(game.state.result, { winner: "player", kind: "single", points: 1 });
}
});
Given("l'ordinateur vous propose de doubler", async () => {
game = Game.start(engine, { setup: { position: COMPUTER_DOUBLES, firstTurn: "computer" } });
await game.playComputerTurn();
@@ -346,7 +451,7 @@ Given("un joueur a sorti tous ses pions", async () => {
dice: fixedDice([6, 5]),
});
await game.roll();
assert.equal(await play("6/off 5/off"), true);
assert.equal(await playAndConfirm("6/off 5/off"), true);
});
Then("le résultat indique le gagnant et les points gagnés", () => {
+26 -1
View File
@@ -12,7 +12,15 @@ import type { BoardSide } from "../src/domain/game/engine.js";
import { appendEvent, EMPTY_JOURNAL } from "../src/domain/game/events.js";
import { parseHalfMoves, START_POSITION } from "../src/domain/game/position.js";
import { createGameStore } from "../src/storage/game-store.js";
import { boardModel, moveTargets, moveTrace, placeOf, pointSlot } from "../src/ui/board-model.js";
import {
boardModel,
moveTargets,
moveTrace,
onlyHalfMove,
placeOf,
pointColor,
pointSlot,
} from "../src/ui/board-model.js";
import { parseFeature, parseGlossaryEntry, parseGlossaryIndex } from "../src/ui/help-text.js";
import { TEXTS } from "../src/ui/texts.js";
@@ -47,6 +55,18 @@ test("cases, barre et sortie de chaque camp", () => {
assert.equal(placeOf("computer", 0), "off-computer");
});
test("couleur des flèches : impaires en point-a, paires en point-b, quelle que soit la colonne", () => {
for (const number of [1, 13, 23]) {
assert.equal(pointColor(number), "point-a", `flèche ${number}`);
}
for (const number of [2, 12, 24]) {
assert.equal(pointColor(number), "point-b", `flèche ${number}`);
}
// 12 et 13 partagent la colonne 0, mais pas la couleur.
assert.equal(pointSlot(12).column, pointSlot(13).column);
assert.notEqual(pointColor(12), pointColor(13));
});
test("disposition : 1 à 12 en bas de droite à gauche, 13 à 24 en haut de gauche à droite", () => {
assert.deepEqual(pointSlot(1), { top: false, column: 11 });
assert.deepEqual(pointSlot(7), { top: false, column: 5 });
@@ -65,6 +85,11 @@ test("les demi-coups possibles donnent les pions jouables et leurs cases d'arriv
assert.deepEqual([...targets.keys()], ["p8", "bar-player", "p3"]);
assert.deepEqual([...(targets.get("p8")?.keys() ?? [])], ["p5", "p7"]);
assert.deepEqual(targets.get("p3")?.get("off-player"), { from: 3, to: 0 });
// Un seul toucher suffit pour un pion à destination unique.
assert.deepEqual(onlyHalfMove(targets, "p3"), { from: 3, to: 0 });
assert.deepEqual(onlyHalfMove(targets, "bar-player"), { from: 25, to: 22 });
assert.equal(onlyHalfMove(targets, "p8"), null);
assert.equal(onlyHalfMove(targets, "p13"), null);
});
test("traces d'un coup simple : une flèche par demi-coup, départs et arrivées", () => {
@@ -52,9 +52,3 @@ 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é.
+21
View File
@@ -68,3 +68,24 @@ vérification des dépendances.
standard ») est tenu même au niveau fort (2 plis) : c'est le niveau standard
de l'ordinateur à l'étape 0d.
- Firefox sur le téléphone reste à vérifier, sans bloquer.
## Amendements
### 2026-11-03 — Pages de test du moteur retirées du site
Décidé par Pierre après son premier test sur le téléphone : « supprimer la
partie test du moteur totalement ».
- Les pages de test et de mesure de l'étape 0b (`web/moteur/` : `index.html`,
`bench.html` et leurs scripts) sont supprimées. Le site publié n'a plus rien
sous `/moteur/`, ni dans le cache du service worker ; `make deploy`
n'annonce plus que l'application, et l'aide ne renvoie plus vers le test du
moteur.
- Les mesures du Fairphone 4 ci-dessus, faites avec `bench.html`, restent la
référence. Pour les refaire, la mesure se lance sous Node sur le PC
(`make bench`) : son code partagé est déplacé de `web/moteur/benchmark.mjs`
vers `engine-wasm/benchmark.mjs`. Une mesure sur le téléphone demanderait de
remettre une page de mesure, à décider le moment venu.
- Le moteur reste vérifié par la suite de contrat sur WebAssembly
(`make test`) et, dans l'application, par la partie jouée dans un navigateur
(`make test-browser`).
-24
View File
@@ -53,27 +53,3 @@ des pions dans les deux cas.
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.
+3 -4
View File
@@ -2,8 +2,8 @@
Ustagammon est un site statique (PWA, ADR 0009) : des fichiers HTML,
JavaScript, le moteur en WebAssembly et ses données. L'application est à la
racine ; les pages de test du moteur de l'étape 0b restent publiées sous
`/moteur/` (test du moteur) et `/moteur/bench.html` (mesure). Il est publié sur
racine ; les pages de test du moteur de l'étape 0b ont été retirées (ADR
0009, amendement du 2026-11-03). Il est publié sur
CapRover, machine `sans-pub` (https://captain.cloud.sans.pub), app
`ustagammon`, sur https://ustagammon.sans.pub.
@@ -112,8 +112,7 @@ ssh sans.pub 'sudo docker service ps srv-captain--ustagammon --format "{{.Image}
```
Puis ouvrir le site sur le téléphone : le plateau s'affiche et une consigne
remplace « Chargement du moteur… ». La page de test du moteur
(https://ustagammon.sans.pub/moteur/) doit afficher « Moteur prêt ».
remplace « Chargement du moteur… ».
## Retour arrière
+73
View File
@@ -0,0 +1,73 @@
# É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.
-55
View File
@@ -1,55 +0,0 @@
# É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.
+16 -18
View File
@@ -3,36 +3,34 @@
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`.
`docs/cadrage.md` ; le détail de l'étape en cours est dans `docs/etape-0.md`.
Légende : 🔄 en cours · ⏳ à venir · ⏸ conditionnel
## Étape 0 — Prototype technique : terminée
## Étape 0 — Prototype technique
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.
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.
| Sous-étape | Contenu | État | Plan |
| --- | --- | --- | --- |
| 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 | ⏳ | |
| 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+ | Fluidité de la partie : supprimer ce qui interrompt le jeu sans nécessité (bouton « Nouvelle partie » toujours visible, confirmations, étapes inutiles) : demande de Pierre | ⏳ | |
**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.
## Étapes suivantes
| Étape | Contenu | État |
| --- | --- | --- |
| 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 | 🔄 |
| 1. V1 Jouer et comprendre | Charte graphique appliquée à la PWA (ADR 0010, `docs/identite/charte/`), plateau soigné, niveaux, conseil du maître après le coup, bilan de fin de partie, premières leçons numérotées (ADR 0011), mode immersif en paysage (ADR 0012), analyse, branches, tutoriel, prompts, sauvegarde | ⏳ |
| 2. V1.5 Apprendre | Parcours 2, exercices, progression | ⏳ |
| 3. V2 Perfectionnement et IA | Parcours 3 et 4, rollouts, révision espacée | ⏳ |
+171
View File
@@ -0,0 +1,171 @@
# 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.
-26
View File
@@ -110,29 +110,3 @@ 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.
-60
View File
@@ -1,60 +0,0 @@
# 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.
-63
View File
@@ -1,63 +0,0 @@
# 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.
+42
View File
@@ -11,11 +11,40 @@ Fonctionnalité: Jouer une partie contre l'ordinateur
Et le premier jet de dés désigne qui commence
Scénario: Jouer un coup permis
Touchez un pion, puis sa case d'arrivée ; s'il n'en a qu'une, il y va dès
que vous le touchez. Chaque dé joué s'estompe. Une fois tous vos dés
joués, votre coup attend que vous le validiez : touchez vos dés, d'un côté
ou de l'autre du plateau, ou « Valider ».
Étant donné que c'est à vous de jouer avec le jet 3-1
Quand vous jouez 8/5 6/5
Alors votre coup attend votre validation
Et vos dés sont tous marqués comme joués
Quand vous validez votre coup
Alors votre coup est accepté
Et c'est ensuite au tour de l'ordinateur
Scénario: Un pion qui n'a qu'une case d'arrivée
Après 8/5, il ne vous reste que le 1 : chaque pion jouable n'a plus
qu'une case d'arrivée, et il y va dès que vous le touchez.
Étant donné que c'est à vous de jouer avec le jet 3-1
Quand vous jouez 8/5
Alors seul votre 3 est marqué comme joué
Et chacun de vos pions jouables n'a plus qu'une case d'arrivée
Scénario: Reprendre un pion avant de valider
Tant que vous n'avez pas validé votre coup, « Annuler » reprend le
dernier pion déplacé ; touchez-le encore pour reprendre le précédent.
Étant donné que c'est à vous de jouer avec le jet 3-1
Quand vous jouez 8/5 6/5
Et que vous annulez le dernier demi-coup
Alors il ne reste de joué que 8/5
Quand vous annulez le dernier demi-coup
Alors la position n'a pas changé
Et aucun de vos dés n'est marqué comme joué
Scénario: Un coup interdit est refusé
La case 12 est tenue par l'ordinateur, qui y a cinq pions : vous ne
pouvez pas vous y poser.
@@ -54,6 +83,19 @@ Fonctionnalité: Jouer une partie contre l'ordinateur
Et votre pion frappé part sur la barre
Et vous pouvez revoir son coup
Scénario: Doubler, après confirmation
Avant de lancer les dés, vous pouvez doubler en touchant le videau ou
« Doubler ». L'application vous demande de confirmer : une fois proposé,
un double ne se reprend pas.
Étant donné que vous pouvez doubler
Quand vous demandez à doubler
Mais que vous changez d'avis
Alors rien n'est proposé à l'ordinateur
Quand vous demandez à doubler
Et que vous confirmez
Alors l'ordinateur prend ou passe, comme le recommande le moteur
Scénario: Prendre le double de l'ordinateur
Étant donné que l'ordinateur vous propose de doubler
Quand vous prenez
+2 -2
View File
@@ -1,4 +1,4 @@
// bench.mjs -- la mesure de la page bench.html, sous Node, sur le PC
// bench.mjs -- mesure du moteur WebAssembly sous Node, sur le PC
// (`make bench`). Premier ordre de grandeur seulement : le chiffre qui compte
// est celui du téléphone.
//
@@ -10,7 +10,7 @@ import os from "node:os";
import { fileURLToPath } from "node:url";
import { gzipSync } from "node:zlib";
import createEngineModule from "../build/wasm/engine.mjs";
import { DOWNLOADED_FILES, formatReport, measureTimes } from "../web/moteur/benchmark.mjs";
import { DOWNLOADED_FILES, formatReport, measureTimes } from "./benchmark.mjs";
import { commands, loadEngine } from "../web/engine-wasm.mjs";
const root = new URL("../", import.meta.url);
@@ -1,5 +1,7 @@
// benchmark.mjs -- mesure du moteur WebAssembly, partagée par la page de
// mesure (bench.html, dans le navigateur) et `make bench` (sous Node).
// benchmark.mjs -- mesure du moteur WebAssembly sous Node, pour `make bench`
// (engine-wasm/bench.mjs). Elle servait aussi à la page de mesure du site,
// retirée avec les pages de test du moteur (ADR 0009, amendement du
// 2026-11-03).
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
@@ -7,7 +9,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 (ADR 0009).
// de l'étape 0b (docs/etape-0.md).
// 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).
@@ -20,13 +22,12 @@ export const TIME_THRESHOLDS = [
// Taille totale à télécharger, compressée : moins de 40 Mo.
export const DOWNLOAD_LIMIT_BYTES = 40e6;
// Fichiers téléchargés pour faire tourner le moteur dans la page de test,
// chemins relatifs à la racine du site (web/ ; wasm/ est build/wasm/).
// Fichiers téléchargés pour faire tourner le moteur dans l'application
// (hors code de l'application elle-même, quelques dizaines de Ko), chemins
// relatifs à la racine du site (web/ ; wasm/ est build/wasm/).
export const DOWNLOADED_FILES = [
"moteur/index.html",
"moteur/style.css",
"moteur/page.mjs",
"moteur/engine-client.mjs",
"index.html",
"app.css",
"engine-worker.mjs",
"engine-wasm.mjs",
"wasm/engine.mjs",
+155 -32
View File
@@ -1,6 +1,7 @@
// play.mjs -- joue une partie complète par l'interface, dans un vrai
// navigateur sans écran (Playwright), sur le site servi par l'image de
// production : dés, coups au toucher, coups de l'ordinateur, videau, reprise
// production : dés, coups au toucher (validés, annulés), coups de
// l'ordinateur, videau (avec sa confirmation), reprise
// après rechargement, aide, puis hors ligne (service worker).
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
@@ -101,7 +102,7 @@ async function waitForPlayer() {
const b = document.getElementById(id);
return !b.hidden && !b.disabled;
};
for (const id of ["again", "take", "skip", "roll"]) {
for (const id of ["again", "take", "skip", "confirm", "roll"]) {
if (usable(id)) {
return id;
}
@@ -161,7 +162,8 @@ async function checkTraces() {
// 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,
check(/^L'ordinateur ne peut pas jouer \d-\d\.$/.test(info ?? "") && blocked >= 2 &&
blocked === (await shownDice()).dice,
`jet de l'ordinateur sans coup mal montré : « ${info} », ${blocked} dés barrés`);
log(`l'ordinateur ne peut pas jouer : « ${info} »`);
}
@@ -175,18 +177,35 @@ async function checkTraces() {
return traces;
}
/** La zone à toucher d'une action du plateau (dés ou videau) : un bouton
* accessible, d'au moins 44 px de côté. Renvoie son sélecteur. */
async function touchTarget(action, label) {
const selector = `[data-action="${action}"]`;
const zone = page.locator(selector);
check((await zone.count()) === 1, `zone ${action} absente du plateau`);
check((await zone.getAttribute("role")) === "button" &&
(await zone.getAttribute("aria-label")) === label, `zone ${action} sans rôle accessible`);
const box = await zone.boundingBox();
check(box.width >= 44 && box.height >= 44,
`zone ${action} trop petite : ${Math.round(box.width)} x ${Math.round(box.height)} px`);
return selector;
/** Une zone à toucher d'une action du plateau (dés ou videau) : count zones
* (les dés en ont une de chaque côté du plateau), chacune un bouton
* accessible d'au moins 44 px de côté. Renvoie le locator de la zone index. */
async function touchTarget(action, label, count = 1, index = 0) {
const zones = page.locator(`[data-action="${action}"]`);
check((await zones.count()) === count,
`zone ${action} : ${await zones.count()} sur le plateau, ${count} attendues`);
for (const zone of await zones.all()) {
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 zones.nth(index);
}
/** Le nombre de dés montrés, et de dés estompés (déjà joués). */
async function shownDice() {
return {
dice: await page.locator("g.die[data-value]").count(),
used: await page.locator("g.die-used").count(),
};
}
/** Le dernier jet du joueur, d'après le journal. */
function lastPlayerRoll(journal) {
const rolled = journal.events.filter((e) => e.type === "DiceRolled").at(-1);
return rolled?.roll ?? [];
}
/** Observe la page pendant ms millisecondes : le plus grand nombre
@@ -256,13 +275,24 @@ let tracesSeen = 0;
let replays = 0;
let skips = 0;
let rolls = 0;
// Saisie du coup : demi-coups du coup en cours, pions avancés d'un seul
// toucher ou en deux (pion, puis case), coups validés et annulations.
let halfMoves = 0;
let singleTouch = 0;
let twoTouches = 0;
let confirms = 0;
let undone = 0;
let doubles = 0;
// Vrai dès que le joueur a touché un pion ou « Annuler » pendant son coup :
// le coup de l'ordinateur n'est plus montré.
let touched = false;
for (let actions = 0; ; actions++) {
check(actions < 800, "la partie ne finit pas");
check(actions < 1500, "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();
const traces = touched ? null : await checkTraces();
if (traces !== null) {
tracesSeen++;
if (replays < 2) {
@@ -292,7 +322,8 @@ for (let actions = 0; ; actions++) {
// 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,
check(/^Aucun coup possible avec \d-\d\.$/.test(info ?? "") && blocked >= 2 &&
blocked === (await shownDice()).dice,
`aucun coup possible mal montré : « ${info} », ${blocked} dés barrés`);
skips++;
log(`aucun coup possible : « ${info} », vous continuez`);
@@ -304,30 +335,115 @@ for (let actions = 0; ; actions++) {
} else if (todo === "roll") {
if (!doubled && (await page.isEnabled("#double"))) {
doubled = true;
log("vous doublez en touchant le videau");
await page.click(await touchTarget("double", "Doubler"));
// Toucher le videau demande confirmation ; « Annuler » ne
// propose rien, puis « Doubler » (le bouton) et confirmer.
const dialog = page.locator("#double-dialog");
const question = "Doubler à 2 ? L'ordinateur pourra prendre ou passer.";
await (await touchTarget("double", "Doubler")).click();
check(await dialog.isVisible(), "videau touché : pas de confirmation");
check((await page.textContent("#double-question")) === question,
`confirmation du videau : « ${await page.textContent("#double-question")} »`);
await shot("videau-confirmation");
await page.click("#double-cancel");
check(!(await dialog.isVisible()), "Annuler ne ferme pas la confirmation");
const offered = (await savedJournal()).events.some((e) => e.type === "CubeOffered");
check(!offered && (await page.isVisible("#roll")), "double proposé malgré Annuler");
await page.click("#double");
check(await dialog.isVisible(), "« Doubler » : pas de confirmation");
await page.click("#double-confirm");
await waitForPlayer();
const answer = (await page.textContent("#info")) || (await page.textContent("#prompt"));
log(`réponse : ${answer}`);
log(`videau touché : confirmation demandée, annulée, puis double confirmé ; réponse : ${answer}`);
continue;
}
// Une fois sur deux, en touchant les dés en attente plutôt que le
// bouton.
// Une fois sur deux, en touchant les dés plutôt que le bouton, d'un
// côté puis de l'autre du plateau.
rolls++;
await page.click(rolls % 2 === 0 ? await touchTarget("roll", "Lancer les dés") : "#roll");
if (rolls % 2 === 0) {
await (await touchTarget("roll", "Lancer les dés", 2, (rolls / 2) % 2)).click();
} else {
await page.click("#roll");
}
} else if (todo === "confirm") {
// Coup complet : rien n'est joué tant qu'il n'est pas validé.
const journal = await savedJournal();
const last = journal.events.at(-1);
check(last?.type === "DiceRolled" && (await page.isEnabled("#cancel")),
`coup complet déjà joué ou non annulable (dernier événement ${last?.type})`);
check(/Valider/.test((await page.textContent("#prompt")) ?? ""),
`consigne de validation absente : « ${await page.textContent("#prompt")} »`);
const dice = await shownDice();
check(dice.used === halfMoves && dice.used > 0,
`dés estompés : ${dice.used} pour ${halfMoves} demi-coups`);
if (undone === 0 && halfMoves >= 2) {
// Une fois : « Annuler » défait les demi-coups un par un.
if (confirms === 0) {
await shot("2b-coup-a-valider");
}
for (let left = halfMoves - 1; left >= 0; left--) {
await page.click("#cancel");
await waitForPlayer();
const after = await shownDice();
check(after.used === left && (await page.isHidden("#confirm")),
`Annuler : ${after.used} dés estompés, ${left} attendus`);
undone++;
}
check(await page.isDisabled("#cancel"), "Annuler reste actif sans demi-coup à défaire");
check((await savedJournal()).head === journal.head, "Annuler a touché au journal");
log(`Annuler : ${undone} demi-coups défaits un par un, journal intact`);
halfMoves = 0;
continue;
}
confirms++;
// Une fois sur trois avec le bouton, sinon en touchant les dés
// (d'un côté ou de l'autre).
if (confirms % 3 === 0) {
await page.click("#confirm");
} else {
await (await touchTarget("confirm", "Valider", 2, confirms % 2)).click();
}
await waitForPlayer();
const played = await savedJournal();
check(countMoves(played, "player") === countMoves(journal, "player") + 1,
"le coup validé n'est pas dans le journal");
halfMoves = 0;
touched = false;
} 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) {
selectionShot = true;
await shot("2-pion-touche");
if (halfMoves === 0) {
// Quatre dés pour un double, deux sinon.
const [a, b] = lastPlayerRoll(await savedJournal());
const dice = await shownDice();
check(dice.dice === (a === b ? 4 : 2) && dice.used === 0,
`jet ${a}-${b} : ${dice.dice} dés montrés, ${dice.used} estompés`);
if (a === b) {
doubles++;
}
}
await page.click(".hit.can-land");
touched = true;
// Un pion à destination unique avance dès qu'on le touche ; sinon,
// ses cases d'arrivée s'allument.
await page.click(".hit.can-move");
if ((await page.locator(".hit.can-land").count()) > 0) {
twoTouches++;
if (!selectionShot) {
selectionShot = true;
await shot("2-pion-touche");
}
await page.click(".hit.can-land");
} else {
singleTouch++;
}
halfMoves++;
await waitForPlayer();
const dice = await shownDice();
check(dice.used === halfMoves, `${dice.used} dés estompés après ${halfMoves} demi-coups`);
continue;
}
// Une fois, au milieu de la partie : fermer et rouvrir la page.
// Une fois, au milieu de la partie, entre deux coups : fermer et rouvrir
// la page.
const journal = await savedJournal();
if (!reloaded && countMoves(journal, "computer") >= 3 && countMoves(journal, "player") >= 3) {
reloaded = true;
@@ -355,7 +471,14 @@ check(countMoves(journal, "player") >= 3 && countMoves(journal, "computer") >= 3
check(reloaded, "la reprise après rechargement n'a pas été essayée");
check(replays === 2, "le coup de l'ordinateur n'a pas été revu");
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`);
check(singleTouch > 0 && twoTouches > 0,
`saisie : ${singleTouch} pions avancés d'un toucher, ${twoTouches} en deux touchers`);
check(undone >= 2, "Annuler n'a pas été essayé sur un coup complet");
check(confirms >= 3, "trop peu de coups validés pour essayer dés et bouton");
log(`dés lancés ${Math.floor(rolls / 2)} fois en les touchant, des deux côtés du plateau`);
log(`demi-coups : ${singleTouch} d'un seul toucher (destination unique), ${twoTouches} en deux ` +
`touchers ; ${confirms} coups validés (${Math.floor(confirms / 3)} par le bouton, les autres ` +
`en touchant les dés) ; ${doubles} doubles montrés en quatre dés`);
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) {
+14 -10
View File
@@ -1,8 +1,9 @@
#!/usr/bin/env bash
# Vérifie l'image de production (deploy/) avec le site assemblé (make package) :
# construit l'image en local, la lance, et contrôle ce que reçoit un
# navigateur (application, pages de test du moteur, aide, manifeste, service
# worker, types MIME, compression, politique de sécurité, version).
# navigateur (application, aide, manifeste, service worker, types MIME,
# compression, politique de sécurité, version), et l'absence des anciennes
# pages de test du moteur.
set -euo pipefail
cd "$(dirname "$0")/.."
@@ -71,10 +72,12 @@ check "wasm/engine.mjs" "text/javascript"
check "wasm/engine.wasm" "application/wasm"
check "wasm/engine.data" "application/octet-stream"
check "version.json" "application/json"
# Pages de test du moteur (étape 0b).
check "moteur/" "text/html"
check "moteur/bench.html" "text/html"
check "moteur/engine-client.mjs" "text/javascript"
# Les pages de test du moteur (étape 0b) sont retirées (ADR 0009,
# amendement du 2026-11-03) : plus rien sous /moteur/.
[ ! -e build/site/moteur ] || report "build/site/moteur/ existe encore"
for gone in moteur/ moteur/bench.html; do
curl -s -o /dev/null -w "%{http_code}" "$base/$gone" | grep -qx 404 || report "/$gone : 404 attendu"
done
# Aide : copies du glossaire et des scénarios (ADR 0008).
check "docs/scenarios/jouer-une-partie.feature" "text/plain"
check "docs/glossaire/README.md" "text/markdown"
@@ -94,15 +97,16 @@ done < <(grep -o '"src": "[^"]*"' <<<"$manifest" | sed 's/"src": "\(.*\)"/\1/')
# Le service worker met en cache chaque fichier du site, avec une version.
sw="$(curl -s "$base/sw.js")"
grep -qE '^const VERSION = "[0-9a-f]{16}";' <<<"$sw" || report "sw.js : version absente"
for file in ./ index.html app/main.js wasm/engine.data wasm/engine.wasm moteur/ \
for file in ./ index.html app/main.js wasm/engine.data wasm/engine.wasm \
docs/glossaire/videau.md version.json fonts/AlegreyaSans-Bold.woff2 icons/favicon.svg; do
grep -qF " \"$file\"," <<<"$sw" || report "sw.js : $file absent de la liste à mettre en cache"
done
listed="$(grep -c '^ "' <<<"$sw")"
present="$(find build/site -type f ! -name sw.js | wc -l)"
# Chaque fichier, plus « ./ » et « moteur/ » pour les deux index.html.
[ "$listed" -eq "$((present + 2))" ] \
|| report "sw.js liste $listed adresses pour $present fichiers (plus 2 dossiers)"
# Chaque fichier, plus « ./ » pour index.html.
[ "$listed" -eq "$((present + 1))" ] \
|| report "sw.js liste $listed adresses pour $present fichiers (plus « ./ »)"
grep -q '"moteur/' <<<"$sw" && report "sw.js met encore en cache des pages de test du moteur"
grep -o '^ "[^"]*"' <<<"$sw" | tr -d ' "' | while read -r file; do
curl -sf -o /dev/null "$base/$file" || echo "sw.js : $file introuvable sur le site"
done | grep . && report "sw.js liste des fichiers introuvables"
+37 -6
View File
@@ -499,6 +499,10 @@ body.busy .prompt {
stroke-linecap: round;
}
/* Dé déjà utilisé par un demi-coup du coup en cours : estompé, sans être
* barré (il n'est pas perdu : « Annuler » le rend). */
.die-used { opacity: 0.35; }
.cube-face { fill: var(--cube); stroke: var(--checker-player-edge); stroke-width: 2; }
.cube-value {
@@ -513,6 +517,39 @@ body.busy .prompt {
cursor: pointer;
}
/* ------------------------------------------------ Confirmation (videau) */
/* Une carte au milieu de l'écran, comme le glossaire ; le fond de la page
* s'assombrit derrière. */
.confirm {
width: min(22rem, calc(100vw - 2 * var(--space-4)));
padding: var(--space-4);
color: var(--text);
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-md);
box-shadow: var(--shadow-raised);
}
.confirm::backdrop {
background: rgba(43, 29, 21, 0.5);
}
.confirm p {
margin: 0 0 var(--space-4);
font-weight: 700;
}
.confirm-actions {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
}
.confirm-actions button {
flex: 1 1 auto;
}
/* --------------------------------------------------------------- Aide */
.help {
@@ -582,12 +619,6 @@ body.busy .prompt {
color: var(--muted);
}
/* Une cible tactile confortable pour le lien. */
.about a {
display: inline-block;
padding-block: 12px;
}
/* ------------------------------------------------------- Mode paysage */
/* Téléphone tourné : le plateau prend toute la hauteur de l'écran, à
+9
View File
@@ -43,11 +43,20 @@
<button id="double" type="button" class="secondary" hidden></button>
<button id="take" type="button" hidden></button>
<button id="drop" type="button" class="secondary" hidden></button>
<button id="confirm" type="button" 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>
<!-- Confirmation avant de doubler (ouverte par app/ui/app.js). -->
<dialog id="double-dialog" class="confirm" aria-labelledby="double-question">
<p id="double-question"></p>
<div class="confirm-actions">
<button id="double-confirm" type="button"></button>
<button id="double-cancel" type="button" class="secondary"></button>
</div>
</dialog>
<noscript><p>Ustagammon a besoin de JavaScript.</p></noscript>
</main>
-98
View File
@@ -1,98 +0,0 @@
// bench-page.mjs -- page de mesure (bench.html) : chronomètre le moteur
// dans un Web Worker et produit le rapport à copier.
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
import { DOWNLOADED_FILES, DOWNLOAD_LIMIT_BYTES, formatReport, measureTimes, summarize }
from "./benchmark.mjs";
import { startEngine } from "./engine-client.mjs";
const $ = (id) => document.getElementById(id);
// Taille d'un fichier du site, brute et compressée gzip (comme le ferait un
// serveur qui compresse ses réponses).
// name est relatif à la racine du site, un dossier au-dessus de cette page.
async function fileSize(name) {
const blob = await (await fetch("../" + name, { cache: "no-store" })).blob();
const gzipped = blob.stream().pipeThrough(new CompressionStream("gzip"));
const gzipBytes = (await new Response(gzipped).arrayBuffer()).byteLength;
return { name, bytes: blob.size, gzipBytes };
}
function addRow(cells) {
const row = $("table").insertRow();
for (const text of cells) {
row.insertCell().textContent = text;
}
}
async function measure() {
$("start").disabled = true;
$("results").hidden = true;
$("table").replaceChildren();
$("status").textContent = "Chargement du moteur…";
const start = performance.now();
const engine = startEngine();
await engine.ready;
const loadMs = performance.now() - start;
const { positions } = await (await fetch("../wasm/positions.json")).json();
let memoryBytes = 0;
const times = await measureTimes({
bestMove: (...args) => engine.bestMove(...args).then(keepMemory),
cubeDecision: (...args) => engine.cubeDecision(...args).then(keepMemory),
}, positions, (done, total) => {
$("status").textContent = "Calcul " + done + " sur " + total + "…";
});
function keepMemory(answer) {
memoryBytes = answer.memoryBytes;
return answer;
}
$("status").textContent = "Mesure de la taille à télécharger…";
const files = await Promise.all(DOWNLOADED_FILES.map(fileSize));
for (const s of summarize(times)) {
addRow([s.label, Math.round(s.medianMs) + " ms", Math.round(s.maxMs) + " ms",
s.limitMs + " ms", s.ok ? "OK" : "dépassé"]);
}
const gzipTotal = files.reduce((sum, f) => sum + f.gzipBytes, 0);
addRow(["Taille compressée", "", (gzipTotal / 1e6).toFixed(1).replace(".", ",") + " Mo",
DOWNLOAD_LIMIT_BYTES / 1e6 + " Mo", gzipTotal < DOWNLOAD_LIMIT_BYTES ? "OK" : "dépassé"]);
$("report").value = formatReport({
environment: navigator.userAgent, files, loadMs, memoryBytes, times,
});
$("results").hidden = false;
$("status").textContent = "Mesure terminée.";
}
// Copie dans le presse-papiers. L'API moderne n'existe que sur les pages
// sécurisées (https ou localhost) : sur le réseau local en http, on
// sélectionne le texte et on utilise l'ancienne commande de copie.
async function copyReport() {
try {
if (navigator.clipboard) {
await navigator.clipboard.writeText($("report").value);
} else {
$("report").select();
if (!document.execCommand("copy")) {
throw new Error();
}
}
$("copy-status").textContent = "Rapport copié : collez-le dans la conversation.";
} catch {
$("report").select();
$("copy-status").textContent = "Copie impossible : le texte est sélectionné, "
+ "copiez-le à la main (appui long, puis Copier).";
}
}
$("start").onclick = () => measure().catch((error) => {
$("status").textContent = "La mesure a échoué : " + error.message;
$("status").classList.add("error");
$("start").disabled = false;
});
$("copy").onclick = copyReport;
-35
View File
@@ -1,35 +0,0 @@
<!doctype html>
<html lang="fr">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Ustagammon : mesure du moteur</title>
<link rel="stylesheet" href="style.css">
<script type="module" src="bench-page.mjs"></script>
</head>
<body>
<main>
<h1>Mesure du moteur</h1>
<p>Cette page chronomètre le moteur sur les positions de référence : meilleur coup
aux niveaux rapide et fort, décision de videau au niveau fort. Elle mesure aussi
la taille à télécharger et la mémoire utilisée. Comptez une à quelques minutes ;
gardez l'écran allumé et la page au premier plan.</p>
<button id="start" type="button">Lancer la mesure</button>
<p id="status" class="status"></p>
<section id="results" hidden>
<h2>Résultats</h2>
<table>
<thead><tr><th>Mesure</th><th>Médiane</th><th>Maximum</th><th>Seuil</th><th></th></tr></thead>
<tbody id="table"></tbody>
</table>
<h2>Rapport</h2>
<button id="copy" type="button">Copier les résultats</button>
<p id="copy-status" class="status"></p>
<textarea id="report" readonly></textarea>
</section>
<p><a href="./">Page de test</a></p>
</main>
</body>
</html>
-66
View File
@@ -1,66 +0,0 @@
// engine-client.mjs -- le moteur vu depuis les pages de test : démarre le
// Web Worker (../engine-worker.mjs, partagé avec l'application) et lui envoie
// les commandes.
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
//
// Toutes les fonctions renvoient une promesse de { result, ms, memoryBytes } :
// result est l'objet JSON d'engine-cli, ms le temps de calcul mesuré dans le
// Worker. La promesse est rejetée si le moteur signale une erreur.
import { commands } from "../engine-wasm.mjs";
export function startEngine() {
const worker = new Worker(new URL("../engine-worker.mjs", import.meta.url), { type: "module" });
const pending = new Map();
let nextId = 1;
let resolveReady, rejectReady;
const ready = new Promise((resolve, reject) => {
resolveReady = resolve;
rejectReady = reject;
});
worker.onmessage = (event) => {
const message = event.data;
if (message.type === "ready") {
resolveReady(message);
} else if (message.type === "failed") {
rejectReady(new Error(message.message));
} else {
const { resolve, reject } = pending.get(message.id);
pending.delete(message.id);
if (message.error) {
reject(new Error(message.error));
} else {
resolve(message);
}
}
};
// Erreur au chargement du Worker lui-même (fichier absent, navigateur
// trop ancien pour les Workers en module ES…).
worker.onerror = (event) => {
rejectReady(new Error(event.message || "le Web Worker du moteur n'a pas démarré"));
};
function run(args) {
const id = nextId++;
return new Promise((resolve, reject) => {
pending.set(id, { resolve, reject });
worker.postMessage({ id, args: args.map(String) });
});
}
return {
// Promesse de { loadMs, memoryBytes } une fois le moteur chargé.
ready,
bestMove: (positionId, matchId, dice, ply) =>
run(commands.bestMove(positionId, matchId, dice, ply)),
cubeDecision: (positionId, matchId, ply) =>
run(commands.cubeDecision(positionId, matchId, ply)),
evaluate: (positionId, matchId, ply) =>
run(commands.evaluate(positionId, matchId, ply)),
reference: (positionId, matchId, dice, ply) =>
run(commands.reference(positionId, matchId, dice, ply)),
};
}
-48
View File
@@ -1,48 +0,0 @@
<!doctype html>
<html lang="fr">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Ustagammon : test du moteur</title>
<link rel="stylesheet" href="style.css">
<script type="module" src="page.mjs"></script>
</head>
<body>
<main>
<h1>Test du moteur</h1>
<p>Le moteur GNU Backgammon, compilé en WebAssembly, calcule dans le navigateur.</p>
<p id="status" class="status">Chargement du moteur…</p>
<form id="form">
<label>Exemple
<select id="example"><option value="">(choisir une position de référence)</option></select>
</label>
<label>Position ID
<input id="position-id" required autocomplete="off" spellcheck="false" value="4HPwATDgc/ABMA">
</label>
<label>Match ID
<input id="match-id" required autocomplete="off" spellcheck="false" value="cIkFAAAAAAAA">
</label>
<div class="row">
<label>Dé 1 <input id="die1" type="number" min="1" max="6" required value="3"></label>
<label>Dé 2 <input id="die2" type="number" min="1" max="6" required value="1"></label>
<label>Niveau
<select id="ply">
<option value="0">rapide (0 pli)</option>
<option value="2">fort (2 plis)</option>
</select>
</label>
</div>
<button id="best-move" type="submit" disabled>Meilleur coup</button>
</form>
<section id="result" class="result" hidden>
<p>Meilleur coup : <strong id="move"></strong></p>
<p id="details"></p>
<p>Temps de calcul : <strong id="time"></strong></p>
</section>
<p><a href="bench.html">Page de mesure</a> · <a href="../">L'application</a></p>
</main>
</body>
</html>
-71
View File
@@ -1,71 +0,0 @@
// page.mjs -- page de test du moteur (index.html) : meilleur coup d'une
// position, avec son temps de calcul.
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
import { startEngine } from "./engine-client.mjs";
const $ = (id) => document.getElementById(id);
const engine = startEngine();
engine.ready.then(
({ loadMs }) => {
$("status").textContent = "Moteur prêt, chargé en " + Math.round(loadMs) + " ms.";
$("best-move").disabled = false;
},
(error) => {
$("status").textContent = "Le moteur n'a pas pu se charger : " + error.message;
$("status").classList.add("error");
},
);
// Exemples : les positions de référence avec des dés.
fetch("../wasm/positions.json")
.then((response) => response.json())
.then(({ positions }) => {
for (const p of positions.filter((p) => p.type === "move")) {
const option = new Option(p.id + " : " + p.description, p.id);
option.position = p;
$("example").add(option);
}
})
.catch(() => {}); // la page reste utilisable sans les exemples
$("example").onchange = () => {
const p = $("example").selectedOptions[0].position;
if (p) {
$("position-id").value = p.position_id;
$("match-id").value = p.match_id;
[$("die1").value, $("die2").value] = p.dice;
}
};
function showResult(move, details, time) {
$("move").textContent = move;
$("details").textContent = details;
$("time").textContent = time;
$("result").hidden = false;
}
$("form").onsubmit = async (event) => {
event.preventDefault();
$("best-move").disabled = true;
$("result").hidden = true;
try {
const { result, ms } = await engine.bestMove($("position-id").value.trim(),
$("match-id").value.trim(), [$("die1").value, $("die2").value], $("ply").value);
const best = result.best_move;
if (best) {
showResult(best.move, "Équité " + best.equity.toFixed(3).replace(".", ",")
+ ", chances de gain " + (100 * best.probabilities.win).toFixed(1).replace(".", ",")
+ " %, évalué à " + best.ply + " pli(s).", Math.round(ms) + " ms");
} else {
showResult("aucun coup possible", "", Math.round(ms) + " ms");
}
} catch (error) {
showResult("erreur", error.message, "");
} finally {
$("best-move").disabled = false;
}
};
-113
View File
@@ -1,113 +0,0 @@
/* Pages de test du moteur : mise en forme minimale, lisible sur téléphone. */
:root {
--text: #1d1d1b;
--background: #fbfaf7;
--muted: #5f5e5a;
--accent: #1f5c4a;
--error: #a3261b;
--border: #cfccc4;
}
@media (prefers-color-scheme: dark) {
:root {
--text: #ecebe6;
--background: #1b1c1a;
--muted: #a9a8a2;
--accent: #7cc4a8;
--error: #f08a7e;
--border: #4a4a46;
}
}
* {
box-sizing: border-box;
}
body {
margin: 0;
font: 17px/1.5 system-ui, sans-serif;
color: var(--text);
background: var(--background);
}
main {
max-width: 40rem;
margin: 0 auto;
padding: 1rem;
}
h1 {
font-size: 1.5rem;
}
label {
display: block;
margin-bottom: 0.75rem;
}
input,
select,
button,
textarea {
display: block;
width: 100%;
margin-top: 0.25rem;
padding: 0.5rem;
font: inherit;
color: inherit;
background: transparent;
border: 1px solid var(--border);
border-radius: 6px;
}
.row {
display: flex;
gap: 0.75rem;
}
.row label {
flex: 1;
}
button {
margin: 0.5rem 0 1rem;
font-weight: 600;
color: var(--background);
background: var(--accent);
border-color: var(--accent);
}
button:disabled {
opacity: 0.5;
}
textarea {
height: 16rem;
font: 13px/1.4 monospace;
}
table {
width: 100%;
border-collapse: collapse;
}
th,
td {
padding: 0.25rem;
text-align: left;
border-bottom: 1px solid var(--border);
}
.status {
color: var(--muted);
}
.error {
color: var(--error);
}
.result {
padding: 0.5rem 1rem;
border-left: 4px solid var(--accent);
}