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>
This commit was merged in pull request #53.
This commit is contained in:
Pierre MARTIN
2026-11-11 21:53:20 +01:00
co-authored by Claude Opus 5.5
13 changed files with 499 additions and 19 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()) {
+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;
}