406 lines
16 KiB
TypeScript
406 lines
16 KiB
TypeScript
// 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,
|
|
levelMove: async () => move,
|
|
levelCubeDecision: async () => noDouble,
|
|
analyseMove: async () => null,
|
|
analyseCube: async () => ({
|
|
analysed: false,
|
|
equities: null,
|
|
equityLoss: 0,
|
|
skill: "none",
|
|
}),
|
|
luck: async () => ({ value: 0, rating: "none" }),
|
|
...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);
|
|
|
|
// Coup complet : il attend la validation, hors du journal.
|
|
assert.equal(game.moveComplete, true);
|
|
assert.equal(game.displayedPosition, "AFTER");
|
|
assert.equal(game.events.length, 2);
|
|
assert.equal(game.state.turn, "player");
|
|
assert.deepEqual(await game.nextHalfMoves(), []);
|
|
assert.equal(await game.playHalfMove({ from: 8, to: 7 }), false);
|
|
|
|
await game.confirmMove();
|
|
const last = game.events.at(-1);
|
|
assert.equal(last?.type, "MovePlayed");
|
|
assert.deepEqual(game.pendingHalfMoves, []);
|
|
assert.equal(game.moveComplete, false);
|
|
assert.equal(game.state.turn, "computer");
|
|
assert.equal(game.state.phase, "rolling");
|
|
assert.equal(game.state.position, "SWAPPED");
|
|
});
|
|
|
|
test("un coup incomplet ne se valide pas", async () => {
|
|
const game = Game.start(fakeEngine(), { dice: fixedDice([3, 1]) });
|
|
await assert.rejects(game.confirmMove(), /pas complet/);
|
|
await game.playHalfMove({ from: 8, to: 5 });
|
|
await assert.rejects(game.confirmMove(), /pas complet/);
|
|
assert.equal(game.events.length, 2);
|
|
});
|
|
|
|
test("Annuler défait les demi-coups un par un, sans toucher au journal", async () => {
|
|
const game = Game.start(fakeEngine(), { dice: fixedDice([3, 1]) });
|
|
await game.playHalfMove({ from: 8, to: 5 });
|
|
await game.playHalfMove({ from: 6, to: 5 });
|
|
const journal = game.journal;
|
|
|
|
game.undoLastHalfMove();
|
|
assert.deepEqual(game.pendingHalfMoves, [{ from: 8, to: 5 }]);
|
|
assert.equal(game.moveComplete, false);
|
|
assert.equal(game.displayedPosition, "HALF");
|
|
|
|
game.undoLastHalfMove();
|
|
assert.deepEqual(game.pendingHalfMoves, []);
|
|
assert.equal(game.displayedPosition, START_POSITION);
|
|
game.undoLastHalfMove(); // rien à défaire : sans effet
|
|
assert.equal(game.journal, journal);
|
|
|
|
// Le coup se ressaisit ensuite normalement.
|
|
assert.equal(await game.playHalfMove({ from: 8, to: 5 }), true);
|
|
assert.equal(await game.playHalfMove({ from: 6, to: 5 }), true);
|
|
assert.equal(game.moveComplete, true);
|
|
});
|
|
|
|
test("les dés utilisés par les demi-coups saisis sont marqués", async () => {
|
|
const game = Game.start(fakeEngine(), { dice: fixedDice([3, 1]) });
|
|
assert.deepEqual(game.diceToPlay, [
|
|
{ value: 3, used: false },
|
|
{ value: 1, used: false },
|
|
]);
|
|
await game.playHalfMove({ from: 8, to: 5 });
|
|
assert.deepEqual(game.diceToPlay, [
|
|
{ value: 3, used: true },
|
|
{ value: 1, used: false },
|
|
]);
|
|
await game.playHalfMove({ from: 6, to: 5 });
|
|
assert.deepEqual(
|
|
game.diceToPlay.map((d) => d.used),
|
|
[true, true],
|
|
);
|
|
await game.confirmMove();
|
|
assert.deepEqual(game.diceToPlay, []);
|
|
});
|
|
|
|
test("un double donne quatre dés, marqués un par un ; une sortie prend le plus petit dé suffisant", async () => {
|
|
const engine = fakeEngine({
|
|
checkHalfMoves: async () => ({ valid: true, complete: false, position: "P" }),
|
|
});
|
|
const double = Game.start(engine, {
|
|
dice: fixedDice([2, 2]),
|
|
setup: { position: "X", firstTurn: "player" },
|
|
});
|
|
await double.roll();
|
|
assert.equal(double.diceToPlay.length, 4);
|
|
await double.playHalfMove({ from: 13, to: 11 });
|
|
await double.playHalfMove({ from: 11, to: 9 });
|
|
assert.deepEqual(
|
|
double.diceToPlay.map((d) => d.used),
|
|
[true, true, false, false],
|
|
);
|
|
|
|
const bearOff = Game.start(engine, {
|
|
dice: fixedDice([6, 4]),
|
|
setup: { position: "X", firstTurn: "player" },
|
|
});
|
|
await bearOff.roll();
|
|
// 3/off : le 4 suffit, le 6 reste.
|
|
await bearOff.playHalfMove({ from: 3, to: 0 });
|
|
assert.deepEqual(bearOff.diceToPlay, [
|
|
{ value: 6, used: false },
|
|
{ value: 4, used: true },
|
|
]);
|
|
// 6/2 demande le 4 : la sortie a donc pris le 6.
|
|
await bearOff.playHalfMove({ from: 6, to: 2 });
|
|
assert.deepEqual(
|
|
bearOff.diceToPlay.map((d) => d.used),
|
|
[true, true],
|
|
);
|
|
});
|
|
|
|
test("sans coup possible, le tour passe quand le joueur le laisse passer", async () => {
|
|
const engine = fakeEngine({ legalMoves: async () => [] });
|
|
const game = Game.start(engine, {
|
|
dice: fixedDice([6, 6]),
|
|
setup: { position: "BLOCKED", firstTurn: "player" },
|
|
});
|
|
await game.roll();
|
|
// Le tour ne passe pas tout seul : le jet reste à « jouer ».
|
|
assert.equal(game.state.phase, "moving");
|
|
assert.equal(game.state.turn, "player");
|
|
assert.equal(await game.cannotMove(), true);
|
|
await game.skipTurn();
|
|
const last = game.events.at(-1);
|
|
assert.ok(last?.type === "MovePlayed");
|
|
assert.deepEqual(last.halfMoves, []);
|
|
assert.equal(game.state.turn, "computer");
|
|
});
|
|
|
|
test("le joueur ne peut pas laisser passer son tour s'il a un coup", async () => {
|
|
const engine = fakeEngine({
|
|
legalMoves: async () => [
|
|
{ notation: "8/5", halfMoves: [{ from: 8, to: 5 }], position: "P" },
|
|
],
|
|
});
|
|
const game = Game.start(engine, {
|
|
dice: fixedDice([3, 1]),
|
|
setup: { position: "X", firstTurn: "player" },
|
|
});
|
|
await game.roll();
|
|
assert.equal(await game.cannotMove(), false);
|
|
await assert.rejects(game.skipTurn(), /coup possible/);
|
|
assert.equal(game.state.phase, "moving");
|
|
});
|
|
|
|
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 });
|
|
await game.confirmMove();
|
|
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 });
|
|
await game.confirmMove();
|
|
|
|
// 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/);
|
|
});
|
|
|
|
test("une partie se reprend depuis son journal ; un journal d'une autre version est refusé", async () => {
|
|
const game = Game.start(fakeEngine(), { dice: fixedDice([3, 1]) });
|
|
await game.playHalfMove({ from: 8, to: 5 });
|
|
await game.playHalfMove({ from: 6, to: 5 });
|
|
await game.confirmMove();
|
|
const saved = JSON.parse(JSON.stringify(game.journal));
|
|
|
|
const resumed = Game.resume(fakeEngine(), saved);
|
|
assert.deepEqual(resumed.state, game.state);
|
|
assert.deepEqual(resumed.events, game.events);
|
|
|
|
const other = { ...saved, events: saved.events.map((e: object) => ({ ...e, version: 99 })) };
|
|
assert.throws(() => Game.resume(fakeEngine(), other), /version/);
|
|
assert.throws(() => Game.resume(fakeEngine(), EMPTY_JOURNAL), /mal formé/);
|
|
assert.throws(() => Game.resume(fakeEngine(), { ...saved, head: "inconnu" }), /introuvable/);
|
|
});
|
|
|
|
test("le droit de doubler vient du moteur, seulement avant de lancer les dés", async () => {
|
|
let available = true;
|
|
const engine = fakeEngine({
|
|
cubeDecision: async () => ({ available, double: false, take: true }),
|
|
});
|
|
const game = Game.start(engine, {
|
|
dice: fixedDice([3, 1]),
|
|
setup: { position: "X", firstTurn: "player" },
|
|
});
|
|
assert.equal(await game.canDouble(), true);
|
|
available = false;
|
|
assert.equal(await game.canDouble(), false);
|
|
available = true;
|
|
await game.roll();
|
|
assert.equal(game.state.phase, "moving");
|
|
assert.equal(await game.canDouble(), false);
|
|
});
|
|
|
|
test("le plateau affiché est donné du point de vue de chaque camp", async () => {
|
|
const side = (pipCount: number) => ({ points: [], bar: 0, off: 0, pipCount });
|
|
const engine = fakeEngine({
|
|
board: async () => ({
|
|
onRoll: side(1),
|
|
opponent: side(2),
|
|
swappedPosition: "SWAPPED",
|
|
result: null,
|
|
}),
|
|
});
|
|
const playerTurn = Game.start(engine, { setup: { position: "X", firstTurn: "player" } });
|
|
const sides = await playerTurn.displayedBoard();
|
|
assert.equal(sides.player.pipCount, 1);
|
|
assert.equal(sides.computer.pipCount, 2);
|
|
const computerTurn = Game.start(engine, { setup: { position: "X", firstTurn: "computer" } });
|
|
const swapped = await computerTurn.displayedBoard();
|
|
assert.equal(swapped.player.pipCount, 2);
|
|
assert.equal(swapped.computer.pipCount, 1);
|
|
});
|