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
+2 -1
View File
@@ -30,7 +30,8 @@ CADDY_IMAGE := caddy:2.8.4-alpine
# Navigateurs sans écran pour make test-browser (Playwright, Apache-2.0).
PLAYWRIGHT_IMAGE := mcr.microsoft.com/playwright:v1.48.2-noble
export SHELLCHECK_IMAGE GHERKIN_IMAGE BUILD_C_IMAGE WASM_IMAGE CADDY_IMAGE WEB_IMAGE PLAYWRIGHT_IMAGE
export SHELLCHECK_IMAGE GHERKIN_IMAGE BUILD_C_IMAGE WASM_IMAGE CADDY_IMAGE WEB_IMAGE PLAYWRIGHT_IMAGE \
REFERENCE_IMAGE
# Lance une commande dans la chaîne C, sous l'utilisateur courant pour que
# build/ lui appartienne.
+91
View File
@@ -0,0 +1,91 @@
// Les branches d'une partie : les lignes de jeu de son journal.
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
//
// Le journal est un arbre (events.ts) : chaque événement pointe vers son
// parent, et « Rejouer d'ici » accroche un nouvel événement à un ancien. Une
// branche est le chemin du premier événement jusqu'à une feuille de l'arbre.
// Les identifiants des événements sont attribués dans l'ordre d'ajout : de
// deux enfants d'un même événement, le plus ancien a le plus petit indice
// dans le journal, et c'est lui qui continue la ligne d'origine.
import { currentLine, type GameEvent, type GameJournal, goBackTo } from "./events.js";
import { replay } from "./state.js";
/**
* Une branche de la partie : la suite d'événements du début jusqu'à head.
*
* Glossaire : docs/glossaire/branche.md
*/
export interface Branch {
/** Le dernier événement de la branche (une feuille de l'arbre). */
readonly head: string;
/** L'événement à partir duquel elle a été rejouée (« Rejouer d'ici ») ;
* null pour la ligne d'origine de la partie. */
readonly forkedFrom: string | null;
/** Les coups joués avant de rejouer (0 : depuis le début) ; 0 aussi pour
* la ligne d'origine. */
readonly forkMoveCount: number;
/** Les coups joués sur toute la branche, depuis le début. */
readonly moveCount: number;
/** La partie est finie au bout de cette branche. */
readonly over: boolean;
/** L'événement courant de la partie est sur cette branche. */
readonly containsHead: boolean;
}
/**
* Les branches de la partie, dans l'ordre de leur création (celui de leur
* premier événement propre) : la ligne d'origine d'abord. Liste vide pour un
* journal vide.
*/
export function branches(journal: GameJournal): Branch[] {
const order = new Map(journal.events.map((event, index) => [event.id, index]));
const children = new Map<string, GameEvent[]>();
for (const event of journal.events) {
if (event.parent !== null) {
children.set(event.parent, [...(children.get(event.parent) ?? []), event]);
}
}
const leaves = journal.events.filter((event) => !children.has(event.id));
return leaves
.map((leaf) => {
const line = currentLine({ events: journal.events, head: leaf.id });
// Le point de départ de la branche : le parent de l'événement le
// plus récent du chemin qui n'est pas le premier enfant de son
// parent (la ligne d'origine n'en a aucun).
let forkIndex = -1;
for (let i = line.length - 1; i > 0; i--) {
const event = line[i] as GameEvent;
const first = children.get(event.parent as string)?.[0];
if (first !== undefined && first.id !== event.id) {
forkIndex = i - 1;
break;
}
}
const moves = (events: readonly GameEvent[]) =>
events.filter((event) => event.type === "MovePlayed").length;
const fork = forkIndex >= 0 ? (line[forkIndex] as GameEvent) : null;
const start = line[forkIndex + 1] as GameEvent;
return {
created: order.get(start.id) ?? 0,
head: leaf.id,
forkedFrom: fork?.id ?? null,
forkMoveCount: fork === null ? 0 : moves(line.slice(0, forkIndex + 1)),
moveCount: moves(line),
over: replay(line).phase === "over",
containsHead: line.some((event) => event.id === journal.head),
};
})
.sort((a, b) => a.created - b.created)
.map(({ created: _, ...branch }) => branch);
}
/** Passe à une autre branche : la partie reprend au bout de celle-ci. */
export function switchToBranch(journal: GameJournal, head: string): GameJournal {
if (!branches(journal).some((branch) => branch.head === head)) {
throw new Error(`branche introuvable : ${head}`);
}
return goBackTo(journal, head);
}
+14
View File
@@ -8,6 +8,7 @@
// (state.ts). Les règles viennent toutes du moteur : coups légaux, coup de
// l'ordinateur, videau, fin de partie et points.
import { type Branch, branches, switchToBranch } from "./branches.js";
import { CENTERED_CUBE, type Cube } from "./cube.js";
import type { BoardSide, Engine, EngineCube, Ply } from "./engine.js";
import {
@@ -415,6 +416,19 @@ export class Game {
this.#journal = goBackTo(this.#journal, eventId);
}
/** Les branches de la partie (voir branches.ts), la ligne d'origine
* d'abord. */
get branches(): Branch[] {
return branches(this.#journal);
}
/** Passe à une autre branche, désignée par son dernier événement : la
* partie reprend au bout de celle-ci ; les autres restent intactes. */
switchBranch(head: string): void {
this.cancelHalfMoves();
this.#journal = switchToBranch(this.#journal, head);
}
/** Annule le dernier événement : la partie repart de son parent. */
undo(): void {
const last = this.events.at(-1);
+64
View File
@@ -0,0 +1,64 @@
// Une partie enregistrée, telle que la liste « Mes parties » la présente.
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
//
// Le journal reste la source (ADR 0007, point 3) : le résumé n'en est qu'une
// lecture, recalculée à chaque enregistrement, pour afficher la liste sans
// relire tous les journaux.
import { branches } from "./branches.js";
import type { Level } from "./engine.js";
import { currentLine, type GameJournal } from "./events.js";
import { type GameResult, replay } from "./state.js";
/**
* Le résumé d'une partie enregistrée.
*
* Glossaire : docs/glossaire/partie-enregistree.md
*/
export interface SavedGameSummary {
/** Identifiant de la partie, unique sur l'appareil. */
readonly id: string;
/** Début de la partie et dernière action, en millisecondes depuis 1970. */
readonly startedAt: number;
readonly updatedAt: number;
/** Niveau de l'ordinateur, l'adversaire. */
readonly level: Level;
/** En cours ou finie, sur la branche courante. */
readonly status: "playing" | "over";
/** Le résultat sur la branche courante (null si elle n'est pas finie). */
readonly result: GameResult | null;
/** Coups joués sur la branche courante. */
readonly moveCount: number;
/** Nombre de branches (1 : jamais rejouée). */
readonly branchCount: number;
}
/** Ce qu'il faut savoir d'une partie pour l'enregistrer, en plus de son
* journal. */
export interface SavedGameDetails {
readonly level: Level;
}
/** Le résumé d'un journal ; startedAt et updatedAt viennent de l'appelant
* (le domaine ne lit pas l'horloge). */
export function summarizeGame(
id: string,
journal: GameJournal,
details: SavedGameDetails,
times: { readonly startedAt: number; readonly updatedAt: number },
): SavedGameSummary {
const line = currentLine(journal);
const state = replay(line);
return {
id,
startedAt: times.startedAt,
updatedAt: times.updatedAt,
level: details.level,
status: state.phase === "over" ? "over" : "playing",
result: state.result,
moveCount: line.filter((event) => event.type === "MovePlayed").length,
branchCount: branches(journal).length,
};
}
+228
View File
@@ -0,0 +1,228 @@
// Export d'une partie au format SGF de GNU Backgammon.
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
//
// Le SGF (Smart Game Format, FF[4], GM[6] pour le backgammon) est le format
// de fichier de GNU Backgammon : une suite de nœuds, un par coup ou décision
// de videau, et des variantes entre parenthèses. Le journal étant un arbre,
// l'export en est un simple parcours (ADR 0007, point 3) : chaque branche
// devient une variante. GNU Backgammon ne lit que la première variante de
// chaque embranchement ; la branche courante de la partie vient donc en
// premier, pour que gnubg ouvre la partie telle que le joueur la voit.
//
// Conventions de gnubg (sgf.c, au commit figé par l'ADR 0005) :
// - le joueur est « B » (le joueur 1 de gnubg, l'humain), l'ordinateur « W » ;
// - un coup s'écrit [dés][départ arrivée]… ; une case c (1 à 24, vue de celui
// qui joue) s'écrit 'x' - (c - 1) pour B et 'a' + (c - 1) pour W ; la barre
// s'écrit 'y', la sortie 'z' ; un coup impossible n'a que ses dés ;
// - [double], [take], [drop] pour le videau ;
// - une position de départ autre que l'habituelle se pose par AE, AW, AB et
// PL, le videau par CV et CP.
// Aucune règle n'est jugée ici : les coups et positions viennent du journal,
// et une position de départ particulière est décodée par le moteur.
import type { Engine } from "./engine.js";
import { currentLine, type GameEvent, type GameJournal } from "./events.js";
import { BAR, type HalfMove, OFF, START_POSITION } from "./position.js";
import type { Side } from "./side.js";
import { replay } from "./state.js";
export interface SgfOptions {
/** Nom du joueur (PB). */
readonly playerName: string;
/** Nom de l'ordinateur (PW). */
readonly computerName: string;
/** Date de la partie, AAAA-MM-JJ (DT) ; absente si non fournie. */
readonly date?: string;
}
/** Un nœud SGF et les variantes qui le suivent. */
interface SgfNode {
readonly properties: string;
readonly children: SgfNode[];
}
/**
* La partie au format SGF de GNU Backgammon, branches comprises (en
* variantes, la branche courante d'abord). Le moteur ne sert qu'à décoder
* une position de départ particulière.
*
* Glossaire : docs/glossaire/sgf.md
*/
export async function exportSgf(
engine: Engine,
journal: GameJournal,
options: SgfOptions,
): Promise<string> {
const start = journal.events[0];
if (start?.type !== "GameStarted") {
throw new Error("journal de partie vide ou mal formé");
}
const byId = new Map(journal.events.map((event) => [event.id, event]));
const onHeadLine = new Set(currentLine(journal).map((event) => event.id));
const children = new Map<string, GameEvent[]>();
for (const event of journal.events) {
if (event.parent !== null) {
children.set(event.parent, [...(children.get(event.parent) ?? []), event]);
}
}
// La branche courante d'abord, puis les autres dans l'ordre du journal.
for (const list of children.values()) {
list.sort((a, b) => Number(onHeadLine.has(b.id)) - Number(onHeadLine.has(a.id)));
}
/** Les variantes qui suivent un événement. */
const variations = (id: string): SgfNode[] =>
(children.get(id) ?? []).flatMap((child) => {
const properties = nodeProperties(child, byId, children.has(child.id));
const below = variations(child.id);
return properties === null ? below : [{ properties, children: below }];
});
// La partie telle que la voit gnubg : la ligne courante.
const result = replay(currentLine(journal)).result;
const root = [
"FF[4]GM[6]CA[UTF-8]AP[Ustagammon]",
"MI[length:0][game:0][ws:0][bs:0]",
`PW[${escapeText(options.computerName)}]PB[${escapeText(options.playerName)}]`,
options.date === undefined ? "" : `DT[${escapeText(options.date)}]`,
result === null ? "" : `RE[${letter(result.winner)}+${result.points}]`,
].join("");
// Les nœuds d'installation, à la suite l'un de l'autre, puis la partie.
const setup = await setupProperties(engine, start);
const tree = setup.reduceRight<SgfNode[]>(
(below, properties) => [{ properties, children: below }],
variations(start.id),
);
return `(;${root}${sequence(tree)})\n`;
}
/** Écrit des nœuds : une suite tant qu'il n'y a qu'une variante, puis
* chaque variante entre parenthèses. */
function sequence(nodes: readonly SgfNode[]): string {
if (nodes.length === 1) {
const [node] = nodes as [SgfNode];
return `\n;${node.properties}${sequence(node.children)}`;
}
return nodes.map((node) => `(\n;${node.properties}${sequence(node.children)})`).join("");
}
/** Les propriétés du nœud d'un événement, ou null s'il n'en fait pas
* (début, dés suivis d'un coup, fin de partie). */
function nodeProperties(
event: GameEvent,
byId: ReadonlyMap<string, GameEvent>,
hasChildren: boolean,
): string | null {
switch (event.type) {
case "MovePlayed": {
const rolled = event.parent === null ? undefined : byId.get(event.parent);
if (rolled?.type !== "DiceRolled") {
throw new Error(`coup sans jet de dés : ${event.id}`);
}
const moves = event.halfMoves.map((h) => halfMove(h, event.side)).join("");
return `${letter(event.side)}[${rolled.roll[0]}${rolled.roll[1]}${moves}]`;
}
case "CubeOffered":
return `${letter(event.side)}[double]`;
case "CubeTaken":
return `${letter(event.side)}[take]`;
case "CubeDropped":
return `${letter(event.side)}[drop]`;
case "DiceRolled": {
// Des dés pas encore joués, au bout d'une branche.
if (hasChildren) {
return null;
}
const [a, b] = event.roll;
const side = event.side ?? (a > b ? "player" : a < b ? "computer" : null);
return side === null ? null : `PL[${letter(side)}]DI[${a}${b}]`;
}
case "GameStarted":
case "GameEnded":
return null;
}
}
/** Les propriétés des nœuds qui posent une position de départ particulière
* (aucun pour la position habituelle, videau au centre, jet d'ouverture à
* faire). */
async function setupProperties(
engine: Engine,
start: Extract<GameEvent, { type: "GameStarted" }>,
): Promise<string[]> {
const usual =
start.position === START_POSITION &&
start.firstTurn === null &&
start.cube.value === 1 &&
start.cube.owner === null;
if (usual) {
return [];
}
const onRoll: Side = start.firstTurn ?? "player";
const owner =
start.cube.owner === null
? "centered"
: start.cube.owner === onRoll
? "onRoll"
: "opponent";
const board = await engine.board(start.position, { value: start.cube.value, owner });
// Chaque camp avec sa propre numérotation : W en 'a' + (c - 1), B en
// 'x' - (c - 1), la barre en 'y' (comme les coups).
const stones = (side: Side, points: readonly number[], bar: number) => {
const out: string[] = [];
points.forEach((count, i) => {
for (let k = 0; k < count; k++) {
out.push(`[${pointLetter(i + 1, side)}]`);
}
});
for (let k = 0; k < bar; k++) {
out.push("[y]");
}
return out.join("");
};
const other: Side = onRoll === "player" ? "computer" : "player";
const sides = { [onRoll]: board.onRoll, [other]: board.opponent } as Record<
Side,
typeof board.onRoll
>;
const white = stones("computer", sides.computer.points, sides.computer.bar);
const black = stones("player", sides.player.points, sides.player.bar);
const nodes = [
`PL[${letter(onRoll)}]AE[a:y]${white === "" ? "" : `AW${white}`}${black === "" ? "" : `AB${black}`}`,
];
if (start.cube.value !== 1) {
nodes.push(`CV[${start.cube.value}]`);
}
if (start.cube.owner !== null) {
nodes.push(`CP[${start.cube.owner === "player" ? "b" : "w"}]`);
}
return nodes;
}
function letter(side: Side): "B" | "W" {
return side === "player" ? "B" : "W";
}
function halfMove(h: HalfMove, side: Side): string {
return pointLetter(h.from, side) + pointLetter(h.to, side);
}
/** Une case vue du camp side : 1 à 24, BAR ou OFF. */
function pointLetter(point: number, side: Side): string {
if (point === BAR) {
return "y";
}
if (point === OFF) {
return "z";
}
const code =
side === "player" ? "x".charCodeAt(0) - (point - 1) : "a".charCodeAt(0) + point - 1;
return String.fromCharCode(code);
}
/** Échappe un texte libre dans une valeur SGF. */
function escapeText(text: string): string {
return text.replace(/[\\\]]/g, (c) => `\\${c}`);
}
+100 -7
View File
@@ -5,36 +5,120 @@
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
import { switchToBranch } from "./domain/game/branches.js";
import type { Level } from "./domain/game/engine.js";
import { Game } from "./domain/game/game.js";
import { exportSgf } from "./domain/game/sgf.js";
import { createWasmEngine } from "./engine/wasm-engine.js";
import { startEngineWorker } from "./engine/worker-run.js";
import { createGameStore } from "./storage/game-store.js";
import { newGameId } from "./storage/game-library.js";
import { openGameLibrary } from "./storage/open-library.js";
import { startApp } from "./ui/app.js";
import { GAMES_TEXTS } from "./ui/games-texts.js";
import { createGamesView } from "./ui/games-view.js";
// Ce fichier est servi sous app/ ; le Worker et le service worker sont à la
// racine du site.
const worker = startEngineWorker(new URL("../engine-worker.mjs", import.meta.url));
const engine = createWasmEngine(worker.run);
const store = createGameStore(window.localStorage);
// Les parties enregistrées (IndexedDB, ou localStorage en repli). Chaque
// accès au stockage du navigateur peut lever une exception (stockage
// interdit) : on fait alors sans.
const { library } = await openGameLibrary({
indexedDB: attempt(() => window.indexedDB),
localStorage: attempt(() => window.localStorage),
});
/** Le niveau de l'ordinateur (celui de Game.start, STANDARD_PLY). */
const LEVEL: Level = "2ply";
const saved = await library.current().catch(() => null);
// La partie à l'écran ; un nouvel identifiant à chaque nouvelle partie.
let shownId = saved?.summary.id ?? newGameId();
// Vrai une fois qu'on recharge la page pour reprendre une autre partie :
// plus rien n'est enregistré sous l'identifiant de celle qu'on quitte.
let leaving = false;
startApp({
engineReady: worker.ready,
newGame: () => Game.start(engine),
newGame: () => {
shownId = newGameId();
return Game.start(engine);
},
savedGame: () => {
const journal = store.load();
if (journal === null) {
if (saved === null) {
return null;
}
try {
return Game.resume(engine, journal);
return Game.resume(engine, saved.journal);
} catch {
return null; // partie illisible : on en commence une nouvelle
}
},
saveGame: (journal) => store.save(journal),
saveGame: (journal) => {
if (!leaving) {
// Stockage plein ou interdit : la partie continue, elle ne
// pourra seulement pas être reprise.
library.save(shownId, journal, { level: LEVEL }).catch(() => {});
}
},
confirm: (question) => window.confirm(question),
});
// « Mes parties ». Point d'accroche provisoire : un bouton ajouté devant
// celui de l'aide, en attendant le menu de l'application (révision UX).
// Reprendre une partie la désigne comme partie en cours, puis recharge la
// page, qui l'ouvre.
const gamesView = createGamesView({
list: () => library.list(),
journal: async (id) => (await library.load(id))?.journal ?? null,
shownId: () => shownId,
resume: async (id, branchHead) => {
const game = await library.load(id);
if (game === null) {
throw new Error("partie introuvable");
}
leaving = true;
if (branchHead === undefined) {
await library.setCurrent(id);
} else {
const journal = switchToBranch(game.journal, branchHead);
await library.save(id, journal, { level: game.summary.level });
}
window.location.reload();
},
remove: (id) => library.remove(id),
exportSgf: async (id) => {
const game = await library.load(id);
if (game === null) {
throw new Error("partie introuvable");
}
const started = new Date(game.summary.startedAt);
const date = [started.getFullYear(), started.getMonth() + 1, started.getDate()]
.map((n) => String(n).padStart(2, "0"))
.join("-");
const time = [started.getHours(), started.getMinutes()]
.map((n) => String(n).padStart(2, "0"))
.join("");
const text = await exportSgf(engine, game.journal, {
playerName: "Vous",
computerName: "Ustagammon",
date,
});
return { fileName: `ustagammon-${date}-${time}.sgf`, text };
},
confirm: (question) => window.confirm(question),
});
const helpButton = document.getElementById("help-button");
if (helpButton !== null) {
const gamesButton = document.createElement("button");
gamesButton.id = "games-button";
gamesButton.type = "button";
gamesButton.className = "secondary";
gamesButton.textContent = GAMES_TEXTS.button;
gamesButton.onclick = () => void gamesView.open();
helpButton.before(gamesButton);
}
// Hors ligne : le service worker met tout le site en cache. Le navigateur le
// refuse hors https (sauf localhost) : l'application marche alors sans.
// Il est enregistré une fois le moteur chargé : si le Worker et le service
@@ -46,3 +130,12 @@ if ("serviceWorker" in navigator) {
const sw = new URL("../sw.js", import.meta.url);
worker.ready.then(() => navigator.serviceWorker.register(sw)).catch(() => {});
}
/** La valeur, ou undefined si l'obtenir lève une exception. */
function attempt<T>(get: () => T): T | undefined {
try {
return get();
} catch {
return undefined;
}
}
+140
View File
@@ -0,0 +1,140 @@
// Les parties enregistrées sur l'appareil : leur résumé, leur journal
// d'événements, et laquelle est la partie en cours.
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
//
// Deux stockages derrière la même interface (GameLibrary) :
// - IndexedDB, le stockage normal du navigateur (indexeddb-backend.ts) ;
// - un stockage clé-valeur (localStorage), en repli quand IndexedDB est
// indisponible, par exemple en navigation privée de certains navigateurs.
// Les opérations sont exécutées l'une après l'autre, dans l'ordre des
// appels : un enregistrement lancé sans être attendu est donc toujours fait
// avant la lecture qui suit.
import type { GameJournal } from "../domain/game/events.js";
import {
type SavedGameDetails,
type SavedGameSummary,
summarizeGame,
} from "../domain/game/saved-game.js";
/** Une partie enregistrée : son résumé et son journal. */
export interface SavedGame {
readonly summary: SavedGameSummary;
readonly journal: GameJournal;
}
export interface GameLibrary {
/** Les parties, la dernière jouée d'abord. */
list(): Promise<SavedGameSummary[]>;
/** Une partie, ou null si elle n'existe pas ou est illisible. */
load(id: string): Promise<SavedGame | null>;
/** Enregistre la partie id (nouvelle ou non), qui devient la partie en
* cours. Renvoie son résumé. */
save(id: string, journal: GameJournal, details: SavedGameDetails): Promise<SavedGameSummary>;
/** Supprime une partie (rien si elle n'existe pas). */
remove(id: string): Promise<void>;
/** La partie en cours : la dernière enregistrée ou choisie (setCurrent),
* si elle existe encore ; sinon la dernière jouée ; null s'il n'y en a
* aucune. */
current(): Promise<SavedGame | null>;
/** Fait d'une partie enregistrée la partie en cours, sans la modifier
* (pour la reprendre). */
setCurrent(id: string): Promise<void>;
}
/** Ce que chaque stockage sait faire ; GameLibrary ajoute l'ordre des
* opérations, les dates et la validation. */
export interface LibraryBackend {
summaries(): Promise<SavedGameSummary[]>;
summary(id: string): Promise<SavedGameSummary | undefined>;
journal(id: string): Promise<unknown>;
/** Écrit le résumé et le journal ensemble. */
write(summary: SavedGameSummary, journal: GameJournal): Promise<void>;
remove(id: string): Promise<void>;
currentId(): Promise<string | undefined>;
setCurrentId(id: string): Promise<void>;
}
export interface LibraryOptions {
/** L'horloge, en millisecondes ; Date.now par défaut. */
readonly now?: () => number;
}
/** Un identifiant de partie : la date et du hasard, sans dépendre de
* crypto.randomUUID, absent hors https. */
export function newGameId(now: number = Date.now()): string {
const random = Math.floor(Math.random() * 36 ** 6)
.toString(36)
.padStart(6, "0");
return `${now.toString(36)}-${random}`;
}
/** Un journal lisible : la forme attendue, sans juger de son contenu (le
* domaine le vérifie en reprenant la partie). */
export function isJournal(value: unknown): value is GameJournal {
if (typeof value !== "object" || value === null) {
return false;
}
const journal = value as { events?: unknown; head?: unknown };
return (
Array.isArray(journal.events) && (journal.head === null || typeof journal.head === "string")
);
}
export function createGameLibrary(
backend: LibraryBackend,
options: LibraryOptions = {},
): GameLibrary {
const now = options.now ?? Date.now;
let queue: Promise<unknown> = Promise.resolve();
/** Exécute op après toutes les opérations déjà demandées. */
const enqueue = <T>(op: () => Promise<T>): Promise<T> => {
const result = queue.then(op, op);
queue = result.catch(() => {});
return result;
};
const load = async (id: string): Promise<SavedGame | null> => {
const summary = await backend.summary(id);
const journal = await backend.journal(id);
return summary === undefined || !isJournal(journal) ? null : { summary, journal };
};
const list = async () => (await backend.summaries()).sort((a, b) => b.updatedAt - a.updatedAt);
return {
list: () => enqueue(list),
load: (id) => enqueue(() => load(id)),
save: (id, journal, details) =>
enqueue(async () => {
const time = now();
const startedAt = (await backend.summary(id))?.startedAt ?? time;
const summary = summarizeGame(id, journal, details, {
startedAt,
updatedAt: Math.max(time, startedAt),
});
await backend.write(summary, journal);
await backend.setCurrentId(id);
return summary;
}),
remove: (id) => enqueue(() => backend.remove(id)),
current: () =>
enqueue(async () => {
const id = await backend.currentId();
const chosen = id === undefined ? null : await load(id);
if (chosen !== null) {
return chosen;
}
const latest = (await list())[0];
return latest === undefined ? null : load(latest.id);
}),
setCurrent: (id) =>
enqueue(async () => {
if ((await backend.summary(id)) === undefined) {
throw new Error(`partie introuvable : ${id}`);
}
await backend.setCurrentId(id);
}),
};
}
-47
View File
@@ -1,47 +0,0 @@
// L'enregistrement de la partie en cours, pour la reprendre après fermeture
// de la page : son journal d'événements, en JSON, dans un stockage clé-valeur
// (localStorage dans le navigateur).
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
import type { GameJournal } from "../domain/game/events.js";
/** Ce qu'il faut d'un stockage clé-valeur (localStorage en a davantage). */
export interface KeyValueStorage {
getItem(key: string): string | null;
setItem(key: string, value: string): void;
}
export interface GameStore {
/** Le journal enregistré, ou null s'il n'y en a pas de lisible. */
load(): GameJournal | null;
save(journal: GameJournal): void;
}
const KEY = "ustagammon.current-game";
export function createGameStore(storage: KeyValueStorage): GameStore {
return {
load() {
try {
const journal = JSON.parse(storage.getItem(KEY) ?? "null");
const valid =
journal !== null &&
Array.isArray(journal.events) &&
(journal.head === null || typeof journal.head === "string");
return valid ? journal : null;
} catch {
return null;
}
},
save(journal) {
try {
storage.setItem(KEY, JSON.stringify(journal));
} catch {
// Stockage plein ou interdit (navigation privée) : la partie
// continue, elle ne pourra seulement pas être reprise.
}
},
};
}
+81
View File
@@ -0,0 +1,81 @@
// Les parties dans IndexedDB, le stockage de données du navigateur.
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
//
// Base « ustagammon », trois magasins d'objets :
// - games : les résumés des parties (SavedGameSummary), clé id ;
// - journals : leurs journaux, { id, journal }, clé id ;
// - settings : { key, value }, dont la partie en cours (key « current »).
// Le résumé et le journal d'une partie sont écrits dans la même transaction.
// L'API IndexedDB est utilisée directement, sans bibliothèque.
import type { GameJournal } from "../domain/game/events.js";
import type { SavedGameSummary } from "../domain/game/saved-game.js";
import type { LibraryBackend } from "./game-library.js";
const DATABASE = "ustagammon";
const VERSION = 1;
const GAMES = "games";
const JOURNALS = "journals";
const SETTINGS = "settings";
/** Le résultat d'une requête IndexedDB, en promesse. */
function done<T>(request: IDBRequest<T>): Promise<T> {
return new Promise((resolve, reject) => {
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
/** La fin d'une transaction (toutes ses écritures faites), en promesse. */
function committed(transaction: IDBTransaction): Promise<void> {
return new Promise((resolve, reject) => {
transaction.oncomplete = () => resolve();
transaction.onerror = () => reject(transaction.error);
transaction.onabort = () => reject(transaction.error ?? new Error("transaction annulée"));
});
}
/** Ouvre la base, en la créant au besoin. Rejetée si IndexedDB est
* indisponible ou refusé. */
export async function openIndexedDbBackend(factory: IDBFactory): Promise<LibraryBackend> {
const request = factory.open(DATABASE, VERSION);
request.onupgradeneeded = () => {
const db = request.result;
db.createObjectStore(GAMES, { keyPath: "id" });
db.createObjectStore(JOURNALS, { keyPath: "id" });
db.createObjectStore(SETTINGS, { keyPath: "key" });
};
const db = await done(request);
const get = async <T>(store: string, key: string): Promise<T | undefined> =>
done(db.transaction(store).objectStore(store).get(key));
return {
summaries: async () => done(db.transaction(GAMES).objectStore(GAMES).getAll()),
summary: (id) => get<SavedGameSummary>(GAMES, id),
journal: async (id) => (await get<{ journal: unknown }>(JOURNALS, id))?.journal,
write: async (summary: SavedGameSummary, journal: GameJournal) => {
const transaction = db.transaction([GAMES, JOURNALS], "readwrite");
transaction.objectStore(JOURNALS).put({ id: summary.id, journal });
transaction.objectStore(GAMES).put(summary);
await committed(transaction);
},
remove: async (id) => {
const transaction = db.transaction([GAMES, JOURNALS], "readwrite");
transaction.objectStore(GAMES).delete(id);
transaction.objectStore(JOURNALS).delete(id);
await committed(transaction);
},
currentId: async () => {
const setting = await get<{ value: unknown }>(SETTINGS, "current");
return typeof setting?.value === "string" ? setting.value : undefined;
},
setCurrentId: async (id) => {
const transaction = db.transaction(SETTINGS, "readwrite");
transaction.objectStore(SETTINGS).put({ key: "current", value: id });
await committed(transaction);
},
};
}
+99
View File
@@ -0,0 +1,99 @@
// Les parties dans un stockage clé-valeur (localStorage), en JSON : le
// repli quand IndexedDB est indisponible, et la lecture de la partie
// enregistrée par les versions précédentes, pour la migrer.
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
import type { GameJournal } from "../domain/game/events.js";
import type { SavedGameSummary } from "../domain/game/saved-game.js";
import { type GameLibrary, isJournal, type LibraryBackend, newGameId } from "./game-library.js";
/** Ce qu'il faut d'un stockage clé-valeur (localStorage en a davantage). */
export interface KeyValueStorage {
getItem(key: string): string | null;
setItem(key: string, value: string): void;
removeItem(key: string): void;
}
const SUMMARIES = "ustagammon.games";
const CURRENT = "ustagammon.current-game-id";
const journalKey = (id: string) => `ustagammon.game.${id}`;
/** La partie unique des versions précédentes (étape 0), à migrer. */
const LEGACY_GAME = "ustagammon.current-game";
/** Lit une clé en JSON ; undefined si elle est absente, illisible, ou si le
* stockage est interdit. */
function read(storage: KeyValueStorage, key: string): unknown {
try {
const text = storage.getItem(key);
return text === null ? undefined : JSON.parse(text);
} catch {
return undefined;
}
}
export function createKeyValueBackend(storage: KeyValueStorage): LibraryBackend {
const summaries = (): SavedGameSummary[] => {
const value = read(storage, SUMMARIES);
return Array.isArray(value) ? value : [];
};
const writeSummaries = (list: readonly SavedGameSummary[]) =>
storage.setItem(SUMMARIES, JSON.stringify(list));
return {
summaries: async () => summaries(),
summary: async (id) => summaries().find((s) => s.id === id),
journal: async (id) => read(storage, journalKey(id)),
write: async (summary, journal) => {
// Le journal d'abord : un résumé ne désigne jamais un journal
// absent.
storage.setItem(journalKey(summary.id), JSON.stringify(journal));
writeSummaries([...summaries().filter((s) => s.id !== summary.id), summary]);
},
remove: async (id) => {
writeSummaries(summaries().filter((s) => s.id !== id));
storage.removeItem(journalKey(id));
},
currentId: async () => {
const id = read(storage, CURRENT);
return typeof id === "string" ? id : undefined;
},
setCurrentId: async (id) => storage.setItem(CURRENT, JSON.stringify(id)),
};
}
/**
* Reprend dans library la partie enregistrée par les versions précédentes
* (une seule partie, sous une clé de localStorage), qui devient la partie
* en cours ; puis efface l'ancienne clé. Sans effet s'il n'y en a pas. Un
* journal illisible est effacé. Renvoie l'identifiant de la partie migrée.
*/
export async function migrateLegacyGame(
storage: KeyValueStorage,
library: GameLibrary,
now: number = Date.now(),
): Promise<string | null> {
let present: boolean;
try {
present = storage.getItem(LEGACY_GAME) !== null;
} catch {
return null;
}
if (!present) {
return null;
}
const journal: unknown = read(storage, LEGACY_GAME);
let id: string | null = null;
if (isJournal(journal) && journal.events.length > 0) {
id = newGameId(now);
// Les parties de l'étape 0 se jouaient toutes au niveau standard.
await library.save(id, journal as GameJournal, { level: "2ply" });
}
try {
storage.removeItem(LEGACY_GAME);
} catch {
// Stockage interdit : la migration sera refaite, sans dommage.
}
return id;
}
+96
View File
@@ -0,0 +1,96 @@
// Ouverture des parties enregistrées au démarrage : IndexedDB si possible,
// sinon localStorage, puis migration de la partie des versions précédentes.
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
import { createGameLibrary, type GameLibrary, type LibraryOptions } from "./game-library.js";
import { openIndexedDbBackend } from "./indexeddb-backend.js";
import {
createKeyValueBackend,
type KeyValueStorage,
migrateLegacyGame,
} from "./key-value-backend.js";
/** Les stockages du navigateur ; chacun peut manquer ou être refusé. */
export interface BrowserStorage {
readonly indexedDB?: IDBFactory;
readonly localStorage?: KeyValueStorage;
}
export interface OpenedLibrary {
readonly library: GameLibrary;
/** Le stockage retenu : IndexedDB, son repli localStorage, ou la seule
* mémoire de la page (rien n'est gardé après sa fermeture). */
readonly kind: "indexeddb" | "localStorage" | "memory";
}
/** Au-delà, IndexedDB est tenu pour indisponible (une ouverture bloquée ne
* doit pas empêcher de jouer). */
const OPEN_TIMEOUT_MS = 5000;
export async function openGameLibrary(
storage: BrowserStorage,
options: LibraryOptions = {},
): Promise<OpenedLibrary> {
const opened = await open(storage, options);
if (storage.localStorage !== undefined) {
try {
await migrateLegacyGame(storage.localStorage, opened.library);
} catch {
// Migration impossible (stockage plein ou refusé) : l'ancienne
// partie reste où elle est, on la retentera au prochain démarrage.
}
}
return opened;
}
async function open(storage: BrowserStorage, options: LibraryOptions): Promise<OpenedLibrary> {
if (storage.indexedDB !== undefined) {
let timer: ReturnType<typeof setTimeout> | undefined;
try {
const backend = await Promise.race([
openIndexedDbBackend(storage.indexedDB),
new Promise<never>((_, reject) => {
timer = setTimeout(() => reject(new Error("IndexedDB muet")), OPEN_TIMEOUT_MS);
}),
]);
return { library: createGameLibrary(backend, options), kind: "indexeddb" };
} catch {
// Indisponible (navigation privée de certains navigateurs) : repli.
} finally {
clearTimeout(timer);
}
}
if (storage.localStorage !== undefined && usable(storage.localStorage)) {
return {
library: createGameLibrary(createKeyValueBackend(storage.localStorage), options),
kind: "localStorage",
};
}
return {
library: createGameLibrary(createKeyValueBackend(memoryStorage()), options),
kind: "memory",
};
}
/** localStorage peut exister et refuser toute écriture. */
function usable(storage: KeyValueStorage): boolean {
try {
storage.setItem("ustagammon.probe", "1");
storage.removeItem("ustagammon.probe");
return true;
} catch {
return false;
}
}
/** Un stockage clé-valeur en mémoire. */
export function memoryStorage(): KeyValueStorage {
const items = new Map<string, string>();
return {
getItem: (key) => items.get(key) ?? null,
setItem: (key, value) => void items.set(key, value),
removeItem: (key) => void items.delete(key),
};
}
+65
View File
@@ -0,0 +1,65 @@
// Les textes de « Mes parties » (ADR 0007, point 5), à part de texts.ts le
// temps que l'écran rejoigne le menu de l'application.
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
import type { ResultKind } from "../domain/game/engine.js";
import type { Side } from "../domain/game/side.js";
const RESULT_KIND: Record<ResultKind, string> = {
single: "partie simple",
gammon: "gammon",
backgammon: "backgammon",
};
function moves(n: number): string {
return n > 1 ? `${n} coups` : `${n} coup`;
}
function points(n: number): string {
return n > 1 ? `${n} points` : `${n} point`;
}
export const GAMES_TEXTS = {
button: "Mes parties",
title: "Mes parties",
empty: "Aucune partie enregistrée.",
close: "Fermer",
resume: "Reprendre",
showBranches: "Branches",
resumeBranch: "Reprendre cette branche",
exportSgf: "Exporter (SGF)",
remove: "Supprimer",
confirmRemove: "Supprimer définitivement cette partie et toutes ses branches ?",
shown: "partie affichée",
failed: (message: string) => `Erreur : ${message}`,
/** « 3 nov. 2026, 14:05 ». */
date: (ms: number) =>
new Date(ms).toLocaleString("fr-FR", { dateStyle: "medium", timeStyle: "short" }),
status: (
result: { winner: Side; kind: ResultKind; points: number } | null,
moveCount: number,
) => {
if (result === null) {
return `En cours, ${moves(moveCount)}`;
}
const who = result.winner === "player" ? "Gagnée" : "Perdue";
return `${who} (${RESULT_KIND[result.kind]}, ${points(result.points)}), ${moves(moveCount)}`;
},
branchCount: (n: number) => (n > 1 ? `${n} branches` : ""),
/** Une branche : d'où elle part et où elle en est. */
branch: (forkMoveCount: number | null, moveCount: number, over: boolean) => {
const start =
forkMoveCount === null
? "Partie d'origine"
: forkMoveCount === 0
? "Rejouée depuis le début"
: `Rejouée après le coup ${forkMoveCount}`;
return `${start} : ${moves(moveCount)}${over ? ", finie" : ""}`;
},
currentBranch: " (branche courante)",
};
+205
View File
@@ -0,0 +1,205 @@
// « Mes parties » : la liste des parties enregistrées, pour en reprendre une
// (ou l'une de ses branches), l'exporter en SGF ou la supprimer.
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
//
// Un écran autonome, dans une fenêtre (<dialog>) qu'il crée lui-même : il ne
// dépend ni de index.html ni de l'écran de jeu (app.ts). Comme tout l'écran,
// il ne connaît ni le stockage ni le moteur : main.ts lui passe les
// opérations (GamesViewOptions).
import { branches } from "../domain/game/branches.js";
import type { GameJournal } from "../domain/game/events.js";
import type { SavedGameSummary } from "../domain/game/saved-game.js";
import { GAMES_TEXTS } from "./games-texts.js";
export interface GamesViewOptions {
/** Les parties, la dernière jouée d'abord. */
readonly list: () => Promise<SavedGameSummary[]>;
/** Le journal d'une partie (null si elle a disparu). */
readonly journal: (id: string) => Promise<GameJournal | null>;
/** L'identifiant de la partie affichée à l'écran. */
readonly shownId: () => string | null;
/** Reprend une partie, au bout de la branche branchHead si elle est
* donnée (sinon là où elle en était). */
readonly resume: (id: string, branchHead?: string) => Promise<void>;
readonly remove: (id: string) => Promise<void>;
/** Le fichier SGF d'une partie : son nom et son contenu. */
readonly exportSgf: (id: string) => Promise<{ fileName: string; text: string }>;
readonly confirm: (question: string) => boolean;
}
export interface GamesView {
/** Ouvre la liste, à jour. */
open(): Promise<void>;
}
/** Crée la fenêtre « Mes parties » (fermée) dans la page. */
export function createGamesView(options: GamesViewOptions): GamesView {
const dialog = element("dialog", "confirm games");
dialog.id = "games-dialog";
dialog.setAttribute("aria-labelledby", "games-title");
const title = element("h2");
title.id = "games-title";
title.textContent = GAMES_TEXTS.title;
const list = element("ul", "games-list");
const message = element("p", "games-message");
message.setAttribute("aria-live", "polite");
const close = button(GAMES_TEXTS.close, () => dialog.close(), "secondary");
close.id = "games-close";
dialog.append(title, message, list, close);
document.body.append(dialog);
/** Lance une action ; une erreur s'affiche dans la fenêtre. */
const run = (action: () => Promise<void>) => async () => {
message.textContent = "";
try {
await action();
} catch (error) {
message.textContent = GAMES_TEXTS.failed(
error instanceof Error ? error.message : String(error),
);
}
};
async function render(): Promise<void> {
const games = await options.list();
list.replaceChildren(...games.map((game) => gameItem(game, game.id === options.shownId())));
if (games.length === 0) {
message.textContent = GAMES_TEXTS.empty;
}
}
function gameItem(game: SavedGameSummary, shown: boolean): HTMLElement {
const item = element("li", "games-item");
item.dataset.id = game.id;
const heading = element("p");
heading.textContent = [
GAMES_TEXTS.date(game.startedAt),
GAMES_TEXTS.status(game.result, game.moveCount),
GAMES_TEXTS.branchCount(game.branchCount),
shown ? GAMES_TEXTS.shown : "",
]
.filter((part) => part !== "")
.join(" · ");
const branchList = element("ul", "games-branches");
branchList.hidden = true;
const actions = element("div", "confirm-actions");
actions.append(
button(
GAMES_TEXTS.resume,
run(() => options.resume(game.id)),
"",
"resume",
),
button(
GAMES_TEXTS.showBranches,
run(async () => {
if (branchList.hidden) {
await renderBranches(game.id, branchList);
}
branchList.hidden = !branchList.hidden;
}),
"secondary",
"branches",
),
button(
GAMES_TEXTS.exportSgf,
run(async () => download(await options.exportSgf(game.id))),
"secondary",
"export",
),
button(
GAMES_TEXTS.remove,
run(async () => {
if (options.confirm(GAMES_TEXTS.confirmRemove)) {
await options.remove(game.id);
await render();
}
}),
"secondary",
"remove",
),
);
item.append(heading, actions, branchList);
return item;
}
async function renderBranches(id: string, target: HTMLElement): Promise<void> {
const journal = await options.journal(id);
const all = journal === null ? [] : branches(journal);
target.replaceChildren(
...all.map((branch) => {
const item = element("li", "games-branch");
item.dataset.head = branch.head;
const text = element("span");
text.textContent =
GAMES_TEXTS.branch(
branch.forkedFrom === null ? null : branch.forkMoveCount,
branch.moveCount,
branch.over,
) + (branch.containsHead ? GAMES_TEXTS.currentBranch : "");
item.append(
text,
button(
GAMES_TEXTS.resumeBranch,
run(() => options.resume(id, branch.head)),
"secondary",
"resume-branch",
),
);
return item;
}),
);
}
return {
async open() {
message.textContent = "";
await render();
if (!dialog.open) {
dialog.showModal();
}
},
};
}
function element<K extends keyof HTMLElementTagNameMap>(
tag: K,
className = "",
): HTMLElementTagNameMap[K] {
const created = document.createElement(tag);
if (className !== "") {
created.className = className;
}
return created;
}
function button(
text: string,
onClick: () => unknown,
className = "",
action = "",
): HTMLButtonElement {
const created = element("button", className);
created.type = "button";
created.textContent = text;
if (action !== "") {
created.dataset.action = action;
}
created.onclick = () => void onClick();
return created;
}
/** Fait télécharger un fichier texte au navigateur. */
function download(file: { fileName: string; text: string }): void {
const url = URL.createObjectURL(new Blob([file.text], { type: "application/x-go-sgf" }));
const link = element("a");
link.href = url;
link.download = file.fileName;
document.body.append(link);
link.click();
link.remove();
setTimeout(() => URL.revokeObjectURL(url), 10_000);
}
+172
View File
@@ -0,0 +1,172 @@
// 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/);
});
+218
View File
@@ -0,0 +1,218 @@
// Tests du stockage des parties (src/storage/), sans navigateur : la
// bibliothèque de parties sur un stockage clé-valeur en mémoire (le repli
// de localStorage), la migration de la partie des versions précédentes et
// le choix du stockage. IndexedDB, absent de Node, est essayé dans un vrai
// navigateur (tests/browser/play.mjs).
//
// 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 {
appendEvent,
EMPTY_JOURNAL,
type GameJournal,
goBackTo,
} from "../src/domain/game/events.js";
import { START_POSITION } from "../src/domain/game/position.js";
import { createGameLibrary, isJournal, newGameId } from "../src/storage/game-library.js";
import {
createKeyValueBackend,
type KeyValueStorage,
migrateLegacyGame,
} from "../src/storage/key-value-backend.js";
import { memoryStorage, openGameLibrary } from "../src/storage/open-library.js";
/** Une partie commencée, et éventuellement quelques événements de plus. */
function journal(extraRolls = 0): GameJournal {
let j = appendEvent(EMPTY_JOURNAL, {
type: "GameStarted",
position: START_POSITION,
cube: { value: 1, owner: null },
firstTurn: null,
});
for (let i = 0; i < extraRolls; i++) {
j = appendEvent(j, { type: "DiceRolled", side: null, roll: [3, 3] });
}
return j;
}
/** Une horloge qui avance d'une seconde à chaque lecture. */
function clock(start = 1_000_000): () => number {
let t = start;
return () => {
t += 1000;
return t;
};
}
function library(storage: KeyValueStorage = memoryStorage()) {
return createGameLibrary(createKeyValueBackend(storage), { now: clock() });
}
test("plusieurs parties : liste, chargement, dernière jouée d'abord", async () => {
const games = library();
assert.deepEqual(await games.list(), []);
assert.equal(await games.current(), null);
const a = await games.save("a", journal(), { level: "2ply" });
await games.save("b", journal(1), { level: "beginner" });
assert.deepEqual(
(await games.list()).map((s) => s.id),
["b", "a"],
);
assert.equal(a.status, "playing");
assert.equal(a.result, null);
assert.equal(a.moveCount, 0);
assert.equal(a.branchCount, 1);
// Rejouer « a » la remet en tête ; sa date de début ne change pas.
const again = await games.save("a", journal(2), { level: "2ply" });
assert.equal(again.startedAt, a.startedAt);
assert.ok(again.updatedAt > a.updatedAt);
assert.deepEqual(
(await games.list()).map((s) => s.id),
["a", "b"],
);
assert.deepEqual((await games.load("a"))?.journal, journal(2));
assert.equal((await games.load("b"))?.summary.level, "beginner");
assert.equal(await games.load("inconnue"), null);
});
test("partie en cours : la dernière enregistrée, ou celle qu'on reprend", async () => {
const games = library();
await games.save("ancienne", journal(), { level: "2ply" });
await games.save("nouvelle", journal(1), { level: "2ply" });
assert.equal((await games.current())?.summary.id, "nouvelle");
// « Reprendre l'ancienne » : elle redevient la partie en cours, sans
// être modifiée ; la nouvelle peut être supprimée.
const before = await games.load("ancienne");
await games.setCurrent("ancienne");
await games.remove("nouvelle");
assert.deepEqual(await games.current(), before);
assert.deepEqual(
(await games.list()).map((s) => s.id),
["ancienne"],
);
await assert.rejects(games.setCurrent("nouvelle"), /introuvable/);
// La partie en cours supprimée : la dernière jouée la remplace.
await games.save("autre", journal(), { level: "2ply" });
await games.setCurrent("ancienne");
await games.remove("ancienne");
assert.equal((await games.current())?.summary.id, "autre");
});
test("les opérations sont faites dans l'ordre des appels", async () => {
const games = library();
// Lancées sans attendre, comme l'enregistrement après chaque action.
void games.save("a", journal(), { level: "2ply" });
void games.save("a", journal(3), { level: "2ply" });
void games.remove("a");
void games.save("b", journal(), { level: "2ply" });
assert.deepEqual(
(await games.list()).map((s) => s.id),
["b"],
);
});
test("les branches et le résultat sont résumés", async () => {
const games = library();
let j = journal(2);
j = goBackTo(j, "2");
j = appendEvent(j, { type: "DiceRolled", side: null, roll: [5, 2] });
j = appendEvent(j, {
type: "MovePlayed",
side: "player",
halfMoves: [],
position: START_POSITION,
});
j = appendEvent(j, { type: "GameEnded", winner: "player", kind: "gammon", points: 2 });
const summary = await games.save("x", j, { level: "2ply" });
assert.equal(summary.branchCount, 2);
assert.equal(summary.moveCount, 1);
assert.equal(summary.status, "over");
assert.deepEqual(summary.result, { winner: "player", kind: "gammon", points: 2 });
});
test("un journal illisible n'est pas chargé ; un stockage plein ne casse rien", async () => {
const storage = memoryStorage();
const games = library(storage);
await games.save("a", journal(), { level: "2ply" });
storage.setItem("ustagammon.game.a", "{pas du JSON");
assert.equal(await games.load("a"), null);
assert.equal(isJournal({ events: [], head: 3 }), false);
assert.equal(isJournal(null), false);
const full = library({
getItem: () => null,
setItem: () => {
throw new Error("plein");
},
removeItem: () => {},
});
await assert.rejects(full.save("a", journal(), { level: "2ply" }), /plein/);
// La bibliothèque reste utilisable après un échec.
assert.deepEqual(await full.list(), []);
});
test("la partie des versions précédentes est migrée, une seule fois", async () => {
const storage = memoryStorage();
const old = journal(1);
storage.setItem("ustagammon.current-game", JSON.stringify(old));
const games = library(storage);
const id = await migrateLegacyGame(storage, games, 42);
assert.ok(id !== null);
assert.equal(storage.getItem("ustagammon.current-game"), null);
const current = await games.current();
assert.equal(current?.summary.id, id);
assert.deepEqual(current?.journal, old);
assert.equal(await migrateLegacyGame(storage, games), null);
assert.equal((await games.list()).length, 1);
// Un ancien enregistrement illisible est seulement effacé.
storage.setItem("ustagammon.current-game", "{pas du JSON");
assert.equal(await migrateLegacyGame(storage, games), null);
assert.equal(storage.getItem("ustagammon.current-game"), null);
assert.equal((await games.list()).length, 1);
});
test("sans IndexedDB : localStorage, ou à défaut la mémoire", async () => {
const storage = memoryStorage();
storage.setItem("ustagammon.current-game", JSON.stringify(journal()));
const opened = await openGameLibrary({ localStorage: storage });
assert.equal(opened.kind, "localStorage");
assert.equal((await opened.library.list()).length, 1);
const refused: KeyValueStorage = {
getItem: () => {
throw new Error("interdit");
},
setItem: () => {
throw new Error("interdit");
},
removeItem: () => {
throw new Error("interdit");
},
};
const memory = await openGameLibrary({ localStorage: refused });
assert.equal(memory.kind, "memory");
await memory.library.save("a", journal(), { level: "2ply" });
assert.equal((await memory.library.current())?.summary.id, "a");
// IndexedDB qui échoue à l'ouverture : repli.
const broken = {
open: () => {
throw new Error("indisponible");
},
} as unknown as IDBFactory;
assert.equal((await openGameLibrary({ indexedDB: broken })).kind, "memory");
});
test("les identifiants de partie sont distincts", () => {
const ids = new Set(Array.from({ length: 100 }, () => newGameId(1)));
assert.ok(ids.size > 95);
assert.match(newGameId(), /^[0-9a-z]+-[0-9a-z]{6}$/);
});
+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();
+2 -34
View File
@@ -1,6 +1,6 @@
// Tests de ce qui, dans l'interface et le stockage, se vérifie sans
// navigateur : le plateau à dessiner, les mises en évidence, la lecture de
// l'aide, l'enregistrement de la partie.
// l'aide. Le stockage des parties a ses tests (storage.test.ts).
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
@@ -9,9 +9,7 @@ import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { test } from "node:test";
import type { BoardSide } from "../src/domain/game/engine.js";
import { appendEvent, EMPTY_JOURNAL } from "../src/domain/game/events.js";
import { parseHalfMoves, START_POSITION } from "../src/domain/game/position.js";
import { createGameStore } from "../src/storage/game-store.js";
import { parseHalfMoves } from "../src/domain/game/position.js";
import {
boardModel,
moveTargets,
@@ -176,36 +174,6 @@ test("l'aide lit chaque fiche de l'index du glossaire", () => {
assert.equal(parseGlossaryEntry("pas une fiche"), null);
});
test("la partie enregistrée se relit ; un enregistrement illisible est ignoré", () => {
const data = new Map<string, string>();
const storage = {
getItem: (key: string) => data.get(key) ?? null,
setItem: (key: string, value: string) => void data.set(key, value),
};
const store = createGameStore(storage);
assert.equal(store.load(), null);
const journal = appendEvent(EMPTY_JOURNAL, {
type: "GameStarted",
position: START_POSITION,
cube: { value: 1, owner: null },
firstTurn: null,
});
store.save(journal);
assert.deepEqual(store.load(), journal);
for (const key of data.keys()) {
data.set(key, "{pas du JSON");
}
assert.equal(store.load(), null);
// Un stockage plein ou interdit ne casse pas la partie.
const full = createGameStore({
getItem: () => null,
setItem: () => {
throw new Error("plein");
},
});
assert.doesNotThrow(() => full.save(journal));
});
test("les textes de fin de partie emploient les mots du glossaire", () => {
assert.equal(TEXTS.youWin("gammon", 2), "Vous gagnez un gammon : 2 points.");
assert.equal(
+87
View File
@@ -0,0 +1,87 @@
# Relit dans GNU Backgammon de référence les parties exportées en SGF par
# Ustagammon (app/src/domain/game/sgf.ts) et vérifie qu'il y retrouve les
# positions du journal.
#
# Ce script s'exécute DANS gnubg (scripting Python intégré), dans l'image
# Docker ustagammon/gnubg-reference : voir tests/test_web.sh. Le dossier
# SGF_DIR contient, pour chaque partie, <nom>.sgf et <nom>.json (écrits par
# app/tests/tools/sgf-samples.ts) :
# after : après chaque coup du journal, la position vue de celui qui a
# alors la main ;
# result : le résultat de la partie ({winner, points}) ou null.
#
# Pour chaque partie, gnubg charge le fichier (load game), puis avance
# enregistrement par enregistrement (next) : après chaque coup, sa position
# doit être celle du journal. Une position de départ mal posée, un coup mal
# écrit ou refusé changent donc la suite des positions. Le résultat (RE)
# doit aussi être celui du journal. Code de sortie : 0 si tout concorde.
import glob
import json
import os
import sys
import gnubg
FOLDER = os.environ.get("SGF_DIR", "/sgf")
def check(path):
"""Les écarts entre gnubg et le journal pour une partie (liste vide si
tout concorde)."""
with open(path[:-4] + ".json") as f:
expected = json.load(f)
gnubg.command("load game " + path)
games = gnubg.match(analysis=0, boards=0, statistics=0, verbose=0)["games"]
if len(games) != 1:
return ["gnubg n'a pas chargé la partie"]
game = games[0]
records = list(game["game"])
# Le premier « next » applique le premier enregistrement.
gnubg.command("first move")
after = []
for record in records:
gnubg.command("next")
if record["action"] == "move":
after.append(gnubg.positionid())
errors = []
if len(after) != len(expected["after"]):
errors.append("%d coups lus, %d attendus" % (len(after), len(expected["after"])))
for i, (got, want) in enumerate(zip(after, expected["after"])):
if got != want:
errors.append("après le coup %d : gnubg %s, journal %s" % (i + 1, got, want))
break
# Dans gnubg, X est le joueur 0 (W, l'ordinateur) et O le joueur 1 (B).
info = game["info"]
result = expected["result"]
if result is None:
if info.get("winner") is not None:
errors.append("gnubg voit une partie finie : %s" % info)
else:
winner = "O" if result["winner"] == "player" else "X"
if (info.get("winner"), info.get("points-won")) != (winner, result["points"]):
errors.append("résultat : gnubg %s, journal %s" % (info, result))
return errors
failures = 0
paths = sorted(glob.glob(os.path.join(FOLDER, "*.sgf")))
for path in paths:
errors = check(path)
name = os.path.basename(path)
if errors:
failures += 1
print("ÉCHEC %s : %s" % (name, " ; ".join(errors)))
else:
print("OK %s" % name)
if not paths:
print("aucun SGF dans %s" % FOLDER)
failures += 1
sys.stdout.flush()
# Un script --python de gnubg ne transmet pas son code de sortie : la
# dernière ligne le donne au script shell.
print("SGF-RESULTAT %s" % ("OK" if failures == 0 else "ECHEC"))
+22 -1
View File
@@ -5,7 +5,10 @@
# - scénarios de docs/scenarios/ exécutés par Cucumber sur le domaine branché
# au vrai moteur (ADR 0008) ;
# - règles d'architecture (dependency-cruiser, ADR 0007), et preuve qu'une
# violation volontaire de chaque règle est bien détectée.
# violation volontaire de chaque règle est bien détectée ;
# - export SGF : des parties jouées sur le vrai moteur, exportées, relues par
# le GNU Backgammon de référence, qui doit y retrouver les positions du
# journal (tests/sgf/check.py).
#
# Lance `make engine-wasm` et `make web` (sans effet si tout est à jour).
@@ -39,6 +42,24 @@ web . node --test --test-reporter=dot 'build/web/tests/**/*.test.js' \
web . cucumber-js --format summary --import 'build/web/tests/steps/*.js' docs/scenarios/*.feature \
|| report "scénarios en échec (voir ci-dessus)"
# Export SGF relu par gnubg de bureau. Le script Python de gnubg ne
# transmet pas de code de sortie : sa dernière ligne donne le verdict.
samples=build/sgf-samples
rm -rf "$samples"
if ! docker image inspect "${REFERENCE_IMAGE:?lancer via make test}" >/dev/null 2>&1; then
report "gnubg de référence absent : lancer d'abord make setup"
elif ! web . node build/web/tests/tools/sgf-samples.js "$samples" >/dev/null; then
report "parties d'exemple SGF non produites (voir ci-dessus)"
else
output="$(docker run --rm --user "$(id -u):$(id -g)" -e HOME=/tmp \
-v "$PWD/$samples:/sgf:ro" -v "$PWD/tests/sgf:/check:ro" "$REFERENCE_IMAGE" \
gnubg --tty --quiet --no-rc --python=/check/check.py 2>&1)" || true
if ! grep -q '^SGF-RESULTAT OK$' <<<"$output"; then
grep -E '^(ÉCHEC|aucun|Traceback| File|[A-Za-z]*Error)' <<<"$output" || true
report "export SGF : gnubg ne retrouve pas les positions du journal"
fi
fi
web app depcruise --config .dependency-cruiser.cjs src tests \
|| report "règles d'architecture non respectées (voir ci-dessus)"