// 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("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, []); });