173 lines
6.1 KiB
TypeScript
173 lines
6.1 KiB
TypeScript
// 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<number, number>) =>
|
|
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/);
|
|
});
|