diff --git a/Makefile b/Makefile index c5bfbf8..2252bc3 100644 --- a/Makefile +++ b/Makefile @@ -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. diff --git a/app/src/domain/game/branches.ts b/app/src/domain/game/branches.ts new file mode 100644 index 0000000..c3ac8e8 --- /dev/null +++ b/app/src/domain/game/branches.ts @@ -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(); + 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); +} diff --git a/app/src/domain/game/game.ts b/app/src/domain/game/game.ts index fa40436..40f5752 100644 --- a/app/src/domain/game/game.ts +++ b/app/src/domain/game/game.ts @@ -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); diff --git a/app/src/domain/game/saved-game.ts b/app/src/domain/game/saved-game.ts new file mode 100644 index 0000000..793060c --- /dev/null +++ b/app/src/domain/game/saved-game.ts @@ -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, + }; +} diff --git a/app/src/domain/game/sgf.ts b/app/src/domain/game/sgf.ts new file mode 100644 index 0000000..215596f --- /dev/null +++ b/app/src/domain/game/sgf.ts @@ -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 { + 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(); + 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( + (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, + 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, +): Promise { + 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}`); +} diff --git a/app/src/main.ts b/app/src/main.ts index d71f8df..f41cfd6 100644 --- a/app/src/main.ts +++ b/app/src/main.ts @@ -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(get: () => T): T | undefined { + try { + return get(); + } catch { + return undefined; + } +} diff --git a/app/src/storage/game-library.ts b/app/src/storage/game-library.ts new file mode 100644 index 0000000..2bdb6dd --- /dev/null +++ b/app/src/storage/game-library.ts @@ -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; + /** Une partie, ou null si elle n'existe pas ou est illisible. */ + load(id: string): Promise; + /** 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; + /** Supprime une partie (rien si elle n'existe pas). */ + remove(id: string): Promise; + /** 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; + /** Fait d'une partie enregistrée la partie en cours, sans la modifier + * (pour la reprendre). */ + setCurrent(id: string): Promise; +} + +/** Ce que chaque stockage sait faire ; GameLibrary ajoute l'ordre des + * opérations, les dates et la validation. */ +export interface LibraryBackend { + summaries(): Promise; + summary(id: string): Promise; + journal(id: string): Promise; + /** Écrit le résumé et le journal ensemble. */ + write(summary: SavedGameSummary, journal: GameJournal): Promise; + remove(id: string): Promise; + currentId(): Promise; + setCurrentId(id: string): Promise; +} + +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 = Promise.resolve(); + /** Exécute op après toutes les opérations déjà demandées. */ + const enqueue = (op: () => Promise): Promise => { + const result = queue.then(op, op); + queue = result.catch(() => {}); + return result; + }; + + const load = async (id: string): Promise => { + 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); + }), + }; +} diff --git a/app/src/storage/game-store.ts b/app/src/storage/game-store.ts deleted file mode 100644 index d46c5c9..0000000 --- a/app/src/storage/game-store.ts +++ /dev/null @@ -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. - } - }, - }; -} diff --git a/app/src/storage/indexeddb-backend.ts b/app/src/storage/indexeddb-backend.ts new file mode 100644 index 0000000..1821c8a --- /dev/null +++ b/app/src/storage/indexeddb-backend.ts @@ -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(request: IDBRequest): Promise { + 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 { + 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 { + 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 (store: string, key: string): Promise => + done(db.transaction(store).objectStore(store).get(key)); + + return { + summaries: async () => done(db.transaction(GAMES).objectStore(GAMES).getAll()), + summary: (id) => get(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); + }, + }; +} diff --git a/app/src/storage/key-value-backend.ts b/app/src/storage/key-value-backend.ts new file mode 100644 index 0000000..1a06e01 --- /dev/null +++ b/app/src/storage/key-value-backend.ts @@ -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 { + 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; +} diff --git a/app/src/storage/open-library.ts b/app/src/storage/open-library.ts new file mode 100644 index 0000000..98e7c88 --- /dev/null +++ b/app/src/storage/open-library.ts @@ -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 { + 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 { + if (storage.indexedDB !== undefined) { + let timer: ReturnType | undefined; + try { + const backend = await Promise.race([ + openIndexedDbBackend(storage.indexedDB), + new Promise((_, 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(); + return { + getItem: (key) => items.get(key) ?? null, + setItem: (key, value) => void items.set(key, value), + removeItem: (key) => void items.delete(key), + }; +} diff --git a/app/src/ui/games-texts.ts b/app/src/ui/games-texts.ts new file mode 100644 index 0000000..d467f0c --- /dev/null +++ b/app/src/ui/games-texts.ts @@ -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 = { + 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)", +}; diff --git a/app/src/ui/games-view.ts b/app/src/ui/games-view.ts new file mode 100644 index 0000000..63dd3cf --- /dev/null +++ b/app/src/ui/games-view.ts @@ -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 () 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; + /** Le journal d'une partie (null si elle a disparu). */ + readonly journal: (id: string) => Promise; + /** 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; + readonly remove: (id: string) => Promise; + /** 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; +} + +/** 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) => async () => { + message.textContent = ""; + try { + await action(); + } catch (error) { + message.textContent = GAMES_TEXTS.failed( + error instanceof Error ? error.message : String(error), + ); + } + }; + + async function render(): Promise { + 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 { + 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( + 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); +} diff --git a/app/tests/branches-sgf.test.ts b/app/tests/branches-sgf.test.ts new file mode 100644 index 0000000..865cc60 --- /dev/null +++ b/app/tests/branches-sgf.test.ts @@ -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) => + 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/); +}); diff --git a/app/tests/storage.test.ts b/app/tests/storage.test.ts new file mode 100644 index 0000000..01ee54f --- /dev/null +++ b/app/tests/storage.test.ts @@ -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}$/); +}); diff --git a/app/tests/tools/sgf-samples.ts b/app/tests/tools/sgf-samples.ts new file mode 100644 index 0000000..eedc50f --- /dev/null +++ b/app/tests/tools/sgf-samples.ts @@ -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 +// +// 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 +// .sgf (la partie exportée, cette branche en premier : c'est celle que +// gnubg lit) et .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 { + 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 { + 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 { + 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 { + const out = process.argv[2]; + if (out === undefined) { + throw new Error("usage : sgf-samples.js "); + } + 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(); diff --git a/app/tests/ui.test.ts b/app/tests/ui.test.ts index ff72201..4881886 100644 --- a/app/tests/ui.test.ts +++ b/app/tests/ui.test.ts @@ -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(); - 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( diff --git a/tests/sgf/check.py b/tests/sgf/check.py new file mode 100644 index 0000000..9b6aff5 --- /dev/null +++ b/tests/sgf/check.py @@ -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, .sgf et .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")) diff --git a/tests/test_web.sh b/tests/test_web.sh index 023d9cc..c44f04d 100644 --- a/tests/test_web.sh +++ b/tests/test_web.sh @@ -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)"