// 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 { 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, ...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/); }); 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 }); 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); });