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>
231 lines
9.4 KiB
TypeScript
231 lines
9.4 KiB
TypeScript
// 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, []);
|
|
});
|