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..b945ffb --- /dev/null +++ b/app/src/domain/game/sgf.ts @@ -0,0 +1,226 @@ +// 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. (Le format : 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 c7fb997..3aef280 100644 --- a/app/src/main.ts +++ b/app/src/main.ts @@ -6,9 +6,11 @@ // Licence : GPLv3 ou ultérieure (voir LICENSE). 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 { createGameSession } from "./storage/game-session.js"; +import { memoryStorage, openGameLibrary } from "./storage/open-library.js"; import { createSettingsStore } from "./storage/settings-store.js"; import { startApp } from "./ui/app.js"; @@ -16,25 +18,72 @@ import { startApp } from "./ui/app.js"; // racine du site. const worker = startEngineWorker(new URL("../engine-worker.mjs", import.meta.url)); const engine = createWasmEngine(worker.run); -const store = createGameStore(window.localStorage); -const settings = createSettingsStore(window.localStorage); + +// Chaque accès au stockage du navigateur peut lever une exception (stockage +// interdit) : on fait alors sans. +const localStorage = attempt(() => window.localStorage); +const settings = createSettingsStore(localStorage ?? memoryStorage()); + +// Les parties enregistrées (IndexedDB, ou localStorage en repli), ouvertes +// pendant que le moteur se charge : le plateau est dessiné sans les +// attendre. L'écran de jeu ne s'en sert qu'une fois engineReady résolue. +const opening = (async () => { + const { library } = await openGameLibrary({ + indexedDB: attempt(() => window.indexedDB), + localStorage, + }); + // Le niveau de l'ordinateur : celui de Game.start (STANDARD_PLY). + const session = createGameSession( + library, + { start: () => Game.start(engine), resume: (journal) => Game.resume(engine, journal) }, + "2ply", + ); + const saved = await session.restore(); + return { library, session, saved }; +})(); +let stored: Awaited | null = null; +/** Le stockage, ouvert (toujours le cas une fois engineReady résolue). */ +function storage(): Awaited { + if (stored === null) { + throw new Error("stockage pas encore ouvert"); + } + return stored; +} startApp({ - engineReady: worker.ready, - newGame: () => Game.start(engine), - savedGame: () => { - const journal = store.load(); - if (journal === null) { - return null; - } - try { - return Game.resume(engine, journal); - } catch { - return null; // partie illisible : on en commence une nouvelle - } + engineReady: Promise.all([ + worker.ready, + opening.then((opened) => { + stored = opened; + }), + ]), + newGame: () => storage().session.newGame(), + savedGame: () => storage().saved, + resumePrevious: () => storage().session.resumePrevious(), + saveGame: (journal) => storage().session.save(journal), + games: { + list: () => storage().library.list(), + journal: async (id) => (await storage().library.load(id))?.journal ?? null, + shownId: () => storage().session.shownId, + open: (id, branchHead) => storage().session.open(id, branchHead), + remove: (id) => storage().library.remove(id), + exportSgf: async (id) => { + const game = await storage().library.load(id); + if (game === null) { + throw new Error("partie introuvable"); + } + const started = new Date(game.summary.startedAt); + const two = (n: number) => String(n).padStart(2, "0"); + const date = `${started.getFullYear()}-${two(started.getMonth() + 1)}-${two(started.getDate())}`; + const text = await exportSgf(engine, game.journal, { + playerName: "Vous", + computerName: "Ustagammon", + date, + }); + const time = `${two(started.getHours())}${two(started.getMinutes())}`; + return { fileName: `ustagammon-${date}-${time}.sgf`, text }; + }, }, - resumeGame: (journal) => Game.resume(engine, journal), - saveGame: (journal) => store.save(journal), cachedBoard: () => settings.loadBoard(), saveBoard: (board) => settings.saveBoard(board), loadSettings: () => settings.loadSettings(), @@ -52,3 +101,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-session.ts b/app/src/storage/game-session.ts new file mode 100644 index 0000000..26b6c4f --- /dev/null +++ b/app/src/storage/game-session.ts @@ -0,0 +1,130 @@ +// La partie à l'écran et son enregistrement : quel identifiant elle porte, +// la nouvelle partie qu'on peut annuler (« Reprendre l'ancienne »), la +// reprise d'une partie enregistrée ou de l'une de ses branches. +// +// Copyright (C) 2026 les auteurs d'Ustagammon. +// Licence : GPLv3 ou ultérieure (voir LICENSE). +// +// main.ts la branche sur l'écran de jeu ; les scénarios l'utilisent telle +// quelle, sur un stockage en mémoire. + +import { switchToBranch } from "../domain/game/branches.js"; +import type { Level } from "../domain/game/engine.js"; +import type { GameJournal } from "../domain/game/events.js"; +import type { Game } from "../domain/game/game.js"; +import { type GameLibrary, newGameId } from "./game-library.js"; + +/** Comment créer une partie : commencée, ou reprise d'un journal (erreur si + * le journal est illisible). */ +export interface GameFactory { + readonly start: () => Game; + readonly resume: (journal: GameJournal) => Game; +} + +export interface GameSession { + /** L'identifiant de la partie à l'écran (null avant la première). */ + readonly shownId: string | null; + /** La partie en cours enregistrée, reprise ; null s'il n'y en a pas ou + * si elle est illisible. */ + restore(): Promise; + /** Une nouvelle partie ; celle qu'elle remplace reste reprenable par + * resumePrevious, jusqu'au prochain changement de partie. */ + newGame(): Game; + /** Enregistre la partie à l'écran (sans attendre la fin de l'écriture ; + * un échec est ignoré : la partie continue). */ + save(journal: GameJournal): void; + /** « Reprendre l'ancienne » : la partie quittée par newGame, relue dans + * le stockage ; la nouvelle est supprimée. null s'il n'y en a pas. */ + resumePrevious(): Promise; + /** Reprend une partie enregistrée, au bout de la branche branchHead si + * elle est donnée. Erreur si elle n'existe pas ou est illisible. */ + open(id: string, branchHead?: string): Promise; +} + +export function createGameSession( + library: GameLibrary, + games: GameFactory, + level: Level, +): GameSession { + let shownId: string | null = null; + let previousId: string | null = null; + + /** Une partie enregistrée, reprise ; null si absente ou illisible. */ + const resume = async (id: string): Promise => { + const saved = await library.load(id); + if (saved === null) { + return null; + } + try { + return games.resume(saved.journal); + } catch { + return null; + } + }; + + return { + get shownId() { + return shownId; + }, + async restore() { + const saved = await library.current().catch(() => null); + if (saved === null) { + return null; + } + try { + const game = games.resume(saved.journal); + shownId = saved.summary.id; + return game; + } catch { + return null; // partie illisible : on en commencera une nouvelle + } + }, + newGame() { + previousId = shownId; + shownId = newGameId(); + return games.start(); + }, + save(journal) { + if (shownId !== null) { + library.save(shownId, journal, { level }).catch(() => {}); + } + }, + async resumePrevious() { + const id = previousId; + if (id === null) { + return null; + } + const resumed = await resume(id); + if (resumed === null) { + return null; + } + const abandoned = shownId; + previousId = null; + shownId = id; + await library.setCurrent(id); + if (abandoned !== null) { + await library.remove(abandoned); + } + return resumed; + }, + async open(id, branchHead) { + const saved = await library.load(id); + if (saved === null) { + throw new Error("partie introuvable"); + } + const journal = + branchHead === undefined + ? saved.journal + : switchToBranch(saved.journal, branchHead); + const game = games.resume(journal); + previousId = null; + shownId = id; + if (branchHead === undefined) { + await library.setCurrent(id); + } else { + await library.save(id, journal, { level: saved.summary.level }); + } + return game; + }, + }; +} 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/storage/settings-store.ts b/app/src/storage/settings-store.ts index 90dd369..c31afaf 100644 --- a/app/src/storage/settings-store.ts +++ b/app/src/storage/settings-store.ts @@ -8,7 +8,7 @@ import type { Cube } from "../domain/game/cube.js"; import type { SideBoards } from "../domain/game/game.js"; -import type { KeyValueStorage } from "./game-store.js"; +import type { KeyValueStorage } from "./key-value-backend.js"; const SETTINGS_KEY = "ustagammon.settings"; const BOARD_KEY = "ustagammon.last-board"; @@ -41,7 +41,9 @@ function isSide(value: unknown): boolean { ); } -export function createSettingsStore(storage: KeyValueStorage): SettingsStore { +export function createSettingsStore( + storage: Pick, +): SettingsStore { const write = (key: string, value: string) => { try { storage.setItem(key, value); diff --git a/app/src/ui/app.ts b/app/src/ui/app.ts index ff15d76..df692ae 100644 --- a/app/src/ui/app.ts +++ b/app/src/ui/app.ts @@ -19,6 +19,7 @@ import type { Cube } from "../domain/game/cube.js"; import type { GameJournal } from "../domain/game/events.js"; import type { Game, MoveSteps, SideBoards } from "../domain/game/game.js"; import { formatHalfMoves, type HalfMove } from "../domain/game/position.js"; +import type { SavedGameSummary } from "../domain/game/saved-game.js"; import { otherSide, type Side } from "../domain/game/side.js"; import { boardModel, @@ -39,6 +40,8 @@ import { type ShownDie, slideChecker, } from "./board-view.js"; +import { GAMES_TEXTS } from "./games-texts.js"; +import { createGamesView, type GamesView } from "./games-view.js"; import { boardGeometry, type Geometry, type Layout, PORTRAIT_ASPECT } from "./geometry.js"; import { createHaptics, type Vibrate } from "./haptics.js"; import { loadGlossaryEntry, showHelp } from "./help.js"; @@ -68,9 +71,12 @@ export interface AppOptions { readonly newGame: () => Game; /** La partie enregistrée, ou null s'il n'y en a pas. */ readonly savedGame: () => Game | null; - /** Une partie reprise depuis son journal (« Reprendre l'ancienne »). */ - readonly resumeGame: (journal: GameJournal) => Game; + /** « Reprendre l'ancienne » : la partie quittée par la dernière + * nouvelle partie (null s'il n'y en a plus). */ + readonly resumePrevious: () => Promise; readonly saveGame: (journal: GameJournal) => void; + /** Les parties enregistrées (« Mes parties »). */ + readonly games: SavedGamesAccess; /** Le dernier plateau affiché (null : jamais) et son enregistrement. */ readonly cachedBoard: () => CachedBoard | null; readonly saveBoard: (board: CachedBoard) => void; @@ -79,6 +85,20 @@ export interface AppOptions { readonly saveSettings: (json: string) => void; } +/** Ce que « Mes parties » demande au stockage. */ +export interface SavedGamesAccess { + /** Les parties, la dernière jouée d'abord. */ + readonly list: () => Promise; + readonly journal: (id: string) => Promise; + /** L'identifiant de la partie à l'écran. */ + readonly shownId: () => string | null; + /** Une partie reprise, au bout de la branche branchHead si elle est + * donnée ; elle devient la partie à l'écran. */ + readonly open: (id: string, branchHead?: string) => Promise; + readonly remove: (id: string) => Promise; + readonly exportSgf: (id: string) => Promise<{ fileName: string; text: string }>; +} + function byId(id: string): T { const element = document.getElementById(id); if (element === null) { @@ -154,6 +174,7 @@ export function startApp(options: AppOptions): void { menuTitle: byId("menu-title"), menuClose: byId("menu-close"), menuNewGame: byId("menu-new-game"), + menuGames: byId("menu-games"), menuFullscreen: byId("menu-fullscreen"), menuHelp: byId("menu-help"), menuTheme: byId("menu-theme"), @@ -229,6 +250,7 @@ export function startApp(options: AppOptions): void { ui.menuClose.append(icon("close")); ui.menuClose.setAttribute("aria-label", TEXTS.close); label(ui.menuNewGame, TEXTS.newGame, "newGame"); + label(ui.menuGames, GAMES_TEXTS.button, "games"); label(ui.menuHelp, TEXTS.helpAndGlossary, "help"); ui.menuTheme.prepend(icon("theme")); child(ui.menuTheme, ".item-label").textContent = TEXTS.theme; @@ -264,6 +286,8 @@ export function startApp(options: AppOptions): void { let announcedEnd: string | null = null; // « Nouvelle partie » : l'ancienne partie, reprenable pendant 10 s. let previous: GameJournal | null = null; + // « Mes parties », créé plus bas (il a besoin des actions de l'écran). + let gamesView: GamesView | null = null; let bannerTimer: ReturnType | null = null; // Plein écran demandé ; rotated : repli, l'écran tourné d'un quart de // tour par CSS, quand le paysage ne peut pas être verrouillé. @@ -423,6 +447,7 @@ export function startApp(options: AppOptions): void { ]) { button.disabled = busy; } + ui.menuGames.disabled = false; ui.cancel.disabled = busy || game.pendingHalfMoves.length === 0; renderPanel(game, landscape, doubled); renderMenu(); @@ -467,6 +492,7 @@ export function startApp(options: AppOptions): void { } ui.moveHint.hidden = true; ui.menuNewGame.disabled = true; + ui.menuGames.disabled = true; renderMenu(); } @@ -766,14 +792,40 @@ export function startApp(options: AppOptions): void { return; } hideBanner(); - void act(() => { - game = options.resumeGame(journal); - panelDismissed = false; - announcedEnd = null; - info = ""; + void act(async () => { + const old = await options.resumePrevious(); + if (old !== null) { + showGame(old); + } }); }; + /** Une autre partie à l'écran (reprise). */ + function showGame(other: Game): void { + game = other; + panelDismissed = false; + announcedEnd = null; + info = ""; + } + + // « Mes parties » : reprendre une partie (ou une branche) en change à + // l'écran, sans recharger la page. + gamesView = createGamesView({ + list: options.games.list, + journal: options.games.journal, + shownId: options.games.shownId, + resume: async (id, branchHead) => { + if (busy) { + throw new Error(GAMES_TEXTS.busy); + } + const other = await options.games.open(id, branchHead); + hideBanner(); + await act(() => showGame(other)); + }, + remove: options.games.remove, + exportSgf: options.games.exportSgf, + }); + /** Joue un demi-coup du joueur ; le coup attend ensuite sa suite ou sa * validation. Le pion qui se pose vibre (deux fois s'il frappe). */ function playHalfMove(halfMove: HalfMove): void { @@ -911,6 +963,7 @@ export function startApp(options: AppOptions): void { * jamais à la partie. */ function closeOverlays(): void { const open = !ui.menuVeil.hidden || !ui.doubleVeil.hidden; + gamesView?.close(); ui.menuVeil.hidden = true; ui.doubleVeil.hidden = true; bubbleSide = null; @@ -965,6 +1018,10 @@ export function startApp(options: AppOptions): void { ui.menuButton.onclick = actions.menu; ui.menuClose.onclick = () => closeOverlays(); ui.menuNewGame.onclick = () => newGame(); + ui.menuGames.onclick = () => { + closeOverlays(); + void gamesView?.open(); + }; ui.menuFullscreen.onclick = actions.fullscreen; ui.menuHelp.onclick = actions.help; ui.menuTheme.onclick = () => saveSettings({ theme: nextTheme(settings.theme) }); diff --git a/app/src/ui/games-texts.ts b/app/src/ui/games-texts.ts new file mode 100644 index 0000000..973f794 --- /dev/null +++ b/app/src/ui/games-texts.ts @@ -0,0 +1,66 @@ +// 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 pour de bon (branches comprises)", + shown: "partie affichée", + busy: "Attendez la fin du coup en cours.", + 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..20891c8 --- /dev/null +++ b/app/src/ui/games-view.ts @@ -0,0 +1,249 @@ +// « 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). +// +// Une feuille qui monte du bas, comme le menu, créée ici même : elle ne +// dépend pas de index.html. Comme tout l'écran, elle ne connaît ni le +// stockage ni le moteur : l'écran de jeu (app.ts) lui passe les opérations +// (GamesViewOptions), et change lui-même de partie quand on en reprend une. + +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"; +import { icon } from "./icons.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 }>; +} + +export interface GamesView { + /** Ouvre la liste, à jour. */ + open(): Promise; + close(): void; + readonly isOpen: boolean; +} + +/** Crée la feuille « Mes parties » (fermée) dans la page. */ +export function createGamesView(options: GamesViewOptions): GamesView { + const veil = element("div", "veil sheet-veil"); + veil.id = "games-veil"; + veil.hidden = true; + const sheet = element("div", "sheet games"); + sheet.id = "games"; + sheet.setAttribute("role", "dialog"); + sheet.setAttribute("aria-modal", "true"); + sheet.setAttribute("aria-labelledby", "games-title"); + const head = element("div", "sheet-head"); + const title = element("h2", "sheet-title"); + title.id = "games-title"; + title.textContent = GAMES_TEXTS.title; + const close = button("", () => hide(), "icon-button"); + close.id = "games-close"; + close.setAttribute("aria-label", GAMES_TEXTS.close); + close.append(icon("close")); + head.append(title, close); + const list = element("ul", "games-list"); + const message = element("p", "games-message"); + message.setAttribute("aria-live", "polite"); + sheet.append(head, message, list); + veil.append(sheet); + document.body.append(veil); + veil.addEventListener("click", (event) => { + if (event.target === veil) { + hide(); + } + }); + + function hide(): void { + veil.hidden = true; + } + + /** 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", "games-actions"); + actions.append( + button( + GAMES_TEXTS.resume, + run(async () => { + await options.resume(game.id); + hide(); + }), + "", + "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", + ), + // Supprimer se fait en deux touchers, sans boîte du navigateur : + // le premier change le bouton en demande de confirmation. + button( + GAMES_TEXTS.remove, + run(async () => { + const remove = item.querySelector('[data-action="remove"]'); + if (remove?.dataset.armed !== "true") { + if (remove !== null) { + remove.dataset.armed = "true"; + remove.textContent = GAMES_TEXTS.confirmRemove; + } + return; + } + await options.remove(game.id); + await render(); + }), + "secondary", + "remove", + ), + ); + // La partie à l'écran ne se supprime pas : elle continue d'être + // enregistrée. + const remove = actions.querySelector('[data-action="remove"]'); + if (remove !== null) { + remove.hidden = shown; + } + 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(async () => { + await options.resume(id, branch.head); + hide(); + }), + "secondary", + "resume-branch", + ), + ); + return item; + }), + ); + } + + return { + async open() { + message.textContent = ""; + await render(); + veil.hidden = false; + close.focus(); + }, + close: hide, + get isOpen() { + return !veil.hidden; + }, + }; +} + +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/src/ui/help.ts b/app/src/ui/help.ts index eae4edf..67301fb 100644 --- a/app/src/ui/help.ts +++ b/app/src/ui/help.ts @@ -17,6 +17,7 @@ import { TEXTS } from "./texts.js"; /** Les scénarios montrés dans l'aide, dans l'ordre. */ const FEATURES = [ "docs/scenarios/jouer-une-partie.feature", + "docs/scenarios/mes-parties.feature", "docs/scenarios/regler-l-application.feature", ]; const GLOSSARY = "docs/glossaire/"; diff --git a/app/src/ui/icons.ts b/app/src/ui/icons.ts index b6fd97a..4e748bf 100644 --- a/app/src/ui/icons.ts +++ b/app/src/ui/icons.ts @@ -18,6 +18,7 @@ export const ICON_PATHS = { chevron: "M9 6l6 6-6 6", copy: "M9 9h11v11H9zM5 15H4V4h11v1", newGame: "M12 5v14M5 12h14", + games: "M9 6h11M9 12h11M9 18h11M4 6h.01M4 12h.01M4 18h.01", vibrate: "M9 5h6v14H9zM5 8v8M19 8v8M2 10v4M22 10v4", theme: "M12 21a9 9 0 1 0 0-18zM12 3a9 9 0 0 1 0 18", screenOn: "M7 3h10v18H7zM11 18h2", 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/steps/mes-parties.steps.ts b/app/tests/steps/mes-parties.steps.ts new file mode 100644 index 0000000..27db67f --- /dev/null +++ b/app/tests/steps/mes-parties.steps.ts @@ -0,0 +1,203 @@ +// Étapes du scénario docs/scenarios/mes-parties.feature (ADR 0008) : elles +// pilotent le domaine, branché sur le vrai moteur WebAssembly, et le +// stockage des parties sur un localStorage simulé en mémoire. Fermer puis +// rouvrir l'application revient à rouvrir ce stockage et à reprendre la +// partie en cours, comme main.ts au démarrage. +// +// Copyright (C) 2026 les auteurs d'Ustagammon. +// Licence : GPLv3 ou ultérieure (voir LICENSE). + +import assert from "node:assert/strict"; +import { Before, Given, Then, When } from "@cucumber/cucumber"; +import type { Engine } from "../../src/domain/game/engine.js"; +import type { GameJournal, MovePlayed } from "../../src/domain/game/events.js"; +import { Game } from "../../src/domain/game/game.js"; +import { exportSgf } from "../../src/domain/game/sgf.js"; +import type { GameLibrary } from "../../src/storage/game-library.js"; +import type { KeyValueStorage } from "../../src/storage/key-value-backend.js"; +import { memoryStorage, openGameLibrary } from "../../src/storage/open-library.js"; +import { loadNodeEngine } from "../support/node-engine.js"; +import { playMoves, seededDice } from "../support/play.js"; + +let engine: Engine; +let storage: KeyValueStorage; +let library: GameLibrary; +let game: Game; +let shownId: string; +/** Les parties jouées, dans l'ordre : identifiant et journal laissé. */ +let played: { id: string; journal: GameJournal }[]; +/** La partie d'origine, avant « Rejouer d'ici ». */ +let original: { head: string; ids: string[] }; +/** Le coup du joueur d'où l'on rejoue. */ +let replayed: MovePlayed; +let sgf: string; +let nextSeed = 1; + +Before(async () => { + engine = await loadNodeEngine(); + storage = memoryStorage(); + library = (await openGameLibrary({ localStorage: storage })).library; + played = []; +}); + +/** Commence une partie, comme l'application : un nouvel identifiant. */ +function startGame(): void { + game = Game.start(engine, { dice: seededDice(nextSeed), ply: 0 }); + shownId = `partie-${nextSeed++}`; +} + +/** Joue des coups, en enregistrant après chacun comme l'application. */ +async function play(moves: number): Promise { + for (let i = 0; i < moves; i++) { + await playMoves(game, engine, 1); + await library.save(shownId, game.journal, { level: "0ply" }); + } +} + +async function reopen(): Promise { + library = (await openGameLibrary({ localStorage: storage })).library; + const saved = await library.current(); + assert.ok(saved !== null, "aucune partie en cours"); + game = Game.resume(engine, saved.journal, { ply: 0 }); + shownId = saved.summary.id; +} + +/** « Rejouer d'ici » : revient juste avant le deuxième coup du joueur (ses + * dés déjà lancés). Renvoie ce coup. */ +function goBackToSecondMove(): MovePlayed { + original = { head: game.journal.head as string, ids: game.events.map((e) => e.id) }; + const second = game.events.filter( + (e): e is MovePlayed => e.type === "MovePlayed" && e.side === "player", + )[1]; + assert.ok(second?.parent != null, "moins de deux coups du joueur"); + game.goBackTo(second.parent); + return second; +} + +/** Joue, avec les mêmes dés, un coup légal qui mène ailleurs que first. */ +async function playAnotherMove(first: MovePlayed): Promise { + const state = game.state; + assert.ok(state.roll !== null); + const moves = await engine.legalMoves(state.position, state.roll); + // Move.position est vue de celui qui joue, MovePlayed.position de son + // adversaire : on compare les demi-coups. + const same = (m: (typeof moves)[number]) => + m.halfMoves.length === first.halfMoves.length && + m.halfMoves.every((h, i) => h.from === first.halfMoves[i]?.from); + const move = moves.find((m) => !same(m)); + assert.ok(move !== undefined, "un seul coup possible"); + for (const halfMove of move.halfMoves) { + assert.ok(await game.playHalfMove(halfMove)); + } + await game.confirmMove(); + await library.save(shownId, game.journal, { level: "0ply" }); + const last = game.events.at(-1); + assert.ok(last?.type === "MovePlayed" && last.position !== first.position); +} + +// ------------------------------------------- Reprendre une partie parmi plusieurs + +Given("vous avez joué deux parties, l'une après l'autre", async () => { + for (const moves of [5, 3]) { + startGame(); + await play(moves); + played.push({ id: shownId, journal: game.journal }); + } +}); + +When("vous fermez l'application puis la rouvrez", reopen); + +Then("la dernière partie jouée est à l'écran", () => { + const last = played.at(-1); + assert.equal(shownId, last?.id); + assert.deepEqual(game.journal, last?.journal); +}); + +Then("« Mes parties » montre vos deux parties, la dernière jouée en premier", async () => { + const ids = (await library.list()).map((s) => s.id); + assert.deepEqual(ids, played.map((p) => p.id).reverse()); +}); + +When("vous reprenez la première partie", async () => { + await library.setCurrent(played[0]?.id as string); + await reopen(); +}); + +Then("elle est à l'écran, telle que vous l'aviez laissée", () => { + assert.equal(shownId, played[0]?.id); + assert.deepEqual(game.journal, played[0]?.journal); +}); + +// ------------------------------------------- Rejouer d'ici sans perdre l'original + +Given("vous avez joué plusieurs coups", async () => { + startGame(); + await play(8); +}); + +When("vous rejouez d'ici à partir de votre deuxième coup", () => { + // Le retour seul : le coup suivant ouvrira la branche. + replayed = goBackToSecondMove(); +}); + +When("vous choisissez un autre coup", async () => { + await playAnotherMove(replayed); +}); + +Then("la partie a deux branches", () => { + assert.equal(game.branches.length, 2); +}); + +Then("la partie d'origine est intacte", () => { + const origin = game.branches.find((b) => b.forkedFrom === null); + assert.equal(origin?.head, original.head); + const copy = Game.resume(engine, game.journal); + copy.switchBranch(original.head); + assert.deepEqual( + copy.events.map((e) => e.id), + original.ids, + ); +}); + +Then("vous êtes sur la nouvelle branche", () => { + const current = game.branches.find((b) => b.containsHead); + assert.ok(current !== undefined && current.forkedFrom !== null); + assert.equal(current.head, game.journal.head); +}); + +Then("vous pouvez revenir à la partie d'origine, au coup près", () => { + const branch = game.branches.find((b) => b.forkedFrom === null); + assert.ok(branch !== undefined); + game.switchBranch(branch.head); + assert.deepEqual( + game.events.map((e) => e.id), + original.ids, + ); +}); + +// ------------------------------------------------------------ Exporter une partie + +Given("vous avez rejoué d'ici une autre suite", async () => { + await playAnotherMove(goBackToSecondMove()); + await play(2); +}); + +When("vous exportez la partie", async () => { + sgf = await exportSgf(engine, game.journal, { + playerName: "Vous", + computerName: "Ustagammon", + }); +}); + +Then("vous obtenez un fichier SGF de GNU Backgammon", () => { + assert.ok(sgf.startsWith("(;FF[4]GM[6]"), sgf.slice(0, 40)); + assert.ok(sgf.trimEnd().endsWith(")")); +}); + +Then("il contient vos coups, et l'autre branche en variante", () => { + // Un nœud par coup joué, toutes branches comprises (B ou W, puis les + // dés), et deux variantes à l'embranchement. + const moves = game.journal.events.filter((e) => e.type === "MovePlayed").length; + assert.equal(sgf.match(/;[BW]\[\d\d/g)?.length, moves); + assert.equal(sgf.match(/\(\n;/g)?.length, 2); +}); diff --git a/app/tests/steps/regler-l-application.steps.ts b/app/tests/steps/regler-l-application.steps.ts index 0300825..d1c048e 100644 --- a/app/tests/steps/regler-l-application.steps.ts +++ b/app/tests/steps/regler-l-application.steps.ts @@ -13,7 +13,10 @@ import type { GameJournal } from "../../src/domain/game/events.js"; import { Game } from "../../src/domain/game/game.js"; import { parseHalfMoves, START_POSITION } from "../../src/domain/game/position.js"; import { fixedDice } from "../../src/domain/game/roll.js"; -import { createGameStore } from "../../src/storage/game-store.js"; +import { createGameLibrary, type GameLibrary } from "../../src/storage/game-library.js"; +import { createGameSession, type GameSession } from "../../src/storage/game-session.js"; +import { createKeyValueBackend } from "../../src/storage/key-value-backend.js"; +import { memoryStorage } from "../../src/storage/open-library.js"; import { BANNER_MS } from "../../src/ui/app.js"; import { createHaptics } from "../../src/ui/haptics.js"; import { nextTheme, parseSettings, type Settings } from "../../src/ui/settings.js"; @@ -34,30 +37,41 @@ BeforeAll(async () => { // -------------------------------------------------- Nouvelle partie annulable -// Comme dans l'application : chaque partie est enregistrée (localStorage, -// ici un stockage en mémoire), et l'ancienne est reprise depuis son journal. -const data = new Map(); -const store = createGameStore({ - getItem: (key) => data.get(key) ?? null, - setItem: (key, value) => void data.set(key, value), -}); +// Comme dans l'application : chaque partie est enregistrée par la session +// (ici sur un stockage en mémoire), et l'ancienne est relue dans le +// stockage par son identifiant. +let library: GameLibrary; +let session: GameSession; +let oldId: string | null; Given("une partie est en cours depuis quelques coups", async () => { - game = Game.start(engine, { dice: fixedDice([3, 1, 6, 4, 5, 2]) }); + library = createGameLibrary(createKeyValueBackend(memoryStorage())); + let dice = fixedDice([3, 1, 6, 4, 5, 2]); + session = createGameSession( + library, + { + start: () => Game.start(engine, { dice }), + resume: (journal) => Game.resume(engine, journal, { dice }), + }, + "2ply", + ); + game = session.newGame(); + dice = () => 1 + Math.floor(Math.random() * 6); for (const halfMove of parseHalfMoves("8/5 6/5")) { assert.ok(await game.playHalfMove(halfMove)); } await game.confirmMove(); await game.playComputerTurn(); - store.save(game.journal); + session.save(game.journal); + oldId = session.shownId; assert.equal(game.state.turn, "player"); }); When("vous commencez une nouvelle partie depuis le menu", async () => { oldJournal = game.journal; oldBoard = await game.displayedBoard(); - game = Game.start(engine); - store.save(game.journal); + game = session.newGame(); + session.save(game.journal); }); Then("une nouvelle partie commence aussitôt", () => { @@ -69,13 +83,21 @@ Then("l'ancienne partie peut être reprise pendant {int} secondes", (seconds: nu assert.equal(BANNER_MS, seconds * 1000); }); -When("vous reprenez l'ancienne partie", () => { - game = Game.resume(engine, oldJournal); - store.save(game.journal); +When("vous reprenez l'ancienne partie", async () => { + const old = await session.resumePrevious(); + assert.ok(old !== null); + game = old; + session.save(game.journal); }); Then("vous retrouvez exactement la partie quittée", async () => { - assert.deepEqual(store.load(), oldJournal); + assert.equal(session.shownId, oldId); + assert.deepEqual((await library.current())?.journal, oldJournal); + // La nouvelle partie, abandonnée, est supprimée. + assert.deepEqual( + (await library.list()).map((s) => s.id), + [oldId], + ); assert.deepEqual(game.journal, oldJournal); assert.deepEqual(await game.displayedBoard(), oldBoard); assert.equal(game.state.turn, "player"); diff --git a/app/tests/storage.test.ts b/app/tests/storage.test.ts new file mode 100644 index 0000000..35ab8d5 --- /dev/null +++ b/app/tests/storage.test.ts @@ -0,0 +1,267 @@ +// 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 { branches } from "../src/domain/game/branches.js"; +import type { Engine } from "../src/domain/game/engine.js"; +import { + appendEvent, + EMPTY_JOURNAL, + type GameJournal, + goBackTo, +} from "../src/domain/game/events.js"; +import { Game } from "../src/domain/game/game.js"; +import { START_POSITION } from "../src/domain/game/position.js"; +import { fixedDice } from "../src/domain/game/roll.js"; +import { createGameLibrary, isJournal, newGameId } from "../src/storage/game-library.js"; +import { createGameSession } from "../src/storage/game-session.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}$/); +}); + +test("session : « Reprendre l'ancienne » par l'identifiant, et reprise d'une branche", async () => { + const games = library(); + // Game.start et Game.resume n'interrogent pas le moteur. + const engine = {} as Engine; + const session = createGameSession( + games, + { + start: () => Game.start(engine, { dice: fixedDice([5, 2]) }), + resume: (j) => Game.resume(engine, j), + }, + "2ply", + ); + assert.equal(await session.restore(), null); + assert.equal(await session.resumePrevious(), null); + + const first = session.newGame(); + const firstId = session.shownId as string; + session.save(first.journal); + const second = session.newGame(); + session.save(second.journal); + assert.notEqual(session.shownId, firstId); + + // L'ancienne revient, relue dans le stockage ; la nouvelle disparaît. + const back = await session.resumePrevious(); + assert.deepEqual(back?.journal, first.journal); + assert.equal(session.shownId, firstId); + assert.deepEqual( + (await games.list()).map((s) => s.id), + [firstId], + ); + assert.equal(await session.resumePrevious(), null); + + // Une branche reprise devient la ligne courante enregistrée. + let j = goBackTo(first.journal, "1"); + j = appendEvent(j, { type: "DiceRolled", side: null, roll: [6, 1] }); + await games.save(firstId, j, { level: "2ply" }); + const original = branches(j).find((b) => b.forkedFrom === null); + assert.ok(original !== undefined); + const opened = await session.open(firstId, original.head); + assert.equal(opened.journal.head, original.head); + assert.equal((await games.current())?.journal.head, original.head); + await assert.rejects(session.open("inconnue"), /introuvable/); +}); diff --git a/app/tests/support/play.ts b/app/tests/support/play.ts new file mode 100644 index 0000000..d938774 --- /dev/null +++ b/app/tests/support/play.ts @@ -0,0 +1,86 @@ +// Jouer une partie du domaine sans interface, pour les tests, les +// scénarios et les parties d'exemple : les deux camps, avec des dés +// reproductibles. +// +// Copyright (C) 2026 les auteurs d'Ustagammon. +// Licence : GPLv3 ou ultérieure (voir LICENSE). + +import type { Engine } from "../../src/domain/game/engine.js"; +import type { Game } from "../../src/domain/game/game.js"; +import type { DiceSource } from "../../src/domain/game/roll.js"; + +/** Des dés pseudo-aléatoires reproductibles (générateur congruentiel). */ +export 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. + */ +export 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}`); + } +} + +/** Joue jusqu'à count coups (des deux camps), ou jusqu'à la fin. */ +export 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; + } +} + +export 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 }); + } +} diff --git a/app/tests/tools/sgf-samples.ts b/app/tests/tools/sgf-samples.ts new file mode 100644 index 0000000..dd8321b --- /dev/null +++ b/app/tests/tools/sgf-samples.ts @@ -0,0 +1,139 @@ +// 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 { Game } from "../../src/domain/game/game.js"; +import { exportSgf } from "../../src/domain/game/sgf.js"; +import { loadNodeEngine } from "../support/node-engine.js"; +import { playMoves, playToTheEnd, seededDice, step } from "../support/play.js"; + +/** 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 9afc9aa..6b01298 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.endTitle("computer", "gammon", false), "L'ordinateur gagne un gammon"); assert.equal(TEXTS.endDetail("computer", 8, 4), "8 points pour lui, videau à 4."); diff --git a/docs/adr/0007-architecture-et-conventions-de-code.md b/docs/adr/0007-architecture-et-conventions-de-code.md index 44dcadb..dba67cc 100644 --- a/docs/adr/0007-architecture-et-conventions-de-code.md +++ b/docs/adr/0007-architecture-et-conventions-de-code.md @@ -94,3 +94,15 @@ les justifie pas. dépendances seront fixés avec le socle, à la fin de l'étape 0. - Les sous-agents reçoivent ces règles dans leurs consignes ; `CLAUDE.md` y renvoie. + +## Amendements + +### 2026-11-03 — Le stockage des parties est un format durable + +Avec les parties enregistrées (étape 1b), le schéma IndexedDB (base +« ustagammon », version 1 : magasins `games`, `journals`, `settings`) et les +clés `localStorage` de l'application (`ustagammon.settings`, +`ustagammon.last-board`, et le repli des parties) rejoignent le journal +d'événements parmi les formats durables : toute modification passe par une +montée de version avec migration testée, jamais par une réécriture qui +perdrait les parties des joueurs. diff --git a/docs/etape-1.md b/docs/etape-1.md index 34e5c0d..8f669f3 100644 --- a/docs/etape-1.md +++ b/docs/etape-1.md @@ -18,7 +18,7 @@ Repris du cadrage (modules, écrans clés, IA générative en V1) et des ADR 001 | Sous-étape | Contenu | Dépend de | | --- | --- | --- | | 1a. Moteur pour l'analyse (fait) | Niveaux de jeu (plis et bruit de GNU Backgammon : débutant à expert), classement des coups d'un jet avec équités, analyse d'un coup joué et d'une décision de videau, chance d'un jet ; exposés par `engine.h`, `engine-cli` et le Worker, avec positions de référence comparées à gnubg de bureau | — | -| 1b. Parties enregistrées | Stockage IndexedDB derrière l'interface de stockage existante : plusieurs parties, reprise automatique, branches (« Rejouer d'ici » crée une branche, l'originale reste intacte et rechargeable), export SGF avec variantes | — | +| 1b. Parties enregistrées (fait) | Stockage IndexedDB derrière l'interface de stockage existante : plusieurs parties, reprise automatique, branches (« Rejouer d'ici » crée une branche, l'originale reste intacte et rechargeable), export SGF avec variantes | — | | 1c. Match et réglages | Partie libre ou match en N points, règle Crawford, règle Jacoby, choix du niveau de l'ordinateur, conseil du maître activable ou non ; écran de nouvelle partie | 1a, 1b | | 1d. Conseil du maître | Après chaque coup du joueur, jamais avant (ADR 0011) : appréciation du moteur (bon coup, imprécision, erreur, gaffe) avec mot, icône et couleur, phrase du maître fondée sur les chiffres du moteur, coup recommandé montré sur demande ; aussi pour les décisions de videau ; calcul en arrière-plan sans retarder le jeu | 1a | | 1e. Fin de partie et analyse | Bilan (ADR 0011) : résultat, coups par appréciation, taux d'erreur comparé à la chance, « le coup à revoir » ; écran d'analyse : graphe d'équité (toucher un point ouvre la position), liste des erreurs filtrable, visualiseur pas à pas coup joué contre meilleur coup, « Rejouer d'ici » | 1a, 1b, 1d | diff --git a/docs/glossaire/README.md b/docs/glossaire/README.md index f5e6a05..2036fc7 100644 --- a/docs/glossaire/README.md +++ b/docs/glossaire/README.md @@ -56,6 +56,7 @@ exportée. `make test` vérifie le lien dans les deux sens : chaque fiche avec | [Moteur](moteur.md) | Engine | | [Niveau de jeu](niveau-de-jeu.md) | Level | | [Partie](partie.md) | Game | +| [Partie enregistrée](partie-enregistree.md) | Saved game | | [Passer](passer.md) | Drop | | [Pion](pion.md) | Checker | | [Plateau](plateau.md) | Board | @@ -64,5 +65,6 @@ exportée. `make test` vérifie le lien dans les deux sens : chaque fiche avec | [Prendre](prendre.md) | Take | | [Règle de Crawford](regle-de-crawford.md) | Crawford rule | | [Règle de Jacoby](regle-de-jacoby.md) | Jacoby rule | +| [SGF](sgf.md) | SGF | | [Sortie](sortie.md) | Bear-off | | [Videau](videau.md) | Cube | diff --git a/docs/glossaire/branche.md b/docs/glossaire/branche.md index 11f0e33..0a2ecab 100644 --- a/docs/glossaire/branche.md +++ b/docs/glossaire/branche.md @@ -1,9 +1,14 @@ --- term: Branche english: Branch +code: Branch --- # Branche **En bref** : suite de coups rejouée à partir d'un moment d'une partie (« Rejouer d'ici ») ; la partie d'origine reste intacte. -Voir aussi : [Événement de partie](evenement-de-partie.md) +Une partie garde toutes ses branches : vous pouvez revenir à l'une ou à +l'autre depuis « Mes parties », et l'export SGF les contient toutes, en +variantes. + +Voir aussi : [Événement de partie](evenement-de-partie.md), [Partie enregistrée](partie-enregistree.md) diff --git a/docs/glossaire/partie-enregistree.md b/docs/glossaire/partie-enregistree.md new file mode 100644 index 0000000..d87f995 --- /dev/null +++ b/docs/glossaire/partie-enregistree.md @@ -0,0 +1,14 @@ +--- +term: Partie enregistrée +english: Saved game +code: SavedGameSummary +--- +# Partie enregistrée + +**En bref** : une partie gardée sur votre téléphone, finie ou en cours, avec toutes ses branches ; vous la retrouvez dans « Mes parties ». + +Chaque partie est enregistrée après chacune de vos actions : fermer +l'application ne perd rien. Rien ne quitte le téléphone, sauf si vous +exportez une partie. + +Voir aussi : [Partie](partie.md), [Branche](branche.md), [SGF](sgf.md) diff --git a/docs/glossaire/sgf.md b/docs/glossaire/sgf.md new file mode 100644 index 0000000..9910a8d --- /dev/null +++ b/docs/glossaire/sgf.md @@ -0,0 +1,13 @@ +--- +term: SGF +english: SGF +--- +# SGF + +**En bref** : format de fichier des parties de GNU Backgammon ; une partie exportée en SGF s'ouvre dans GNU Backgammon pour l'analyser. + +SGF (Smart Game Format) est un format commun à plusieurs jeux. Les branches +d'une partie y deviennent des variantes ; GNU Backgammon ouvre la branche +courante. + +Voir aussi : [Partie enregistrée](partie-enregistree.md), [Moteur](moteur.md) diff --git a/docs/plans/2026-11-03-parties-enregistrees.md b/docs/plans/2026-11-03-parties-enregistrees.md deleted file mode 100644 index 8afba18..0000000 --- a/docs/plans/2026-11-03-parties-enregistrees.md +++ /dev/null @@ -1,57 +0,0 @@ -# Parties enregistrées, branches et export SGF (1b) - -- **Date** : 2026-11-03 -- **Étape** : 1b (`docs/etape-1.md`) -- **Statut** : prévu (lancé après la fusion de 0e.1) -- **Exécutant** : sous-agent general-purpose, dans un worktree - -## Objectif - -Garder toutes les parties du joueur sur le téléphone, avec leurs branches, et -les exporter au format SGF de GNU Backgammon. - -## Consignes - -ADR 0007 (domaine pur, stockage derrière une interface), ADR 0008 (scénarios -et glossaire). Aucune nouvelle dépendance : l'API IndexedDB du navigateur -directement. - -1. **Stockage IndexedDB** derrière l'interface de `app/src/storage/` : une - liste de parties (identifiant, date de début et de dernière action, - adversaire et niveau, score, état en cours ou finie, résultat) et le - journal d'événements de chacune. Migration transparente de la partie - en cours enregistrée aujourd'hui dans `localStorage`. Si IndexedDB est - indisponible (navigation privée), repli sur le comportement actuel. -2. **Branches** : le journal est déjà un arbre (événements avec parent, - `goBackTo`). Exposer dans le domaine la liste des branches d'une partie - (feuilles de l'arbre, avec le coup d'origine) et le passage de l'une à - l'autre. « Rejouer d'ici » depuis un coup passé crée une branche ; la - branche d'origine reste intacte et rechargeable. Pas d'écran d'analyse ici - (1e) : un accès minimal suffit pour le test (menu « Mes parties » : - reprendre une partie, voir ses branches). -3. **Nouvelle partie annulable** : l'arbitrage UX (plan - `2026-11-03-revision-ux.md`, point 3) prévoit « Reprendre l'ancienne » - pendant 10 s. Fournir l'API de stockage qui le permet (reprendre une partie - par son identifiant) ; l'interface viendra avec la révision UX. -4. **Export SGF** : une partie (et ses branches en variantes) au format SGF - de GNU Backgammon, téléchargeable depuis « Mes parties ». Vérification par - le gnubg de bureau de référence (Docker existant) : le fichier exporté se - charge sans erreur et ses positions successives sont les mêmes que celles - du journal, sur des parties générées par les tests. -5. Scénarios Gherkin : reprendre une partie parmi plusieurs, rejouer d'ici - sans perdre l'original, exporter une partie. Fiches du glossaire pour les - nouveaux termes (branche, SGF…). - -`make lint`, `make test` et `make test-browser` passent (ajouter au test -navigateur : deux parties enregistrées, reprise de la première après -rechargement). Commits fréquents ; pas de push ni de MR. - -## Critères de réussite - -- Après rechargement, la liste des parties et chaque branche sont intactes. -- Un SGF exporté se charge dans gnubg de bureau avec les mêmes positions. -- Les trois commandes de test passent. - -## Résultat - -À remplir à la clôture. diff --git a/docs/plans/2026-11-03-position-dans-l-adresse.md b/docs/plans/2026-11-03-position-dans-l-adresse.md index 5c79e45..53e7431 100644 --- a/docs/plans/2026-11-03-position-dans-l-adresse.md +++ b/docs/plans/2026-11-03-position-dans-l-adresse.md @@ -2,7 +2,7 @@ - **Date** : 2026-11-03 - **Étape** : 0e.4 (feuille de route) -- **Statut** : prévu (après la fusion de la révision UX et de 1b) +- **Statut** : prévu - **Exécutant** : sous-agent general-purpose, dans un worktree ## Objectif diff --git a/docs/scenarios/README.md b/docs/scenarios/README.md index 4b09d1c..56305f2 100644 --- a/docs/scenarios/README.md +++ b/docs/scenarios/README.md @@ -47,4 +47,5 @@ Règles d'écriture : | Fichier | Tâche | Depuis | | --- | --- | --- | | [jouer-une-partie.feature](jouer-une-partie.feature) | Jouer une partie contre l'ordinateur | Étape 0d | +| [mes-parties.feature](mes-parties.feature) | Retrouver mes parties, leurs branches, les exporter en SGF | Étape 1b | | [regler-l-application.feature](regler-l-application.feature) | Régler l'application (menu) | Étape 0e | diff --git a/docs/scenarios/mes-parties.feature b/docs/scenarios/mes-parties.feature new file mode 100644 index 0000000..b353647 --- /dev/null +++ b/docs/scenarios/mes-parties.feature @@ -0,0 +1,36 @@ +# language: fr +Fonctionnalité: Retrouver mes parties + + Toutes vos parties restent sur votre téléphone, finies ou en cours, avec + leurs branches : chacune est enregistrée après chacune de vos actions, et + fermer l'application ne perd rien. Dans « Mes parties », vous en reprenez + une, vous passez d'une branche à l'autre, ou vous l'exportez au format SGF + pour l'ouvrir dans GNU Backgammon. + + Scénario: Reprendre une partie parmi plusieurs + Étant donné que vous avez joué deux parties, l'une après l'autre + Quand vous fermez l'application puis la rouvrez + Alors la dernière partie jouée est à l'écran + Et « Mes parties » montre vos deux parties, la dernière jouée en premier + Quand vous reprenez la première partie + Alors elle est à l'écran, telle que vous l'aviez laissée + + Scénario: Rejouer d'ici sans perdre l'original + Revenez à un moment passé de la partie et jouez autre chose : une + nouvelle branche commence là, et la partie d'origine reste entière. + + Étant donné que vous avez joué plusieurs coups + Quand vous rejouez d'ici à partir de votre deuxième coup + Et que vous choisissez un autre coup + Alors la partie a deux branches + Et la partie d'origine est intacte + Quand vous fermez l'application puis la rouvrez + Alors vous êtes sur la nouvelle branche + Et vous pouvez revenir à la partie d'origine, au coup près + + Scénario: Exporter une partie + Étant donné que vous avez joué plusieurs coups + Et que vous avez rejoué d'ici une autre suite + Quand vous exportez la partie + Alors vous obtenez un fichier SGF de GNU Backgammon + Et il contient vos coups, et l'autre branche en variante diff --git a/tests/browser/play.mjs b/tests/browser/play.mjs index c5d8852..f0973ea 100644 --- a/tests/browser/play.mjs +++ b/tests/browser/play.mjs @@ -19,7 +19,7 @@ // premier problème, ou si la page signale une erreur (dont une violation de // la politique de sécurité du contenu). -import { mkdirSync } from "node:fs"; +import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { chromium, firefox } from "playwright"; const [base, browserName, shots] = process.argv.slice(2); @@ -124,9 +124,69 @@ function check(condition, message) { } } -/** Les coups joués de chaque camp, d'après le journal enregistré. */ +/** Le journal de la partie en cours, tel qu'enregistré dans IndexedDB + * (app/src/storage/indexeddb-backend.ts), ou null. */ +async function readJournal() { + return page.evaluate( + () => + new Promise((resolve, reject) => { + const open = indexedDB.open("ustagammon"); + open.onerror = () => reject(open.error); + open.onsuccess = () => { + const db = open.result; + const tx = db.transaction(["settings", "journals"]); + const current = tx.objectStore("settings").get("current"); + current.onsuccess = () => { + const id = current.result?.value; + const journal = id === undefined ? null : tx.objectStore("journals").get(id); + if (journal === null) { + db.close(); + resolve(null); + return; + } + journal.onsuccess = () => { + db.close(); + resolve(journal.result?.journal ?? null); + }; + }; + }; + }), + ); +} + +/** Le nombre de parties enregistrées dans IndexedDB. */ +async function savedGameCount() { + await savedJournal(); // les enregistrements en cours terminés + return page.evaluate( + () => + new Promise((resolve, reject) => { + const open = indexedDB.open("ustagammon"); + open.onerror = () => reject(open.error); + open.onsuccess = () => { + const db = open.result; + const count = db.transaction("games").objectStore("games").count(); + count.onsuccess = () => { + db.close(); + resolve(count.result); + }; + }; + }), + ); +} + +/** Le journal enregistré, une fois les enregistrements en cours terminés + * (l'application enregistre sans attendre) : deux lectures identiques. */ async function savedJournal() { - return page.evaluate(() => JSON.parse(localStorage.getItem("ustagammon.current-game"))); + let last = await readJournal(); + for (let i = 0; i < 50; i++) { + await new Promise((resolve) => setTimeout(resolve, 100)); + const next = await readJournal(); + if (JSON.stringify(next) === JSON.stringify(last)) { + return next; + } + last = next; + } + throw new Error("le journal enregistré ne se stabilise pas"); } function countMoves(journal, side) { @@ -177,8 +237,7 @@ async function waitForPlayer() { return ( `${busy} ; « ${text("info")} » ; « ${text("prompt")} » ; moteur : ` + `${engine.sent} commandes, ${engine.received} réponses, dernière ` + - `${JSON.stringify(engine.last)} ; journal ` + - localStorage.getItem("ustagammon.current-game") + `${JSON.stringify(engine.last)}` ); }) .catch((error) => `page illisible (${error.message})`); @@ -345,8 +404,9 @@ async function checkView(width, height, when) { /** Ce qui doit survivre à une rotation : partie, sélection, dés. */ async function screenState() { - return page.evaluate(() => ({ - head: JSON.parse(localStorage.getItem("ustagammon.current-game"))?.head ?? null, + const head = (await savedJournal())?.head ?? null; + return page.evaluate((head) => ({ + head, selected: document.querySelector("#board .checker.selected") !== null, landing: [...document.querySelectorAll("#board .hit.can-land")] .map((e) => e.getAttribute("data-place")) @@ -357,7 +417,7 @@ async function screenState() { traces: document.querySelectorAll(".trace-arrow").length, menu: !document.getElementById("menu-veil").hidden, panel: !document.getElementById("panel-veil").hidden, - })); + }), head); } const recette = { views: 0, captures: 0, minNumber: Infinity, rotations: 0 }; @@ -1017,6 +1077,7 @@ for (let actions = 0; ; actions++) { check(await page.isVisible("#menu"), "M n'ouvre pas le menu"); await page.keyboard.press("Escape"); const pips = await page.textContent("#strip-player .count"); + const games = await savedGameCount(); const old = await newGameFromMenu(); await page.click("#resume-old"); await waitForPlayer(); @@ -1026,6 +1087,8 @@ for (let actions = 0; ; actions++) { check((await page.textContent("#strip-player .count")) === pips, "« Reprendre l'ancienne » : plateau différent"); check(!(await page.isVisible("#banner")), "le bandeau reste après la reprise"); + check((await savedGameCount()) === games, + "« Reprendre l'ancienne » : la nouvelle partie n'a pas été supprimée"); log(`nouvelle partie puis « Reprendre l'ancienne » : journal identique (${old.events.length} événements)`); } } @@ -1086,6 +1149,68 @@ await page.click("#panel-secondary"); check(!(await page.isVisible("#panel")) && (await page.isVisible("#again")), "« Revoir la partie » : panneau toujours là, ou pas de « Nouvelle partie »"); +step("mes parties"); +// Mes parties (IndexedDB), depuis le menu : une deuxième partie commencée, +// les deux listées, la première exportée en SGF puis reprise sans +// recharger la page. +{ + const finished = await savedJournal(); + await page.click("#again"); + await waitForPlayer(); + const second = await savedJournal(); + check(second.head !== finished.head || second.events.length !== finished.events.length, + "la nouvelle partie n'a pas été enregistrée"); + await openMenu(); + await page.click("#menu-games"); + await page.waitForSelector("#games-veil:not([hidden]) .games-item"); + check(!(await page.isVisible("#menu")), "le menu reste ouvert sous « Mes parties »"); + const items = page.locator("#games .games-item"); + // Toutes les parties enregistrées, la dernière jouée d'abord : la + // nouvelle, puis celle qui vient de finir. + const listed = await items.count(); + check(listed >= 2 && listed === (await savedGameCount()), `${listed} parties listées`); + check(!(await items.nth(0).locator('[data-action="remove"]').isVisible()), + "la partie à l'écran peut être supprimée"); + const oldest = items.nth(1); + await oldest.locator('[data-action="branches"]').click(); + await oldest.locator(".games-branch").first().waitFor(); + const branchCount = await oldest.locator(".games-branch").count(); + const [download] = await Promise.all([ + page.waitForEvent("download"), + oldest.locator('[data-action="export"]').click(), + ]); + const sgf = readFileSync(await download.path(), "utf8"); + writeFileSync(`${shots}/${browserName}-partie.sgf`, sgf); + check(sgf.startsWith("(;FF[4]GM[6]"), `export SGF inattendu : ${sgf.slice(0, 40)}`); + const moves = countMoves(finished, "player") + countMoves(finished, "computer"); + check((sgf.match(/;[BW]\[\d\d/g) ?? []).length >= moves, + "export SGF : moins de coups que dans le journal"); + await shot("4b-mes-parties"); + const loads = await page.evaluate(() => performance.getEntriesByType("navigation").length); + await oldest.locator('[data-action="resume"]').click(); + await page.waitForSelector("#games-veil", { state: "hidden", timeout: 20_000 }).catch(async () => { + const state = await page.evaluate(() => ({ + message: document.querySelector("#games .games-message")?.textContent, + busy: document.body.classList.contains("busy"), + info: document.getElementById("info")?.textContent, + panel: !document.getElementById("panel-veil").hidden, + })); + throw new Error(`reprise bloquée : ${JSON.stringify(state)}`); + }); + await page.waitForSelector("#panel"); + const resumed = await savedJournal(); + check(JSON.stringify(resumed) === JSON.stringify(finished), "la première partie n'a pas été reprise"); + const shown = `${await page.textContent("#panel-text")}, ${await page.textContent("#panel-detail")}`; + check(shown === result, `écran différent après la reprise : ${shown}`); + check((await page.evaluate(() => performance.getEntriesByType("navigation").length)) === loads, + "la page a été rechargée"); + await page.click("#panel-secondary"); + log( + `mes parties : ${listed} parties listées (${branchCount} branche(s) pour la première), ` + + `SGF exporté (${sgf.length} octets), première partie reprise sans rechargement`, + ); +} + step("aide"); // Aide : scénarios et glossaire lus dans les copies de docs/. await page.click("#help-button"); 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)" diff --git a/web/app.css b/web/app.css index 4a8b2e5..8b5c697 100644 --- a/web/app.css +++ b/web/app.css @@ -1202,6 +1202,47 @@ html.rotated .banner { position: absolute; } +/* ------------------------------------------------------- Mes parties */ + +/* La feuille « Mes parties » (app/src/ui/games-view.ts) : une partie par + * ligne, ses actions dessous, ses branches dépliables. */ +.games-list, +.games-branches { + margin: 0; + padding: 0; + list-style: none; +} + +.games-item { + padding: var(--space-2) 0 var(--space-4); + border-bottom: 1px solid var(--border); +} + +.games-item:last-child { + border-bottom: 0; +} + +.games-item p { + margin: 0 0 var(--space-2); + font-weight: 500; +} + +.games-actions { + display: flex; + flex-wrap: wrap; + gap: var(--space-2); +} + +.games-branch { + display: flex; + flex-wrap: wrap; + gap: var(--space-2); + align-items: center; + justify-content: space-between; + padding-top: var(--space-2); + color: var(--muted); +} + /* --------------------------------------------------------------- Aide */ .help { diff --git a/web/index.html b/web/index.html index ada1f9f..0005e3c 100644 --- a/web/index.html +++ b/web/index.html @@ -93,6 +93,7 @@ +