Domaine « partie » en journal d'événements, branché au moteur WebAssembly

app/src/domain/game/ : une partie libre contre l'ordinateur, enregistrée
comme un arbre d'événements immuables et versionnés (GameStarted,
DiceRolled, MovePlayed, CubeOffered, CubeTaken, CubeDropped, GameEnded) ;
l'état se recalcule en les rejouant, annuler repart du parent, une action
après un retour ouvre une branche. Dés injectés. Le joueur saisit son coup
demi-coup par demi-coup ; l'ordinateur joue et décide du videau à 2 plis.
Toutes les règles viennent du moteur, derrière l'interface Engine que le
domaine définit.

app/src/engine/wasm-engine.ts : l'adaptateur Engine sur les commandes
d'engine-cli du moteur WebAssembly.

Tests : unitaires avec un faux moteur, et deux parties complètes jusqu'à
GameEnded sur le vrai moteur, avec des dés imposés.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Pierre MARTIN
2026-11-02 20:42:08 +01:00
co-authored by Claude Opus 5.5
parent 54883f4ca3
commit 602d0eafed
14 changed files with 1314 additions and 0 deletions
+68
View File
@@ -0,0 +1,68 @@
// Une partie complète sur le vrai moteur WebAssembly, avec des dés imposés :
// l'ordinateur contre une stratégie simple du joueur (toujours le premier
// coup légal, saisi demi-coup par demi-coup, et toujours prendre).
//
// 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 { Game } from "../src/domain/game/game.js";
import type { DiceSource } from "../src/domain/game/roll.js";
import { replay } from "../src/domain/game/state.js";
import { loadNodeEngine } from "./support/node-engine.js";
/** Des dés pseudo-aléatoires reproductibles (générateur congruentiel). */
function seededDice(seed: number): DiceSource {
let state = seed;
return () => {
state = (state * 1103515245 + 12345) % 2147483648;
return 1 + (Math.floor(state / 65536) % 6);
};
}
/** Joue jusqu'à la fin ; renvoie le nombre d'actions. */
async function playToTheEnd(game: Game, engine: Awaited<ReturnType<typeof loadNodeEngine>>) {
let actions = 0;
while (game.state.phase !== "over") {
assert.ok(++actions < 2000, "la partie ne finit pas");
const state = game.state;
if (state.turn === "computer" && state.phase === "doubled") {
game.take();
} else if (state.turn === "computer") {
await game.playComputerTurn();
} else if (state.phase === "rolling") {
await game.roll();
} else {
assert.ok(state.roll !== null);
const [first] = await engine.legalMoves(state.position, state.roll);
assert.ok(first !== undefined, "le joueur devrait avoir un coup");
for (const halfMove of first.halfMoves) {
assert.equal(await game.playHalfMove(halfMove), true);
}
}
}
return actions;
}
for (const [seed, ply] of [
[1, 0],
[7, 2],
] as const) {
test(`partie complète, dés ${seed}, ordinateur à ${ply} pli(s)`, async () => {
const engine = await loadNodeEngine();
const game = Game.start(engine, { dice: seededDice(seed), ply });
await playToTheEnd(game, engine);
const events = game.events;
const last = events.at(-1);
assert.equal(last?.type, "GameEnded");
const result = game.state.result;
assert.ok(result !== null);
assert.ok(result.points >= 1);
assert.ok(events.some((e) => e.type === "MovePlayed" && e.side === "player"));
assert.ok(events.some((e) => e.type === "MovePlayed" && e.side === "computer"));
// Rejouer le journal redonne le même état.
assert.deepEqual(replay(events), game.state);
});
}
+220
View File
@@ -0,0 +1,220 @@
// Tests unitaires du domaine « partie », avec un faux moteur (ADR 0007,
// point 7) : journal, rejeu, branches, déroulement des tours et du videau.
//
// 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 {
Board,
CubeDecision,
Engine,
HalfMovesCheck,
Move,
} from "../src/domain/game/engine.js";
import { appendEvent, currentLine, EMPTY_JOURNAL, goBackTo } from "../src/domain/game/events.js";
import { Game } from "../src/domain/game/game.js";
import { formatHalfMoves, parseHalfMoves, START_POSITION } from "../src/domain/game/position.js";
import { fixedDice } from "../src/domain/game/roll.js";
import { replay } from "../src/domain/game/state.js";
/** Un faux moteur : chaque réponse est réglable, et les positions sont des
* textes sans signification. */
function fakeEngine(overrides: Partial<Engine> = {}): Engine {
const move: Move = {
notation: "8/5 6/5",
halfMoves: parseHalfMoves("8/5 6/5"),
position: "AFTER",
};
const notOver: Board = {
onRoll: { points: [], bar: 0, off: 0, pipCount: 0 },
opponent: { points: [], bar: 0, off: 0, pipCount: 0 },
swappedPosition: "SWAPPED",
result: null,
};
const noDouble: CubeDecision = { available: true, double: false, take: true };
return {
legalMoves: async () => [move],
bestMove: async () => move,
cubeDecision: async () => noDouble,
checkHalfMoves: async (_position, _roll, halfMoves): Promise<HalfMovesCheck> => {
const text = formatHalfMoves(halfMoves);
if (text === "8/5") {
return { valid: true, complete: false, position: "HALF" };
}
if (text === "8/5 6/5") {
return { valid: true, complete: true, position: "AFTER" };
}
return { valid: false, complete: false, position: null };
},
board: async () => notOver,
...overrides,
};
}
test("le jet d'ouverture se relance en cas d'égalité, puis le plus fort dé commence", () => {
const game = Game.start(fakeEngine(), { dice: fixedDice([4, 4, 2, 5]) });
const rolls = game.events.filter((e) => e.type === "DiceRolled");
assert.deepEqual(
rolls.map((e) => e.roll),
[
[4, 4],
[2, 5],
],
);
assert.equal(game.state.turn, "computer");
assert.equal(game.state.phase, "moving");
assert.deepEqual(game.state.roll, [2, 5]);
assert.equal(game.state.position, START_POSITION);
});
test("un coup se saisit demi-coup par demi-coup ; un demi-coup refusé ne change rien", async () => {
const game = Game.start(fakeEngine(), { dice: fixedDice([3, 1]) });
assert.equal(await game.playHalfMove({ from: 13, to: 12 }), false);
assert.equal(game.events.length, 2);
assert.equal(await game.playHalfMove({ from: 8, to: 5 }), true);
assert.equal(game.displayedPosition, "HALF");
assert.deepEqual(game.pendingHalfMoves, [{ from: 8, to: 5 }]);
assert.equal(await game.playHalfMove({ from: 6, to: 5 }), true);
const last = game.events.at(-1);
assert.equal(last?.type, "MovePlayed");
assert.deepEqual(game.pendingHalfMoves, []);
assert.equal(game.state.turn, "computer");
assert.equal(game.state.phase, "rolling");
assert.equal(game.state.position, "SWAPPED");
});
test("sans coup possible, le tour passe", async () => {
const engine = fakeEngine({ legalMoves: async () => [] });
const game = Game.start(engine, {
dice: fixedDice([6, 6]),
setup: { position: "BLOCKED", firstTurn: "player" },
});
await game.roll();
const last = game.events.at(-1);
assert.ok(last?.type === "MovePlayed");
assert.deepEqual(last.halfMoves, []);
assert.equal(game.state.turn, "computer");
});
test("l'ordinateur double si le moteur le recommande ; le joueur prend", async () => {
const engine = fakeEngine({
cubeDecision: async () => ({ available: true, double: true, take: true }),
});
const game = Game.start(engine, { setup: { position: "X", firstTurn: "computer" } });
await game.playComputerTurn();
assert.equal(game.state.phase, "doubled");
game.take();
assert.deepEqual(game.state.cube, { value: 2, owner: "player" });
assert.equal(game.state.phase, "rolling");
assert.equal(game.state.turn, "computer");
});
test("le joueur passe : il perd la partie à la valeur d'avant le double", async () => {
const engine = fakeEngine({
cubeDecision: async () => ({ available: true, double: true, take: true }),
});
const game = Game.start(engine, {
setup: { position: "X", firstTurn: "computer", cube: { value: 2, owner: "computer" } },
});
await game.playComputerTurn();
game.drop();
assert.equal(game.state.phase, "over");
assert.deepEqual(game.state.result, { winner: "computer", kind: "single", points: 2 });
});
test("le joueur double : l'ordinateur passe si le moteur le dit", async () => {
const engine = fakeEngine({
cubeDecision: async () => ({ available: true, double: true, take: false }),
});
const game = Game.start(engine, { setup: { position: "X", firstTurn: "player" } });
await game.double();
assert.deepEqual(
game.events.slice(-3).map((e) => e.type),
["CubeOffered", "CubeDropped", "GameEnded"],
);
assert.deepEqual(game.state.result, { winner: "player", kind: "single", points: 1 });
});
test("le joueur ne peut pas doubler si le videau est chez l'ordinateur", async () => {
const engine = fakeEngine({
cubeDecision: async () => ({ available: false, double: false, take: true }),
});
const game = Game.start(engine, {
setup: { position: "X", firstTurn: "player", cube: { value: 2, owner: "computer" } },
});
await assert.rejects(game.double(), /videau/);
assert.equal(game.events.length, 1);
});
test("la fin de partie vient du moteur : gagnant, sorte et points", async () => {
const engine = fakeEngine({
board: async () => ({
onRoll: { points: [], bar: 0, off: 15, pipCount: 0 },
opponent: { points: [], bar: 0, off: 0, pipCount: 100 },
swappedPosition: "SWAPPED",
result: { winner: "onRoll", kind: "gammon", points: 2 },
}),
});
const game = Game.start(engine, {
setup: { position: "X", firstTurn: "computer" },
dice: fixedDice([2, 1]),
});
await game.playComputerTurn();
assert.deepEqual(game.state.result, { winner: "computer", kind: "gammon", points: 2 });
assert.throws(() => game.take(), /action impossible/);
});
test("annuler repart du parent ; l'action suivante ouvre une branche", async () => {
const game = Game.start(fakeEngine(), { dice: fixedDice([3, 1, 6, 5, 4, 2]) });
await game.playHalfMove({ from: 8, to: 5 });
await game.playHalfMove({ from: 6, to: 5 });
const played = game.events.at(-1);
assert.equal(played?.type, "MovePlayed");
game.undo();
assert.equal(game.state.phase, "moving");
assert.equal(game.state.turn, "player");
await game.playHalfMove({ from: 8, to: 5 });
await game.playHalfMove({ from: 6, to: 5 });
// Deux coups accrochés au même jet : l'ancien reste dans le journal.
const moves = game.journal.events.filter((e) => e.type === "MovePlayed");
assert.equal(moves.length, 2);
assert.equal(moves[0]?.parent, moves[1]?.parent);
assert.notEqual(game.events.at(-1)?.id, played?.id);
game.goBackTo(played?.id ?? "");
assert.equal(game.events.at(-1)?.id, played?.id);
});
test("les événements sont immuables et versionnés ; le rejeu est déterministe", () => {
let journal = appendEvent(EMPTY_JOURNAL, {
type: "GameStarted",
position: START_POSITION,
cube: { value: 1, owner: null },
firstTurn: null,
});
journal = appendEvent(journal, { type: "DiceRolled", side: null, roll: [5, 2] });
const [started, rolled] = currentLine(journal);
assert.equal(started?.version, 1);
assert.equal(started?.parent, null);
assert.equal(rolled?.parent, started?.id);
assert.throws(() => {
(rolled as { parent: string | null }).parent = null;
}, TypeError);
assert.deepEqual(replay(currentLine(journal)), replay(currentLine(journal)));
assert.equal(replay(currentLine(goBackTo(journal, "1"))).phase, "opening");
});
test("lecture et écriture des demi-coups", () => {
assert.deepEqual(parseHalfMoves("bar/22 6/off"), [
{ from: 25, to: 22 },
{ from: 6, to: 0 },
]);
assert.equal(formatHalfMoves(parseHalfMoves(" 8/5 6/5 ")), "8/5 6/5");
assert.throws(() => parseHalfMoves("8-5"), /mal formé/);
assert.throws(() => parseHalfMoves("30/5"), /case invalide/);
});
+37
View File
@@ -0,0 +1,37 @@
// Le vrai moteur WebAssembly sous Node, pour les tests et les scénarios.
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
//
// Charge build/wasm/ (make engine-wasm) avec web/engine-wasm.mjs, comme la
// page de test du moteur, et le branche sur l'adaptateur du domaine. Le moteur
// n'est chargé qu'une fois par processus.
import { fileURLToPath } from "node:url";
import type { Engine } from "../../src/domain/game/engine.js";
import { createWasmEngine } from "../../src/engine/wasm-engine.js";
// Ce fichier est compilé dans build/web/tests/support/ : la racine du dépôt
// est quatre dossiers plus haut.
const root = new URL("../../../../", import.meta.url);
interface WasmEngine {
run(args: string[]): { result: unknown; ms: number };
}
let loading: Promise<Engine> | undefined;
export function loadNodeEngine(): Promise<Engine> {
loading ??= load();
return loading;
}
async function load(): Promise<Engine> {
const module = await import(new URL("build/wasm/engine.mjs", root).href);
const wrapper = await import(new URL("web/engine-wasm.mjs", root).href);
const wasm: WasmEngine = await wrapper.loadEngine(
module.default,
fileURLToPath(new URL("build/wasm/", root)),
);
return createWasmEngine(async (args) => wasm.run(args).result);
}