Compare commits

...
Author SHA1 Message Date
pierre 6a1ee6f65a Fusion de la MR !54 : retours des premiers joueurs 2026-11-11 21:00:44 +00:00
Pierre MARTINandClaude Opus 5.5 22e415bb81 Charte : retours des premiers joueurs (pions sombres lisibles, numéros sur le cadre, jans, flèches circulaires)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 22:00:42 +01:00
Pierre MARTINandClaude Opus 5.5 21baddcea3 Fusion de la MR !53 : corrections, saisie de la sortie, largeur du tutoriel, notation du coup de l'ordinateur
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 21:53:20 +01:00
Pierre MARTINandClaude Opus 5.5 7a6c0ac2ac Aide : texte de « Sortir ses pions dans l'ordre de son choix » choisi par Pierre
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 21:53:14 +01:00
Pierre MARTINandClaude Opus 5.5 90f835869a Plan des corrections clos : résultat dans la description de la MR
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 20:24:22 +01:00
Pierre MARTINandClaude Opus 5.5 ce36d738fb Saisie : plus de demi-coup demandé une fois tous les dés joués
Après un double complet, la saisie restée ouverte demandait au moteur un
cinquième demi-coup, que la ligne de commande refuse (erreur dans la page,
relevée par make test-browser). Test : un moteur qui refuse plus de
demi-coups que de dés.

