Merge remote-tracking branch 'origin/main' into prompts

This commit is contained in:
Pierre MARTIN
2026-11-11 21:54:32 +01:00
13 changed files with 499 additions and 19 deletions
+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()) {