// Tests du domaine : les branches d'une partie et son export SGF, sur des // journaux écrits à la main (sans moteur, sauf un faux pour une position de // départ particulière). La relecture des SGF par le vrai GNU Backgammon est // faite par tests/test_web.sh (tests/sgf/check.py). // // 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 { branches, switchToBranch } from "../src/domain/game/branches.js"; import type { Board, Engine } from "../src/domain/game/engine.js"; import { appendEvent, currentLine, EMPTY_JOURNAL, type GameJournal, goBackTo, type NewGameEvent, } from "../src/domain/game/events.js"; import { parseHalfMoves, START_POSITION } from "../src/domain/game/position.js"; import { exportSgf } from "../src/domain/game/sgf.js"; function add(journal: GameJournal, ...events: NewGameEvent[]): GameJournal { return events.reduce(appendEvent, journal); } const start: NewGameEvent = { type: "GameStarted", position: START_POSITION, cube: { value: 1, owner: null }, firstTurn: null, }; const move = (side: "player" | "computer", text: string, position = "P"): NewGameEvent => ({ type: "MovePlayed", side, halfMoves: parseHalfMoves(text), position, }); const roll = (side: "player" | "computer" | null, a: number, b: number): NewGameEvent => ({ type: "DiceRolled", side, roll: [a, b], }); /** * Une partie et ses branches : * 1 début, 2 ouverture 3-1 (joueur), 3 coup 8/5 6/5, 4 dés ordinateur 6-4, * 5 coup 24/18 13/9, 6 dés joueur 2-1 ; * rejouée après 3 : 7 dés ordinateur 5-5 ; puis rejouée après 2 (même * ouverture, autre coup) : 8 coup 24/23 13/10. */ function branchedGame(): GameJournal { let j = add( EMPTY_JOURNAL, start, roll(null, 3, 1), move("player", "8/5 6/5"), roll("computer", 6, 4), move("computer", "24/18 13/9"), roll("player", 2, 1), ); j = add(goBackTo(j, "3"), roll("computer", 5, 5)); j = add(goBackTo(j, "2"), move("player", "24/23 13/10")); return j; } test("les branches : feuilles de l'arbre, avec leur point de départ", () => { const j = branchedGame(); const all = branches(j); assert.deepEqual( all.map((b) => [b.head, b.forkedFrom, b.forkMoveCount, b.moveCount, b.containsHead]), [ ["6", null, 0, 2, false], ["7", "3", 1, 1, false], ["8", "2", 0, 1, true], ], ); assert.deepEqual(branches(EMPTY_JOURNAL), []); // Passer d'une branche à l'autre ne perd rien. const back = switchToBranch(j, "6"); assert.equal(back.events, j.events); assert.deepEqual( currentLine(back).map((e) => e.id), ["1", "2", "3", "4", "5", "6"], ); assert.throws(() => switchToBranch(j, "4"), /branche introuvable/); }); test("une branche rejouée puis continuée garde son ordre de création", () => { // La ligne d'origine continue après la création d'une branche : elle // reste la première. let j = add(EMPTY_JOURNAL, start, roll(null, 3, 1), move("player", "8/5 6/5")); j = add(goBackTo(j, "2"), move("player", "24/23 13/10")); j = add(goBackTo(j, "3"), roll("computer", 6, 4)); assert.deepEqual( branches(j).map((b) => [b.head, b.forkedFrom]), [ ["5", null], ["4", "2"], ], ); }); /** Un faux moteur qui ne sait que décoder un plateau. */ function boardEngine(board: Board): Engine { return { board: async () => board } as unknown as Engine; } const noEngine = boardEngine({} as Board); const names = { playerName: "Vous", computerName: "Ordi]nateur", date: "2026-11-03" }; test("SGF : les coups, les variantes, la branche courante d'abord", async () => { const sgf = await exportSgf(noEngine, branchedGame(), names); assert.equal( sgf, "(;FF[4]GM[6]CA[UTF-8]AP[Ustagammon]MI[length:0][game:0][ws:0][bs:0]" + "PW[Ordi\\]nateur]PB[Vous]DT[2026-11-03](" + // Branche courante : même ouverture, autre coup. "\n;B[31ablo])(" + // Ligne d'origine : 8/5 6/5 (B : 'x' - (case - 1)). "\n;B[31qtst](" + // Puis 24/18 13/9 de l'ordinateur (W : 'a' + (case - 1)), et // des dés lancés, pas joués. "\n;W[64xrmi]\n;PL[B]DI[21])(" + "\n;PL[W]DI[55])))\n", ); }); test("SGF : barre, sortie, coup impossible, videau et résultat", async () => { const j = add( EMPTY_JOURNAL, start, roll(null, 1, 6), move("computer", "bar/19 6/off"), { type: "CubeOffered", side: "player" }, { type: "CubeTaken", side: "computer" }, roll("player", 6, 6), move("player", ""), { type: "CubeOffered", side: "computer" }, { type: "CubeDropped", side: "player" }, { type: "GameEnded", winner: "computer", kind: "single", points: 2 }, ); const sgf = await exportSgf(noEngine, j, names); assert.ok(sgf.includes("RE[W+2]")); assert.ok( sgf.endsWith("\n;W[16ysfz]\n;B[double]\n;W[take]\n;B[66]\n;W[double]\n;B[drop])\n"), sgf, ); }); test("SGF : une position de départ particulière est posée", async () => { // Le joueur a la main : 2 pions sur sa case 1, 1 sur la barre ; // l'ordinateur : 3 pions sur sa case 24. Videau à 4 chez l'ordinateur. const points = (entries: Record) => Array.from({ length: 24 }, (_, i) => entries[i + 1] ?? 0); const engine = boardEngine({ onRoll: { points: points({ 1: 2 }), bar: 1, off: 12, pipCount: 27 }, opponent: { points: points({ 24: 3 }), bar: 0, off: 12, pipCount: 72 }, swappedPosition: "X", result: null, }); const j = add(EMPTY_JOURNAL, { type: "GameStarted", position: "ABC", cube: { value: 4, owner: "computer" }, firstTurn: "player", }); const sgf = await exportSgf(engine, j, names); assert.ok(sgf.endsWith("\n;PL[B]AE[a:y]AW[x][x][x]AB[x][x][y]\n;CV[4]\n;CP[w])\n"), sgf); await assert.rejects(exportSgf(noEngine, EMPTY_JOURNAL, names), /vide/); });