diff --git a/app/src/domain/game/game.ts b/app/src/domain/game/game.ts index 0c56d4c..70963d5 100644 --- a/app/src/domain/game/game.ts +++ b/app/src/domain/game/game.ts @@ -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 { + 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> { @@ -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 { 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 { 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 { diff --git a/app/src/domain/learning/tutorial.ts b/app/src/domain/learning/tutorial.ts index 2f54f0e..c89ae59 100644 --- a/app/src/domain/learning/tutorial.ts +++ b/app/src/domain/learning/tutorial.ts @@ -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"; } diff --git a/app/src/ui/app.ts b/app/src/ui/app.ts index 6b0dea7..a4cb543 100644 --- a/app/src/ui/app.ts +++ b/app/src/ui/app.ts @@ -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 { 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 { 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 diff --git a/app/tests/bear-off-input.test.ts b/app/tests/bear-off-input.test.ts new file mode 100644 index 0000000..c2bca54 --- /dev/null +++ b/app/tests/bear-off-input.test.ts @@ -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 { + 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(); + 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, []); +}); diff --git a/app/tests/game-analysis.test.ts b/app/tests/game-analysis.test.ts index 28fd014..21090fe 100644 --- a/app/tests/game-analysis.test.ts +++ b/app/tests/game-analysis.test.ts @@ -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"); }); diff --git a/app/tests/steps/jouer-une-partie.steps.ts b/app/tests/steps/jouer-une-partie.steps.ts index cbe6ad5..62c3277 100644 --- a/app/tests/steps/jouer-une-partie.steps.ts +++ b/app/tests/steps/jouer-une-partie.steps.ts @@ -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é. diff --git a/app/tests/tutorial.test.ts b/app/tests/tutorial.test.ts index 86eea06..d4cc81f 100644 --- a/app/tests/tutorial.test.ts +++ b/app/tests/tutorial.test.ts @@ -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()) { diff --git a/docs/scenarios/jouer-une-partie.feature b/docs/scenarios/jouer-une-partie.feature index a19af74..5de42e5 100644 --- a/docs/scenarios/jouer-une-partie.feature +++ b/docs/scenarios/jouer-une-partie.feature @@ -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 diff --git a/tests/browser/accueil.mjs b/tests/browser/accueil.mjs index 9f51320..1e305dd 100644 --- a/tests/browser/accueil.mjs +++ b/tests/browser/accueil.mjs @@ -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})` : ""}` : "")); diff --git a/tests/browser/play.mjs b/tests/browser/play.mjs index 94f1cd8..2b47443 100644 --- a/tests/browser/play.mjs +++ b/tests/browser/play.mjs @@ -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) { diff --git a/tests/browser/recette.mjs b/tests/browser/recette.mjs index a3d7b6f..ddc2658 100644 --- a/tests/browser/recette.mjs +++ b/tests/browser/recette.mjs @@ -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; +} diff --git a/tests/test_engine.sh b/tests/test_engine.sh index 22af1ab..290daa3 100644 --- a/tests/test_engine.sh +++ b/tests/test_engine.sh @@ -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" diff --git a/web/app.css b/web/app.css index 368b25c..f1b8457 100644 --- a/web/app.css +++ b/web/app.css @@ -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; }