Parties enregistrées : IndexedDB, branches, export SGF relu par gnubg

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Pierre MARTIN
2026-11-03 15:29:01 +01:00
co-authored by Claude Opus 5.5
parent 08cb89735f
commit 274fbf6252
19 changed files with 1896 additions and 90 deletions
+210
View File
@@ -0,0 +1,210 @@
// Parties d'exemple exportées en SGF, pour les faire relire par le GNU
// Backgammon de référence (tests/test_sgf_reference.sh).
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
//
// Usage : node build/web/tests/tools/sgf-samples.js <dossier>
//
// Joue sur le vrai moteur WebAssembly, avec des dés reproductibles, des
// parties variées : complètes, avec branches, avec videau, depuis une
// position particulière. Pour chaque branche de chaque partie, écrit
// <nom>.sgf (la partie exportée, cette branche en premier : c'est celle que
// gnubg lit) et <nom>.json : les positions successives du journal sur cette
// branche, celles que gnubg doit retrouver (tests/sgf/check.py).
import { mkdirSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import type { Engine } from "../../src/domain/game/engine.js";
import { Game } from "../../src/domain/game/game.js";
import type { DiceSource } from "../../src/domain/game/roll.js";
import { exportSgf } from "../../src/domain/game/sgf.js";
import { loadNodeEngine } from "../support/node-engine.js";
/** Des dés pseudo-aléatoires reproductibles (générateur congruentiel). */
function seededDice(seed: number): DiceSource {
let state = seed;
return () => {
state = (state * 1103515245 + 12345) % 2147483648;
return 1 + (Math.floor(state / 65536) % 6);
};
}
/**
* Une action de la partie, quel que soit le camp qui a la main. Le joueur
* joue le choice-ième coup légal (le dernier s'il y en a moins), prend les
* doubles si take, et double quand il le peut si double.
*/
async function step(
game: Game,
engine: Engine,
strategy: { choice?: number; take?: boolean; double?: boolean } = {},
): Promise<void> {
const state = game.state;
if (state.turn === "computer" && state.phase === "doubled") {
if (strategy.take ?? true) {
game.take();
} else {
game.drop();
}
} else if (state.turn === "computer") {
await game.playComputerTurn();
} else if (state.phase === "rolling") {
if (strategy.double === true && (await game.canDouble())) {
await game.double();
} else {
await game.roll();
}
} else if (state.phase === "moving" && state.roll !== null) {
const moves = await engine.legalMoves(state.position, state.roll);
const move = moves[Math.min(strategy.choice ?? 0, moves.length - 1)];
if (move === undefined) {
await game.skipTurn();
return;
}
for (const halfMove of move.halfMoves) {
if (!(await game.playHalfMove(halfMove))) {
throw new Error("coup légal refusé");
}
}
await game.confirmMove();
} else {
throw new Error(`phase inattendue : ${state.phase}`);
}
}
async function playMoves(game: Game, engine: Engine, count: number, choice = 0): Promise<void> {
let played = 0;
while (played < count && game.state.phase !== "over") {
const before = game.events.length;
await step(game, engine, { choice });
played += game.events.slice(before).filter((event) => event.type === "MovePlayed").length;
}
}
async function playToTheEnd(game: Game, engine: Engine, choice = 0): Promise<void> {
for (let i = 0; game.state.phase !== "over"; i++) {
if (i > 2000) {
throw new Error("la partie ne finit pas");
}
await step(game, engine, { choice });
}
}
/** Le dernier événement « coup joué » de la ligne courante. */
function lastMoveId(game: Game, skip = 0): string {
const moves = game.events.filter((event) => event.type === "MovePlayed");
const move = moves.at(-1 - skip);
if (move === undefined) {
throw new Error("aucun coup joué");
}
return move.id;
}
async function main(): Promise<void> {
const out = process.argv[2];
if (out === undefined) {
throw new Error("usage : sgf-samples.js <dossier>");
}
mkdirSync(out, { recursive: true });
const engine = await loadNodeEngine();
const games: [string, Game][] = [];
// Une partie complète, puis une autre où le joueur joue autrement.
for (const [seed, choice] of [
[3, 0],
[11, 2],
] as const) {
const game = Game.start(engine, { dice: seededDice(seed), ply: 0 });
await playToTheEnd(game, engine, choice);
games.push([`complete-${seed}`, game]);
}
// Des branches : rejouée d'un coup de l'ordinateur (nouveaux dés), d'un
// jet du joueur (même jet, autre coup), et une branche d'une branche.
{
const game = Game.start(engine, { dice: seededDice(5), ply: 0 });
await playMoves(game, engine, 14);
const original = game.events.at(-1)?.id as string;
game.goBackTo(lastMoveId(game, 6));
await playMoves(game, engine, 8);
const rolled = game.events.findLast((e) => e.type === "DiceRolled" && e.side === "player");
if (rolled === undefined) {
throw new Error("aucun jet du joueur");
}
game.goBackTo(rolled.id);
await playMoves(game, engine, 5, 1);
game.switchBranch(original);
await playToTheEnd(game, engine);
games.push(["branches", game]);
}
// Le joueur double d'entrée ; l'ordinateur prend ou passe selon le moteur.
{
const game = Game.start(engine, { dice: seededDice(9), ply: 0 });
for (let i = 0; i < 400 && game.state.phase !== "over"; i++) {
await step(game, engine, { double: true });
}
games.push(["player-doubles", game]);
}
// Depuis une position particulière : l'ordinateur, au trait, double
// (position de référence « cube-double-take ») ; le joueur prend dans
// une branche, passe dans une autre.
{
const game = Game.start(engine, {
dice: seededDice(13),
ply: 0,
setup: { position: "6O5uAAC4uzsAAA", firstTurn: "computer" },
});
await step(game, engine);
const offered = game.events.at(-1)?.id as string;
await playMoves(game, engine, 6);
game.goBackTo(offered);
await step(game, engine, { take: false });
games.push(["setup-cube", game]);
}
// Depuis une position particulière, videau déjà doublé chez le joueur,
// qui a la main ; partie laissée en cours, dés lancés et pas joués.
{
const game = Game.start(engine, {
dice: seededDice(17),
ply: 0,
setup: {
position: "t+0OAADg/wcAQA",
firstTurn: "player",
cube: { value: 2, owner: "player" },
},
});
await playMoves(game, engine, 4);
await game.roll();
games.push(["setup-unfinished", game]);
}
let files = 0;
for (const [name, game] of games) {
const original = game.journal;
for (const [i, branch] of game.branches.entries()) {
game.switchBranch(branch.head);
const sgf = await exportSgf(engine, game.journal, {
playerName: "Vous",
computerName: "Ordinateur",
date: "2026-11-03",
});
// Après chaque coup, la position vue de celui qui a alors la main,
// et le résultat.
const expected = {
after: game.events.flatMap((e) => (e.type === "MovePlayed" ? [e.position] : [])),
result: game.state.result,
};
writeFileSync(join(out, `${name}-${i}.sgf`), sgf);
writeFileSync(join(out, `${name}-${i}.json`), `${JSON.stringify(expected)}\n`);
files++;
}
game.goBackTo(original.head as string);
}
console.log(`${files} parties exportées dans ${out}`);
}
await main();