// 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, EngineCube, HalfMovesCheck, Level, Move, } from "../src/domain/game/engine.js"; import { appendEvent, currentLine, EMPTY_JOURNAL, EVENT_VERSION, type GameJournal, goBackTo, migrateJournal, } 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 { DEFAULT_LEVEL, 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 { 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 => { 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, levelDouble: async () => false, levelTake: async () => true, matchAfterGame: async (match, winner, points) => { const score = { ...match.score }; score[winner] += points; return { score, over: score[winner] >= match.length, crawford: score[winner] === match.length - 1, }; }, analyseMove: async () => null, rankMoves: async () => [], analyseCube: async () => ({ analysed: false, equities: null, equityLoss: 0, skill: "none", }), luck: async () => ({ value: 0, rating: "none" }), encodeMatch: async () => "MATCH", decodeMatch: async () => ({ matchLength: 0, score: [0, 0], cubeValue: 1, cubeOwner: -1, playerOnRoll: 0, crawford: false, jacoby: true, }), ...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 dit à son niveau ; le joueur prend", async () => { const engine = fakeEngine({ levelDouble: async () => 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({ levelDouble: async () => true }); const game = Game.start(engine, { setup: { position: "X", firstTurn: "computer", cube: { value: 2, owner: "computer" } }, }); await game.playComputerTurn(); await 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 à son niveau", async () => { const engine = fakeEngine({ levelTake: async () => 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(), /pas doubler/); 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, 2); 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); }); // ------------------------------------------------------------- Match (1c) test("une partie de match garde son niveau et son match, et les donne au moteur vus du camp au trait", async () => { const cubes: EngineCube[] = []; const levels: Level[] = []; const engine = fakeEngine({ levelDouble: async (_position, cube, level) => { cubes.push(cube); levels.push(level); return false; }, }); const match = { length: 5, score: { player: 1, computer: 3 }, crawford: false }; const game = Game.start(engine, { dice: fixedDice([2, 5, 3, 1, 4, 2]), level: "intermediate", match, }); const start = game.events[0]; assert.ok(start?.type === "GameStarted"); assert.equal(start.level, "intermediate"); assert.deepEqual(start.match, match); assert.equal(game.state.level, "intermediate"); // L'ordinateur commence (2-5) et joue, puis le joueur ; ensuite // l'ordinateur pense à doubler, et le match lui est donné vu de lui. await game.playComputerTurn(); await game.roll(); await game.playHalfMove({ from: 8, to: 5 }); await game.playHalfMove({ from: 6, to: 5 }); await game.confirmMove(); await game.playComputerTurn(); assert.deepEqual(levels, ["intermediate"]); assert.deepEqual(cubes[0]?.match, { length: 5, score: { onRoll: 3, opponent: 1 }, crawford: false, }); }); test("une partie de match finie : le moteur dit le score, la fin du match et la partie Crawford suivante", async () => { const winning: Board = { 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 engine = fakeEngine({ board: async () => winning }); const game = Game.start(engine, { dice: fixedDice([5, 2]), level: "advanced", match: { length: 3, score: { player: 0, computer: 0 }, crawford: false }, }); assert.equal(game.nextGame, null); await game.playHalfMove({ from: 8, to: 5 }); await game.playHalfMove({ from: 6, to: 5 }); await game.confirmMove(); const end = game.events.at(-1); assert.ok(end?.type === "GameEnded"); assert.deepEqual(end.match, { score: { player: 2, computer: 0 }, over: false, crawford: true }); const next = game.nextGame; assert.deepEqual(next, { level: "advanced", match: { length: 3, score: { player: 2, computer: 0 }, crawford: true }, }); // La partie Crawford, gagnée : le match est fini, plus de partie suivante. const crawford = Game.start(engine, { dice: fixedDice([5, 2]), ...(game.nextGame ?? {}) }); await crawford.playHalfMove({ from: 8, to: 5 }); await crawford.playHalfMove({ from: 6, to: 5 }); await crawford.confirmMove(); assert.deepEqual(crawford.state.matchAfter, { score: { player: 4, computer: 0 }, over: true, crawford: false, }); assert.equal(crawford.nextGame, null); // « Rejouer » après le match : un nouveau match de même longueur, à 0 à // 0, au même niveau. assert.deepEqual(crawford.rematch, { level: "advanced", match: { length: 3, score: { player: 0, computer: 0 }, crawford: false }, }); }); test("passer un double dans un match : le moteur compte le match", async () => { const engine = fakeEngine({ levelDouble: async () => true }); const game = Game.start(engine, { dice: fixedDice([2, 5, 3, 1]), match: { length: 3, score: { player: 0, computer: 1 }, crawford: false }, }); // L'ordinateur joue son jet d'ouverture, le joueur son coup, puis // l'ordinateur double. await game.playComputerTurn(); await game.roll(); await game.playHalfMove({ from: 8, to: 5 }); await game.playHalfMove({ from: 6, to: 5 }); await game.confirmMove(); await game.playComputerTurn(); assert.equal(game.state.phase, "doubled"); await game.drop(); assert.deepEqual(game.state.matchAfter, { score: { player: 0, computer: 2 }, over: false, crawford: true, }); }); test("une partie libre n'a ni match ni partie suivante", () => { const game = Game.start(fakeEngine(), { dice: fixedDice([3, 1]) }); assert.equal(game.state.match, null); assert.equal(game.state.level, DEFAULT_LEVEL); assert.equal(game.nextGame, null); }); test("« Rejouer » une partie libre : une partie libre au même niveau, depuis le départ", () => { const game = Game.start(fakeEngine(), { dice: fixedDice([3, 1]), level: "0ply" }); assert.deepEqual(game.rematch, { level: "0ply" }); }); test("migration : un journal de la version 1 se reprend en version 2, au niveau 2 plis, en partie libre", async () => { // Un journal tel que l'étape 1b l'enregistrait : version 1, GameStarted // sans level ni match, un coup joué, une branche. const v1 = { events: [ { id: "1", parent: null, version: 1, type: "GameStarted", position: START_POSITION, cube: { value: 1, owner: null }, firstTurn: null, }, { id: "2", parent: "1", version: 1, type: "DiceRolled", side: null, roll: [3, 1] }, { id: "3", parent: "2", version: 1, type: "MovePlayed", side: "player", halfMoves: [ { from: 8, to: 5 }, { from: 6, to: 5 }, ], position: "SWAPPED", }, { id: "4", parent: "1", version: 1, type: "DiceRolled", side: null, roll: [5, 2] }, ], head: "3", } as unknown as GameJournal; const before = replay(currentLine(v1)); const migrated = migrateJournal(v1); assert.ok(migrated.events.every((event) => event.version === EVENT_VERSION)); assert.equal(migrated.head, "3"); const start = migrated.events[0]; assert.ok(start?.type === "GameStarted"); assert.equal(start.level, "2ply"); assert.equal(start.match, undefined); // Rien d'autre ne change : mêmes événements, même arbre, même état. const strip = (journal: GameJournal) => journal.events.map(({ version: _, ...event }) => { const { level: _level, ...rest } = event as typeof event & { level?: unknown }; return rest; }); assert.deepEqual(strip(migrated), strip(v1)); assert.deepEqual(replay(currentLine(migrated)), { ...before, level: "2ply" }); // Une version courante passe telle quelle. assert.equal(migrateJournal(migrated), migrated); const resumed = Game.resume(fakeEngine(), JSON.parse(JSON.stringify(v1))); assert.equal(resumed.state.level, "2ply"); assert.equal(resumed.state.match, null); assert.ok(resumed.journal.events.every((event) => event.version === EVENT_VERSION)); // La partie continue en version 2. await resumed.playComputerTurn(); assert.ok(resumed.journal.events.every((event) => event.version === EVENT_VERSION)); });