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:
co-authored by
Claude Opus 5.5
parent
54883f4ca3
commit
602d0eafed
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user