Coup de l'ordinateur : la recette du navigateur vérifie la ligne
« L'ordinateur a joué … » au premier coup montré.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 20:05:14 +01:00
Pierre MARTINandClaude Opus 5.5 fbf317909a Scénario de sortie : mise en forme Biome
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 19:44:36 +01:00
Pierre MARTINandClaude Opus 5.5 b2263c7e3d Tutoriel : même largeur à chaque étape ; règle ajoutée à la recette
Les pages sont des éléments de la colonne flexible #screen ; leurs marges
automatiques annulaient l'étirement, et une page au contenu court se
réduisait à la largeur de ce contenu : l'étape 6 « à vous » (consigne
courte, pas d'explication) avait un plateau de 300 px au lieu de 396 en
412 × 915. Les pages prennent toute la largeur, bornée par max-width.
La recette mesure le plateau et le texte à chaque écran du tutoriel, dans
les trois tailles, et refuse un plateau de largeur différente ou une page
plus large que l'écran (mesures dans tutoriel-largeurs.txt).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 19:42:41 +01:00
Pierre MARTINandClaude Opus 5.5 4e74bab3ac Écran de jeu : le coup de l'ordinateur dans la notation du moteur
« L'ordinateur a joué 6-4 : 6/off » au lieu de « 6/2 2/off » : la
notation vient du coup légal de gnubg qui mène à la même position
(Game.moveNotation), comme dans l'analyse et le bilan. Testé : sur une
partie analysée, chaque coup s'écrit pareil des deux côtés.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 19:37:46 +01:00
Pierre MARTINandClaude Opus 5.5 ab8dc512ba Sortie : un coup déjà complet peut encore se prolonger (6/off puis 1/off avec 6-1)
La partie fermait la saisie dès que les demi-coups formaient un coup
légal. En sortie, 6/off seul mène à la même position que 6/5/off : le
1/off qui suivait était refusé. La saisie reste ouverte tant que le
moteur accepte un demi-coup de plus ; le tutoriel suit la même règle.
Tests : tous les ordres de saisie des fins de partie d'un à quatre pions,
sur les 21 jets, scénario Gherkin et tests du moteur pour les deux ordres.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 19:36:15 +01:00
Pierre MARTINandClaude Opus 5.5 e9bb9ae88d Plan des corrections : sortie, largeur du tutoriel, notation de l'ordinateur
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 16:12:29 +01:00
Pierre MARTINandClaude Opus 5.5 f3f977bf66 Fusion de la MR !52 : bilan de fin de partie et écran d'analyse (1e)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 16:03:43 +01:00
16 changed files with 536 additions and 35 deletions
+52 -9
View File
@@ -27,7 +27,14 @@ import {
withAnalysesOf,
} from "./events.js";
import { firstGameOf, type Match, nextGameOf } from "./match.js";
import { BAR, type HalfMove, OFF, type PositionId, START_POSITION } from "./position.js";
import {
BAR,
formatHalfMoves,
type HalfMove,
OFF,
type PositionId,
START_POSITION,
} from "./position.js";
import { type DiceSource, type Roll, randomDice, rollDice } from "./roll.js";
import { otherSide, type Side } from "./side.js";
import { type GameState, replay } from "./state.js";
@@ -186,8 +193,11 @@ export class Game {
}
/** 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). */
* peut être validé (confirmMove) ou défait (undoLastHalfMove). La saisie
* n'est pas close pour autant : en sortie, un coup complet peut encore
* se prolonger en un autre coup légal (avec 6-1 et des pions sur 6 et 1,
* 6/off seul mène à la même position que 6/5/off, et 1/off reste
* possible) ; nextHalfMoves dit lesquels, selon le moteur. */
get moveComplete(): boolean {
return this.#pending.at(-1)?.complete === true;
}
@@ -273,6 +283,29 @@ export class Game {
return { side, halfMoves, boards, hits };
}
/**
* Le coup joué à l'événement eventId (un MovePlayed), dans la notation
* du moteur : celle du coup légal de gnubg qui mène à la même position
* (« 6/off » et non « 6/5 5/off »), comme dans l'analyse et le bilan.
* À défaut (aucun coup légal ne correspond), les demi-coups ; chaîne
* vide si l'événement n'est pas un coup ou si aucun pion n'a bougé.
*/
async moveNotation(eventId: string): Promise<string> {
const line = currentLine({ ...this.#journal, head: eventId });
const event = line.at(-1);
if (event?.type !== "MovePlayed" || event.halfMoves.length === 0) {
return "";
}
const before = replay(line.slice(0, -1));
const roll = rollToPlay(before);
const [check, legal] = await Promise.all([
this.#engine.checkHalfMoves(before.position, roll, event.halfMoves),
this.#engine.legalMoves(before.position, roll),
]);
const move = legal.find((m) => m.position === check.position);
return move?.notation ?? formatHalfMoves(event.halfMoves);
}
/** Les conseils du maître déjà rattachés aux décisions du joueur, par
* identifiant d'événement. */
get advice(): Readonly<Record<string, Advice>> {
@@ -296,11 +329,14 @@ export class Game {
* déjà saisis (liste vide hors de son tour de jeu). Chaque candidat
* déplace un de ses pions de la valeur d'un dé (vers la sortie si le dé
* dépasse la case 1) ; seuls restent ceux que le moteur déclare menant à
* un coup légal. Aucune règle n'est jugée ici.
* un coup légal. Aucune règle n'est jugée ici : même après un coup
* complet, c'est le moteur qui dit si un dé reste à jouer. Seule
* exception : une fois tous les dés utilisés (diceLeft), rien n'est
* demandé, le moteur n'acceptant pas plus de demi-coups que de dés.
*/
async nextHalfMoves(): Promise<HalfMove[]> {
const state = this.state;
if (state.turn !== "player" || state.phase !== "moving" || this.moveComplete) {
if (state.turn !== "player" || state.phase !== "moving" || !this.#diceLeft(state)) {
return [];
}
const roll = rollToPlay(state);
@@ -370,13 +406,13 @@ export class Game {
/**
* Le joueur déplace un pion (un dé). Renvoie false si ce demi-coup ne
* 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).
* mène à aucun coup légal : 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), ou prolongé si le moteur le permet.
*/
async playHalfMove(halfMove: HalfMove): Promise<boolean> {
const state = this.#expect("player", "moving");
if (this.moveComplete) {
if (!this.#diceLeft(state)) {
return false;
}
const check = await this.#engine.checkHalfMoves(state.position, rollToPlay(state), [
@@ -393,6 +429,13 @@ export class Game {
return true;
}
/** Il reste un dé à jouer : moins de demi-coups saisis que de dés
* (quatre pour un double). */
#diceLeft(state: GameState): boolean {
const roll = rollToPlay(state);
return this.#pending.length < (roll[0] === roll[1] ? 4 : 2);
}
/** 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 {
+7 -3
View File
@@ -209,10 +209,14 @@ export class MovePractice {
if (!(await this.#game.playHalfMove(halfMove))) {
return "wrong";
}
if (this.#game.moveComplete) {
const position = this.#game.displayedPosition;
return this.#expected.some((m) => m.position === position) ? "right" : "wrong";
if (
this.#game.moveComplete &&
this.#expected.some((m) => m.position === this.#game.displayedPosition)
) {
return "right";
}
// Un coup complet peut encore se prolonger (en sortie) : il n'est
// refusé que s'il ne peut plus mener à un coup attendu.
return canStillLeadTo(this.#game.pendingHalfMoves, this.#expected) ? "continue" : "wrong";
}
+6 -5
View File
@@ -32,7 +32,7 @@ import type { Advice } from "../domain/game/advice.js";
import type { Cube } from "../domain/game/cube.js";
import type { GameJournal } from "../domain/game/events.js";
import type { Game, MoveSteps, NewGame, SideBoards } from "../domain/game/game.js";
import { formatHalfMoves, type HalfMove } from "../domain/game/position.js";
import type { HalfMove } from "../domain/game/position.js";
import { type PositionLink, type SharedPosition, setupOf } from "../domain/game/position-link.js";
import type { SavedGameSummary } from "../domain/game/saved-game.js";
import { otherSide, type Side } from "../domain/game/side.js";
@@ -1018,7 +1018,7 @@ export function startApp(options: AppOptions): void {
while (game !== null && game.state.turn === "computer" && isPlaying(game)) {
await refresh();
await game.playComputerTurn();
info = describeComputerTurn(game);
info = await describeComputerTurn(game);
save();
played = true;
}
@@ -2500,8 +2500,9 @@ async function computerMoveToShow(game: Game): Promise<MoveSteps | null> {
return game.lastMoveSteps();
}
/** Ce qu'a fait l'ordinateur pendant son tour (sans durée de calcul). */
function describeComputerTurn(game: Game): string {
/** Ce qu'a fait l'ordinateur pendant son tour (sans durée de calcul). Son
* coup s'écrit dans la notation du moteur, comme dans l'analyse. */
async function describeComputerTurn(game: Game): Promise<string> {
const last = game.events.at(-1);
if (last?.type === "CubeOffered") {
return "";
@@ -2514,7 +2515,7 @@ function describeComputerTurn(game: Game): string {
if (played.halfMoves.length === 0) {
return TEXTS.computerCannotMove(dice);
}
return TEXTS.computerMoved(dice, formatHalfMoves(played.halfMoves));
return TEXTS.computerMoved(dice, await game.moveNotation(played.id));
}
/** Le résultat d'une partie finie, en deux parties : le titre du panneau
+230
View File
@@ -0,0 +1,230 @@
// Saisie d'un coup de sortie demi-coup par demi-coup, sur le vrai moteur
// WebAssembly : tout ordre de saisie que le moteur accepte doit être accepté
// par la partie, même quand un début de coup forme déjà un coup complet.
//
// Défaut corrigé (2026-11-10) : avec 6-1 et des pions sur 6 et 1, 6/off
// mène à la même position que le coup légal « 6/5/off » ; la partie le
// tenait pour complet et refusait ensuite 1/off, alors que 1/off puis 6/off
// passait.
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
import assert from "node:assert/strict";
import { test } from "node:test";
import type { Engine } from "../src/domain/game/engine.js";
import { Game } from "../src/domain/game/game.js";
import { formatHalfMoves, type HalfMove } from "../src/domain/game/position.js";
import { fixedDice, type Roll } from "../src/domain/game/roll.js";
import { loadNodeEngine } from "./support/node-engine.js";
// Le joueur, au trait, a un pion sur sa case 6 et un sur sa case 1 ;
// l'ordinateur un sur sa case 1 et un sur sa case 2. Le cas signalé par
// Pierre (#/position/BQAACAIAAAAAAA, « Jouer à partir d'ici »).
const SIX_AND_ONE = "BQAACAIAAAAAAA";
/**
* Position ID (format de gnubg) d'une fin de partie : les pions du joueur au
* trait sur ses cases 1 à 6 (player[i] : nombre de pions sur la case i + 1),
* l'ordinateur avec un pion sur sa case 1 et un sur sa case 2 ; les autres
* pions sont sortis. Outil de test seulement : chaque position produite est
* relue par le moteur, qui doit y retrouver ces pions.
*/
function bearOffPosition(player: readonly number[]): string {
const bits: number[] = [];
const side = (counts: readonly number[]) => {
for (let point = 0; point < 25; point++) {
for (let k = 0; k < (counts[point] ?? 0); k++) {
bits.push(1);
}
bits.push(0);
}
};
side([1, 1]); // l'adversaire d'abord, comme dans gnubg
side(player);
const bytes = new Uint8Array(10);
bits.forEach((bit, i) => {
if (bit === 1) {
bytes[i >> 3] = (bytes[i >> 3] ?? 0) | (1 << (i & 7));
}
});
return Buffer.from(bytes).toString("base64").slice(0, 14);
}
/** Une partie où le joueur, au trait dans position, vient de lancer roll. */
async function rolled(engine: Engine, position: string, roll: Roll): Promise<Game> {
const game = Game.start(engine, {
dice: fixedDice(roll),
setup: { position, firstTurn: "player" },
});
await game.roll();
return game;
}
/** Toutes les permutations distinctes des demi-coups. */
function orders(halfMoves: readonly HalfMove[]): HalfMove[][] {
if (halfMoves.length <= 1) {
return [[...halfMoves]];
}
const seen = new Set<string>();
const result: HalfMove[][] = [];
halfMoves.forEach((h, i) => {
const rest = [...halfMoves.slice(0, i), ...halfMoves.slice(i + 1)];
for (const tail of orders(rest)) {
const order = [h, ...tail];
const key = formatHalfMoves(order);
if (!seen.has(key)) {
seen.add(key);
result.push(order);
}
}
});
return result;
}
test("le cas signalé est bien celui que décrit le test", async () => {
const engine = await loadNodeEngine();
assert.equal(bearOffPosition([1, 0, 0, 0, 0, 1]), SIX_AND_ONE);
const board = await engine.board(SIX_AND_ONE, { value: 1, owner: "centered" });
assert.deepEqual(board.onRoll.points.slice(0, 6), [1, 0, 0, 0, 0, 1]);
});
test("6-1 en sortie : 6/off puis 1/off, comme 1/off puis 6/off", async () => {
const engine = await loadNodeEngine();
const allOff = (await engine.legalMoves(SIX_AND_ONE, [6, 1])).find(
(m) => m.notation === "6/off 1/off",
);
assert.ok(allOff, "le moteur propose 6/off 1/off");
// 6/off d'abord : la position est déjà celle d'un coup légal (6/5/off),
// mais le 1 reste jouable et le moteur l'accepte.
const six = await rolled(engine, SIX_AND_ONE, [6, 1]);
assert.equal(await six.playHalfMove({ from: 6, to: 0 }), true);
assert.equal(six.moveComplete, true);
assert.deepEqual(await six.nextHalfMoves(), [{ from: 1, to: 0 }]);
assert.equal(await six.playHalfMove({ from: 1, to: 0 }), true);
assert.equal(six.moveComplete, true);
assert.equal(six.displayedPosition, allOff.position);
assert.deepEqual(
six.diceToPlay.map((d) => d.used),
[true, true],
);
await six.confirmMove();
assert.equal(six.state.phase, "over");
// 1/off d'abord : pas encore complet, puis 6/off.
const one = await rolled(engine, SIX_AND_ONE, [6, 1]);
assert.equal(await one.playHalfMove({ from: 1, to: 0 }), true);
assert.equal(one.moveComplete, false);
assert.equal(await one.playHalfMove({ from: 6, to: 0 }), true);
assert.equal(one.displayedPosition, allOff.position);
// Et le coup 6/off seul (6/5/off) se valide toujours tel quel.
const alone = await rolled(engine, SIX_AND_ONE, [6, 1]);
assert.equal(await alone.playHalfMove({ from: 6, to: 0 }), true);
await alone.confirmMove();
assert.equal(alone.state.turn, "computer");
});
test("tous les dés joués, le moteur n'est plus interrogé", async () => {
// Le moteur de la page refuse plus de quatre demi-coups (erreur de la
// ligne de commande) : la partie ne doit pas en demander un cinquième
// après un double, ni un troisième après un jet simple.
const real = await loadNodeEngine();
const engine: Engine = {
...real,
checkHalfMoves: (position, roll, halfMoves) => {
const dice = roll[0] === roll[1] ? 4 : 2;
if (halfMoves.length > dice) {
throw new Error(`${halfMoves.length} demi-coups pour ${dice} dés`);
}
return real.checkHalfMoves(position, roll, halfMoves);
},
};
const double = await rolled(engine, bearOffPosition([0, 0, 2, 2, 0, 0]), [2, 2]);
for (const h of [
{ from: 4, to: 2 },
{ from: 4, to: 2 },
{ from: 3, to: 1 },
{ from: 3, to: 1 },
]) {
assert.equal(await double.playHalfMove(h), true);
}
assert.equal(double.moveComplete, true);
assert.deepEqual(await double.nextHalfMoves(), []);
assert.equal(await double.playHalfMove({ from: 2, to: 0 }), false);
const simple = await rolled(engine, SIX_AND_ONE, [6, 1]);
assert.equal(await simple.playHalfMove({ from: 1, to: 0 }), true);
assert.equal(await simple.playHalfMove({ from: 6, to: 0 }), true);
assert.deepEqual(await simple.nextHalfMoves(), []);
});
test("en sortie, tout ordre accepté par le moteur l'est par la partie", async () => {
const engine = await loadNodeEngine();
// Toutes les fins de partie d'un à trois pions dans le jan intérieur,
// et quelques-unes à quatre (doubles), avec les 21 jets.
const positions: number[][] = [];
for (let a = 0; a < 6; a++) {
positions.push([a]);
for (let b = a; b < 6; b++) {
positions.push([a, b]);
for (let c = b; c < 6; c++) {
positions.push([a, b, c]);
}
}
}
positions.push([0, 0, 0, 0], [1, 2, 3, 4], [5, 5, 4, 4], [0, 2, 4, 5], [3, 3, 3, 3]);
const rolls: Roll[] = [];
for (let a = 1; a <= 6; a++) {
for (let b = 1; b <= a; b++) {
rolls.push([a, b]);
}
}
const failures: string[] = [];
let checked = 0;
for (const pips of positions) {
const counts = [0, 0, 0, 0, 0, 0];
for (const p of pips) {
counts[p] = (counts[p] ?? 0) + 1;
}
const position = bearOffPosition(counts);
for (const roll of rolls) {
const moves = await engine.legalMoves(position, roll);
const game = await rolled(engine, position, roll);
for (const move of moves) {
for (const order of orders(move.halfMoves)) {
// Le moteur doit accepter chaque début de cet ordre…
let accepted = true;
for (let i = 1; i <= order.length && accepted; i++) {
accepted = (await engine.checkHalfMoves(position, roll, order.slice(0, i)))
.valid;
}
if (!accepted) {
continue;
}
// … et alors la partie aussi, jusqu'au coup complet.
game.cancelHalfMoves();
checked++;
const label = `${position} ${roll.join("-")} ${formatHalfMoves(order)}`;
for (const h of order) {
const offered = (await game.nextHalfMoves()).some(
(n) => n.from === h.from && n.to === h.to,
);
if (!offered || !(await game.playHalfMove(h))) {
failures.push(`${label} : ${formatHalfMoves([h])} refusé`);
break;
}
}
if (game.pendingHalfMoves.length === order.length) {
if (!game.moveComplete || game.displayedPosition !== move.position) {
failures.push(`${label} : coup incomplet ou position différente`);
}
}
}
}
}
}
assert.ok(checked > 1000, `${checked} ordres vérifiés`);
assert.deepEqual(failures, []);
});
+27 -1
View File
@@ -30,7 +30,7 @@ import {
withAnalysesOf,
} from "../src/domain/game/events.js";
import { Game } from "../src/domain/game/game.js";
import { START_POSITION } from "../src/domain/game/position.js";
import { formatHalfMoves, START_POSITION } from "../src/domain/game/position.js";
import { emptyReview, REVIEW_VERSION } from "../src/domain/game/review.js";
import { loadNodeEngine } from "./support/node-engine.js";
import { playToTheEnd, seededDice } from "./support/play.js";
@@ -315,4 +315,30 @@ test("une partie complète analysée par le moteur, comme une partie d'avant 1d"
// Le journal analysé se reprend : l'analyse fait partie du format.
const resumed = Game.resume(engine, JSON.parse(JSON.stringify(analysed)));
assert.deepEqual(resumed.journal.review, analysed.review);
// Chaque coup s'écrit pareil sur l'écran de jeu (« L'ordinateur a
// joué … », moveNotation) et dans l'analyse : la notation du moteur.
let compared = 0;
let rewritten = 0;
for (const move of moves) {
if (move.type !== "MovePlayed" || move.halfMoves.length === 0) {
continue;
}
const advice =
move.side === "computer"
? analysed.review?.computer[move.id]
: analysed.advice?.[move.id];
if (advice?.kind !== "move") {
continue;
}
const notation = await resumed.moveNotation(move.id);
assert.equal(notation, advice.played.notation, `coup ${move.id}`);
compared++;
if (notation !== formatHalfMoves(move.halfMoves)) {
rewritten++;
}
}
assert.ok(compared > 5, `${compared} coups comparés`);
// La course finit en sortie : au moins un coup s'écrit autrement que
// ses demi-coups (« 6/off » pour « 6/2 2/off »…).
assert.ok(rewritten > 0, "aucun coup réécrit par le moteur");
});
+30
View File
@@ -476,6 +476,36 @@ Given("un joueur a sorti tous ses pions", async () => {
assert.equal(await playAndConfirm("6/off 5/off"), true);
});
// ------------------------------------------------- Sortir dans tout ordre
// Le joueur, au trait : un pion sur sa case 6, un sur sa case 1 ;
// l'ordinateur un pion sur sa case 1 et un sur sa case 2.
const SIX_AND_ONE = "BQAACAIAAAAAAA";
Given("vous avez un pion sur la flèche 6 et un sur la flèche 1, avec le jet 6-1", async () => {
game = Game.start(engine, {
setup: { position: SIX_AND_ONE, firstTurn: "player" },
dice: fixedDice([6, 1]),
});
await game.roll();
});
Then(/^vous pouvez valider ou jouer encore (.+)$/, async (text: string) => {
assert.equal(accepted, true);
// 6/off seul mène à la même position que 6/5/off, un coup légal.
assert.equal(game.moveComplete, true);
assert.deepEqual(await game.nextHalfMoves(), parseHalfMoves(text));
});
Then("vos deux pions sont sortis", async () => {
assert.equal(accepted, true);
assert.equal(game.moveComplete, true);
const board = await game.displayedBoard();
assert.equal(board.player.off, 15);
await game.confirmMove();
assert.equal(game.state.phase, "over");
});
Then("le résultat indique le gagnant et les points gagnés", () => {
// Gammon (l'ordinateur n'a sorti aucun pion), mais 1 point seulement :
// règle Jacoby, le videau n'a pas bougé.
+15
View File
@@ -174,6 +174,21 @@ test("le joueur peut revenir en arrière après un coup refusé", async () => {
assert.equal(await practice.play({ from: 6, to: 5 }), "right");
});
test("en sortie, un coup déjà complet peut encore mener au coup attendu", async () => {
// Pions sur 6 et 1, jet 6-1 : 6/off seul est déjà un coup complet (même
// position que 6/5/off), mais le coup attendu sort les deux pions.
const engine = await loadNodeEngine();
const first = await MovePractice.start(engine, "BQAACAIAAAAAAA", [6, 1], ["6/off 1/off"]);
assert.equal(await first.play({ from: 6, to: 0 }), "continue");
assert.equal(await first.play({ from: 1, to: 0 }), "right");
const other = await MovePractice.start(engine, "BQAACAIAAAAAAA", [6, 1], ["6/off 1/off"]);
assert.equal(await other.play({ from: 1, to: 0 }), "continue");
assert.equal(await other.play({ from: 6, to: 0 }), "right");
// 6/5 puis 5/off : complet, mais ce n'est pas le coup attendu.
const wrong = await MovePractice.start(engine, "BQAACAIAAAAAAA", [6, 1], ["6/off 1/off"]);
assert.equal(await wrong.play({ from: 6, to: 5 }), "wrong");
});
test("la réponse au double de l'étape 8 est celle du moteur", async () => {
const engine = await loadNodeEngine();
for (const [name, screen] of screens()) {
+32 -13
View File
@@ -76,9 +76,11 @@ interrompre.
demande au moteur si le coup du joueur fait partie des coups permis attendus.
- **Les pions bougent** : sur un écran d'explication, le coup illustré se joue
tout seul après une seconde, pion par pion (`duration-fast`), puis reste
affiché ; « Revoir » le rejoue. Sur les écrans à flèches (sens du jeu), un
tracé en pointillés `text` sur un liseré ivoire, jamais en brique (la brique
veut dire « à faire »).
affiché ; « Revoir » le rejoue. Sur l'écran du sens du jeu, **deux flèches
circulaires** : une claire pour vos pions, une sombre pour ceux de
l'ordinateur, chacune dans le sens de son joueur et finissant par une pointe,
dessinées sous les pions, avec un liseré de la couleur opposée pour rester
lisibles sur le plateau ; jamais en brique (la brique veut dire « à faire »).
- Un geste juste : coche `verdict-good`, vibration « pion posé », phrase
courte, « Continuer » devient l'action principale. Un geste faux : le pion
revient, et une phrase dit pourquoi, sans le mot « erreur ».
@@ -90,11 +92,11 @@ interrompre.
1. **Le but du jeu.** « Vous avez quinze pions ivoire, l'ordinateur quinze
pions noyer. Chacun fait faire le tour du plateau à ses pions, puis les
sort. Le premier qui a sorti tous les siens gagne la partie. »
2. **Dans quel sens avancer.** « Vos pions ivoire font le tour du plateau dans
le sens des pointillés : ils partent d'en haut à droite, passent à gauche,
puis reviennent en bas jusqu'aux six dernières flèches, en bas à droite.
C'est là que vous les rassemblerez avant de les sortir. Les pions noyer de
l'ordinateur font le même chemin, en sens inverse. »
2. **Dans quel sens avancer.** « Vos pions suivent la flèche claire : ils
partent d'en haut à droite, passent à gauche, puis reviennent en bas jusqu'à
votre jan intérieur, les flèches 1 à 6. C'est là que vous les rassemblez
avant de les sortir. Les pions de l'ordinateur suivent la flèche sombre, en
sens inverse, jusqu'à son jan intérieur, les flèches 19 à 24. »
3. **Les dés et les doubles.** « Chaque dé déplace un pion d'autant de
flèches. Avec 5 et 2, vous jouez un pion de 5 et un pion de 2, ou le même
pion de 5 puis de 2. Un double se joue quatre fois : avec 4-4, vous avancez
@@ -106,16 +108,16 @@ interrompre.
point 7 avec 6-1.
5. **Rentrer depuis la barre.** « Votre pion frappé attend sur la barre, au
milieu. Avant de jouer quoi que ce soit d'autre, il doit rentrer dans le
camp adverse, en haut à droite. Chaque dé indique une flèche d'entrée :
jan intérieur de l'ordinateur, en haut à droite. Chaque dé indique une flèche d'entrée :
avec 3 et 4, la 22 ou la 21. Si les deux flèches sont fermées, votre tour
passe. »
6. **Sortir ses pions.** « Quand vos quinze pions sont dans les six dernières
flèches, vous pouvez commencer à les sortir : un 6 sort un pion de la
6. **Sortir ses pions.** « Quand vos quinze pions sont dans votre jan intérieur
(flèches 1 à 6), vous pouvez commencer à les sortir : un 6 sort un pion de la
flèche 6, un 1 un pion de la flèche 1. Touchez le pion, puis la sortie à
droite. » *À vous* : sortir deux pions avec 6-3.
7. **Gammon et backgammon.** « Une partie gagnée vaut 1 point. Si
l'adversaire n'a sorti aucun pion, c'est un gammon : 2 points ; s'il lui
reste en plus un pion sur la barre ou dans votre camp, c'est un
reste en plus un pion sur la barre ou dans votre jan intérieur, c'est un
backgammon : 3 points. En partie libre, gammon et backgammon ne comptent
que si le videau a été doublé : c'est la règle de Jacoby. »
8. **Le videau.** « Le cube aux faces 2, 4, 8, 16, 32 et 64 sert à miser.
@@ -164,7 +166,7 @@ ouvrir, partager et retrouver avec le bouton « retour » du téléphone.
| « Je découvre », « Toutes les leçons » | `#/tutoriel/1` |
| Étape *n* du tutoriel (« Continuer », « Retour », segments de la barre de progression) | `#/tutoriel/<n>` |
| « Je connais les règles », « Reprendre la partie » | l'écran de jeu |
| Un terme du jeu dans un texte (blot, barre, camp…) | `#/glossaire/<terme>` |
| Un terme du jeu dans un texte (blot, barre, jan intérieur…) | `#/glossaire/<terme>` |
| Une position illustrée, « Voir sur le plateau » | `#/position/<Position ID>/<Match ID>` |
| Aide, onglet « Jouer » | `#/aide` ; une question ouverte : `#/aide/<scénario>` |
| Menu | `#/menu` |
@@ -174,3 +176,20 @@ ouvrir, partager et retrouver avec le bouton « retour » du téléphone.
- Le bouton « retour » du téléphone ferme d'abord la feuille ou la question
ouverte, puis revient à l'écran précédent ; il ne quitte jamais une partie
sans retour possible.
## Vocabulaire : les jans
Le plateau se partage en quatre jans de six flèches. Les nommer simplifie les
explications (retour des premiers joueurs, 2026-11-11) :
| Flèches | Nom |
| --- | --- |
| 1 à 6 | votre jan intérieur |
| 7 à 12 | votre jan extérieur |
| 13 à 18 | le jan extérieur de l'ordinateur |
| 19 à 24 | le jan intérieur de l'ordinateur |
Le tutoriel, l'aide, le maître et les leçons emploient ces mots, avec un lien
vers leur fiche du glossaire à la première apparition. Fiches à créer :
`jan-interieur.md` (anglais : home board) et `jan-exterieur.md` (anglais :
outer board). « Camp » n'est plus employé dans les textes.
+2
View File
@@ -8,6 +8,8 @@ Référence : `interaction.md`, maquettes « Révision UX » du canevas.
## Toutes tailles
- [ ] Flèches impaires en `point-a`, paires en `point-b` ; 12 et 13 de couleurs différentes.
- [ ] Numéros des flèches à l'extérieur du plateau, sur le cadre (bande haute et bande basse), en portrait comme en paysage ; jamais dans le fond du plateau entre les deux rangées.
- [ ] Pions de l'ordinateur lisibles partout : contour et filet `checker-computer-edge` (clairs) visibles sur la barre, sur les flèches sombres et en thème sombre.
- [ ] Une seule action principale (brique) visible à la fois.
- [ ] Aucun `window.confirm` ni `alert` dans le code de l'app (`grep -rn "confirm(\|alert(" app/src` vide).
- [ ] Aucune cible tactile sous 44 × 44 px (boutons, pictogrammes, dés, videau).
+3 -3
View File
@@ -240,10 +240,10 @@
{
"name": "checker-computer-edge",
"value": {
"light": "#120A05",
"dark": "#A8957D"
"light": "#B8AC98",
"dark": "#B8AC98"
},
"usage": "Contour et filet des pions de l'ordinateur ; clair en thème sombre pour rester visible."
"usage": "Contour et filet des pions de l'ordinateur : clairs dans les deux thèmes, pour que les pions sombres se détachent de la barre, des flèches sombres et du fond sombre (retour des premiers joueurs, 2026-11-11)."
},
{
"name": "count-player",
+18
View File
@@ -142,6 +142,24 @@ Fonctionnalité: Jouer une partie contre l'ordinateur
Alors la partie s'arrête
Et vous perdez la partie à sa valeur d'avant le double
@position:BQAACAIAAAAAAA
Scénario: Sortir ses pions dans l'ordre de son choix
Une fois tous vos pions dans les six dernières flèches, vous les
sortez dans l'ordre que vous voulez. Avec 6-1, un pion sur la flèche 6
et un sur la flèche 1 : sortez l'un puis l'autre, ou l'inverse. Si vous
ne sortez que le pion de la 6, « Jouer ce coup » apparaît déjà, car ce
pion a pu utiliser vos deux dés (6/5, puis 5/off). Touchez l'autre pion
pour le sortir aussi.
Étant donné que vous avez un pion sur la flèche 6 et un sur la flèche 1, avec le jet 6-1
Quand vous jouez 6/off
Alors vous pouvez valider ou jouer encore 1/off
Quand vous jouez 1/off
Alors vos deux pions sont sortis
Étant donné que vous avez un pion sur la flèche 6 et un sur la flèche 1, avec le jet 6-1
Quand vous jouez 1/off 6/off
Alors vos deux pions sont sortis
Scénario: Comment se termine une partie ?
La partie s'arrête dès qu'un joueur a sorti tous ses pions. Le panneau de
fin dit qui gagne, et combien de points : la valeur du videau, doublée
+19 -1
View File
@@ -16,7 +16,7 @@
import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
import { chromium, firefox } from "playwright";
import { hiddenUnderActionBar } from "./recette.mjs";
import { hiddenUnderActionBar, tutorialWidthProblems, tutorialWidths } from "./recette.mjs";
const [base, browserName, shots, updateBase, updateDir] = process.argv.slice(2);
const browserType = { chromium, firefox }[browserName];
@@ -171,6 +171,7 @@ async function checkTutorialView(page, name) {
return b.scrollHeight - b.clientHeight;
})(),
height: innerHeight,
width: innerWidth,
buttons,
};
});
@@ -189,8 +190,13 @@ async function checkTutorialView(page, name) {
}
tutorialViews++;
tutorialMinNumber = Math.min(tutorialMinNumber, view.minNumber);
// Largeurs du plateau et du texte, comparées d'une étape à l'autre à la
// fin du tutoriel (tutorialWidthProblems).
const widths = await tutorialWidths(page);
tutorialWidthViews.push({ name, size: `${view.width}x${view.height}`, ...widths });
}
let tutorialViews = 0;
const tutorialWidthViews = [];
let tutorialBodyScroll = { px: 0, where: "" };
let tutorialMinNumber = Infinity;
@@ -331,6 +337,18 @@ async function playTouches(page, touches) {
await until(page, "fin", () => location.hash === "#/tutoriel/fin");
await page.click("#tutorial-next");
await until(page, "première partie", () => location.hash === "#/" && window.__shown("game"));
if (full) {
// Les mesures, écran par écran, à côté des captures.
writeFileSync(`${dir}/tutoriel-largeurs.txt`, tutorialWidthViews.map((v) =>
`${v.size}\t${v.name}\tplateau ${v.board.toFixed(1)}\ttexte ${v.text.toFixed(1)}\tpage ${v.page}`)
.join("\n") + "\n");
const widthProblems = tutorialWidthProblems(tutorialWidthViews);
check(widthProblems.length === 0, `largeurs du tutoriel :\n ${widthProblems.join("\n ")}`);
const sizes = [...new Set(tutorialWidthViews.map((v) => v.size))];
log(`tutoriel : plateau de même largeur à chaque étape (${sizes.map((s) =>
`${s} : ${tutorialWidthViews.find((v) => v.size === s).board.toFixed(1)} px`).join(", ")}), ` +
"aucune page plus large que l'écran");
}
log(`tutoriel : ${screens} écrans, gestes refusés puis justes sur les 4 écrans « à vous », fin` +
(full ? ` ; recette sur ${tutorialViews} vues, numéros de ${tutorialMinNumber.toFixed(1)} px au moins, ` +
`texte défilant au plus de ${tutorialBodyScroll.px} px${tutorialBodyScroll.px > 0 ? ` (${tutorialBodyScroll.where})` : ""}` : ""));
+6
View File
@@ -1193,6 +1193,12 @@ for (let actions = 0; ; actions++) {
tracesSeen++;
if (!moments.has("coup-ordinateur")) {
moments.add("coup-ordinateur");
// La ligne du dernier coup (portrait seulement : en paysage, le
// plateau occupe tout l'écran), dans la notation du moteur.
const played = (await page.textContent("#info")) ?? "";
check(/^L'ordinateur a joué \d-\d : \S.*\.$/.test(played),
`coup de l'ordinateur mal écrit : « ${played} »`);
log(`coup de l'ordinateur : « ${played} »`);
await review("4-coup-ordinateur");
}
if (replays < 2) {
+69
View File
@@ -98,3 +98,72 @@ export async function hiddenUnderActionBar(target) {
return hidden;
});
}
/**
* Les largeurs d'un écran du tutoriel, en px CSS : le plateau, la colonne
* de texte, et la largeur de la page (scrollWidth) face à celle de l'écran.
* Une page plus large que l'écran défile de côté ; sur un téléphone, Chrome
* la dézoome pour la montrer en entier, et tout l'écran paraît rétréci.
*/
export async function tutorialWidths(target) {
return target.evaluate(() => {
const width = (selector) => {
const el = document.querySelector(selector);
return el === null ? 0 : el.getBoundingClientRect().width;
};
const page = document.scrollingElement;
// Le plus à droite des descendants du tutoriel, pour nommer le
// coupable d'un débordement.
let widest = { what: "", right: 0 };
for (const el of document.querySelectorAll("#tutorial *")) {
const r = el.getBoundingClientRect();
if (r.width > 0 && r.right > widest.right) {
const name = el.id
? `#${el.id}`
: `${el.tagName.toLowerCase()}${[...el.classList].map((c) => `.${c}`).join("")}`;
widest = { what: name, right: r.right };
}
}
return {
board: width("#tutorial-board"),
text: width("#tutorial .tutorial-body"),
page: Math.max(page.scrollWidth, document.body.scrollWidth),
screen: innerWidth,
widest,
};
});
}
/**
* Règle de la recette : pour une taille d'écran donnée, le plateau du
* tutoriel a la même largeur à toutes les étapes, et aucune page n'est plus
* large que l'écran. views : [{ name, size, board, text, page, screen,
* widest }]. Renvoie la liste des écarts, décrits (vide si tout va bien).
*/
export function tutorialWidthProblems(views) {
const problems = [];
const bySize = new Map();
for (const v of views) {
bySize.set(v.size, [...(bySize.get(v.size) ?? []), v]);
if (v.page > v.screen + 0.5) {
problems.push(
`${v.name} en ${v.size} : page de ${v.page} px pour ${v.screen} px ` +
`(le plus à droite : ${v.widest.what}, ${Math.round(v.widest.right)} px)`,
);
}
}
for (const [size, list] of bySize) {
const widths = list.map((v) => v.board);
const min = Math.min(...widths);
const max = Math.max(...widths);
if (max - min > 0.5) {
const odd = list
.filter((v) => Math.abs(v.board - max) > 0.5)
.map((v) => `${v.name} ${v.board.toFixed(1)}`);
problems.push(
`${size} : plateau de ${min.toFixed(1)} à ${max.toFixed(1)} px (${odd.join(", ")})`,
);
}
}
return problems;
}
+11
View File
@@ -205,6 +205,17 @@ attendre_demi_coups "true false /38AAAAgAAAAAA" "$course" 65 "5/off"
# Course, jet 21 : il faut jouer les deux dés.
attendre_demi_coups "false false " "$course" 21 "6/3"
attendre_demi_coups "true true /38AAAAkAAAAAA" "$course" 21 "6/5 5/3"
# Sortie avec 6-1, pions sur 6 et 1 (BQAACAIAAAAAAA) : gnubg donne deux
# coups légaux, « 6/off 1/off » et « 6/off » (6/5/off, qui laisse le pion
# sur 1). 6/off seul mène déjà à la position de ce second coup (complet),
# mais 1/off reste permis ensuite : les deux ordres mènent à « tout sorti ».
sortie_61=BQAACAIAAAAAAA
grep -qF '"move": "6/off 1/off"' <<<"$($cli legal "$sortie_61" "$libre_j0" 61)" \
|| echouer "sortie 6-1 : coup légal « 6/off 1/off » attendu"
attendre_demi_coups "true true BQAACAAAAAAAAA" "$sortie_61" 61 "6/off"
attendre_demi_coups "true true BQAAAAAAAAAAAA" "$sortie_61" 61 "6/off 1/off"
attendre_demi_coups "true false BQAAAAEAAAAAAA" "$sortie_61" 61 "1/off"
attendre_demi_coups "true true BQAAAAAAAAAAAA" "$sortie_61" 61 "1/off 6/off"
# Aucun coup possible : rien n'est valide.
attendre_demi_coups "false false " 27YBBwAcz8EDQA 66
attendre_erreur usage halfmoves "$depart" 31 "8-5"
+9
View File
@@ -295,6 +295,15 @@ a {
flex-direction: column;
}
/* Les pages sont des éléments de la colonne flexible #screen. Leurs marges
* automatiques les centrent, mais annulent leur étirement en largeur : sans
* largeur explicite, une page au contenu court se réduit à la largeur de ce
* contenu (le plateau de l'étape 6 « à vous » du tutoriel passait de 396 à
* 300 px en 412 × 915). Leur max-width les borne toujours. */
.screen > .page {
width: 100%;
}
.spacer {
flex: 1;
}