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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Pierre MARTIN
2026-11-03 15:29:01 +01:00
co-authored by Claude Opus 5.5
parent 08cb89735f
commit 274fbf6252
19 changed files with 1896 additions and 90 deletions
+91
View File
@@ -0,0 +1,91 @@
// Les branches d'une partie : les lignes de jeu de son journal.
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
//
// Le journal est un arbre (events.ts) : chaque événement pointe vers son
// parent, et « Rejouer d'ici » accroche un nouvel événement à un ancien. Une
// branche est le chemin du premier événement jusqu'à une feuille de l'arbre.
// Les identifiants des événements sont attribués dans l'ordre d'ajout : de
// deux enfants d'un même événement, le plus ancien a le plus petit indice
// dans le journal, et c'est lui qui continue la ligne d'origine.
import { currentLine, type GameEvent, type GameJournal, goBackTo } from "./events.js";
import { replay } from "./state.js";
/**
* Une branche de la partie : la suite d'événements du début jusqu'à head.
*
* Glossaire : docs/glossaire/branche.md
*/
export interface Branch {
/** Le dernier événement de la branche (une feuille de l'arbre). */
readonly head: string;
/** L'événement à partir duquel elle a été rejouée (« Rejouer d'ici ») ;
* null pour la ligne d'origine de la partie. */
readonly forkedFrom: string | null;
/** Les coups joués avant de rejouer (0 : depuis le début) ; 0 aussi pour
* la ligne d'origine. */
readonly forkMoveCount: number;
/** Les coups joués sur toute la branche, depuis le début. */
readonly moveCount: number;
/** La partie est finie au bout de cette branche. */
readonly over: boolean;
/** L'événement courant de la partie est sur cette branche. */
readonly containsHead: boolean;
}
/**
* Les branches de la partie, dans l'ordre de leur création (celui de leur
* premier événement propre) : la ligne d'origine d'abord. Liste vide pour un
* journal vide.
*/
export function branches(journal: GameJournal): Branch[] {
const order = new Map(journal.events.map((event, index) => [event.id, index]));
const children = new Map<string, GameEvent[]>();
for (const event of journal.events) {
if (event.parent !== null) {
children.set(event.parent, [...(children.get(event.parent) ?? []), event]);
}
}
const leaves = journal.events.filter((event) => !children.has(event.id));
return leaves
.map((leaf) => {
const line = currentLine({ events: journal.events, head: leaf.id });
// Le point de départ de la branche : le parent de l'événement le
// plus récent du chemin qui n'est pas le premier enfant de son
// parent (la ligne d'origine n'en a aucun).
let forkIndex = -1;
for (let i = line.length - 1; i > 0; i--) {
const event = line[i] as GameEvent;
const first = children.get(event.parent as string)?.[0];
if (first !== undefined && first.id !== event.id) {
forkIndex = i - 1;
break;
}
}
const moves = (events: readonly GameEvent[]) =>
events.filter((event) => event.type === "MovePlayed").length;
const fork = forkIndex >= 0 ? (line[forkIndex] as GameEvent) : null;
const start = line[forkIndex + 1] as GameEvent;
return {
created: order.get(start.id) ?? 0,
head: leaf.id,
forkedFrom: fork?.id ?? null,
forkMoveCount: fork === null ? 0 : moves(line.slice(0, forkIndex + 1)),
moveCount: moves(line),
over: replay(line).phase === "over",
containsHead: line.some((event) => event.id === journal.head),
};
})
.sort((a, b) => a.created - b.created)
.map(({ created: _, ...branch }) => branch);
}
/** Passe à une autre branche : la partie reprend au bout de celle-ci. */
export function switchToBranch(journal: GameJournal, head: string): GameJournal {
if (!branches(journal).some((branch) => branch.head === head)) {
throw new Error(`branche introuvable : ${head}`);
}
return goBackTo(journal, head);
}
+14
View File
@@ -8,6 +8,7 @@
// (state.ts). Les règles viennent toutes du moteur : coups légaux, coup de
// l'ordinateur, videau, fin de partie et points.
import { type Branch, branches, switchToBranch } from "./branches.js";
import { CENTERED_CUBE, type Cube } from "./cube.js";
import type { BoardSide, Engine, EngineCube, Ply } from "./engine.js";
import {
@@ -415,6 +416,19 @@ export class Game {
this.#journal = goBackTo(this.#journal, eventId);
}
/** Les branches de la partie (voir branches.ts), la ligne d'origine
* d'abord. */
get branches(): Branch[] {
return branches(this.#journal);
}
/** Passe à une autre branche, désignée par son dernier événement : la
* partie reprend au bout de celle-ci ; les autres restent intactes. */
switchBranch(head: string): void {
this.cancelHalfMoves();
this.#journal = switchToBranch(this.#journal, head);
}
/** Annule le dernier événement : la partie repart de son parent. */
undo(): void {
const last = this.events.at(-1);
+64
View File
@@ -0,0 +1,64 @@
// Une partie enregistrée, telle que la liste « Mes parties » la présente.
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
//
// Le journal reste la source (ADR 0007, point 3) : le résumé n'en est qu'une
// lecture, recalculée à chaque enregistrement, pour afficher la liste sans
// relire tous les journaux.
import { branches } from "./branches.js";
import type { Level } from "./engine.js";
import { currentLine, type GameJournal } from "./events.js";
import { type GameResult, replay } from "./state.js";
/**
* Le résumé d'une partie enregistrée.
*
* Glossaire : docs/glossaire/partie-enregistree.md
*/
export interface SavedGameSummary {
/** Identifiant de la partie, unique sur l'appareil. */
readonly id: string;
/** Début de la partie et dernière action, en millisecondes depuis 1970. */
readonly startedAt: number;
readonly updatedAt: number;
/** Niveau de l'ordinateur, l'adversaire. */
readonly level: Level;
/** En cours ou finie, sur la branche courante. */
readonly status: "playing" | "over";
/** Le résultat sur la branche courante (null si elle n'est pas finie). */
readonly result: GameResult | null;
/** Coups joués sur la branche courante. */
readonly moveCount: number;
/** Nombre de branches (1 : jamais rejouée). */
readonly branchCount: number;
}
/** Ce qu'il faut savoir d'une partie pour l'enregistrer, en plus de son
* journal. */
export interface SavedGameDetails {
readonly level: Level;
}
/** Le résumé d'un journal ; startedAt et updatedAt viennent de l'appelant
* (le domaine ne lit pas l'horloge). */
export function summarizeGame(
id: string,
journal: GameJournal,
details: SavedGameDetails,
times: { readonly startedAt: number; readonly updatedAt: number },
): SavedGameSummary {
const line = currentLine(journal);
const state = replay(line);
return {
id,
startedAt: times.startedAt,
updatedAt: times.updatedAt,
level: details.level,
status: state.phase === "over" ? "over" : "playing",
result: state.result,
moveCount: line.filter((event) => event.type === "MovePlayed").length,
branchCount: branches(journal).length,
};
}
+228
View File
@@ -0,0 +1,228 @@
// Export d'une partie au format SGF de GNU Backgammon.
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
//
// Le SGF (Smart Game Format, FF[4], GM[6] pour le backgammon) est le format
// de fichier de GNU Backgammon : une suite de nœuds, un par coup ou décision
// de videau, et des variantes entre parenthèses. Le journal étant un arbre,
// l'export en est un simple parcours (ADR 0007, point 3) : chaque branche
// devient une variante. GNU Backgammon ne lit que la première variante de
// chaque embranchement ; la branche courante de la partie vient donc en
// premier, pour que gnubg ouvre la partie telle que le joueur la voit.
//
// Conventions de gnubg (sgf.c, au commit figé par l'ADR 0005) :
// - le joueur est « B » (le joueur 1 de gnubg, l'humain), l'ordinateur « W » ;
// - un coup s'écrit [dés][départ arrivée]… ; une case c (1 à 24, vue de celui
// qui joue) s'écrit 'x' - (c - 1) pour B et 'a' + (c - 1) pour W ; la barre
// s'écrit 'y', la sortie 'z' ; un coup impossible n'a que ses dés ;
// - [double], [take], [drop] pour le videau ;
// - une position de départ autre que l'habituelle se pose par AE, AW, AB et
// PL, le videau par CV et CP.
// Aucune règle n'est jugée ici : les coups et positions viennent du journal,
// et une position de départ particulière est décodée par le moteur.
import type { Engine } from "./engine.js";
import { currentLine, type GameEvent, type GameJournal } from "./events.js";
import { BAR, type HalfMove, OFF, START_POSITION } from "./position.js";
import type { Side } from "./side.js";
import { replay } from "./state.js";
export interface SgfOptions {
/** Nom du joueur (PB). */
readonly playerName: string;
/** Nom de l'ordinateur (PW). */
readonly computerName: string;
/** Date de la partie, AAAA-MM-JJ (DT) ; absente si non fournie. */
readonly date?: string;
}
/** Un nœud SGF et les variantes qui le suivent. */
interface SgfNode {
readonly properties: string;
readonly children: SgfNode[];
}
/**
* La partie au format SGF de GNU Backgammon, branches comprises (en
* variantes, la branche courante d'abord). Le moteur ne sert qu'à décoder
* une position de départ particulière.
*
* Glossaire : docs/glossaire/sgf.md
*/
export async function exportSgf(
engine: Engine,
journal: GameJournal,
options: SgfOptions,
): Promise<string> {
const start = journal.events[0];
if (start?.type !== "GameStarted") {
throw new Error("journal de partie vide ou mal formé");
}
const byId = new Map(journal.events.map((event) => [event.id, event]));
const onHeadLine = new Set(currentLine(journal).map((event) => event.id));
const children = new Map<string, GameEvent[]>();
for (const event of journal.events) {
if (event.parent !== null) {
children.set(event.parent, [...(children.get(event.parent) ?? []), event]);
}
}
// La branche courante d'abord, puis les autres dans l'ordre du journal.
for (const list of children.values()) {
list.sort((a, b) => Number(onHeadLine.has(b.id)) - Number(onHeadLine.has(a.id)));
}
/** Les variantes qui suivent un événement. */
const variations = (id: string): SgfNode[] =>
(children.get(id) ?? []).flatMap((child) => {
const properties = nodeProperties(child, byId, children.has(child.id));
const below = variations(child.id);
return properties === null ? below : [{ properties, children: below }];
});
// La partie telle que la voit gnubg : la ligne courante.
const result = replay(currentLine(journal)).result;
const root = [
"FF[4]GM[6]CA[UTF-8]AP[Ustagammon]",
"MI[length:0][game:0][ws:0][bs:0]",
`PW[${escapeText(options.computerName)}]PB[${escapeText(options.playerName)}]`,
options.date === undefined ? "" : `DT[${escapeText(options.date)}]`,
result === null ? "" : `RE[${letter(result.winner)}+${result.points}]`,
].join("");
// Les nœuds d'installation, à la suite l'un de l'autre, puis la partie.
const setup = await setupProperties(engine, start);
const tree = setup.reduceRight<SgfNode[]>(
(below, properties) => [{ properties, children: below }],
variations(start.id),
);
return `(;${root}${sequence(tree)})\n`;
}
/** Écrit des nœuds : une suite tant qu'il n'y a qu'une variante, puis
* chaque variante entre parenthèses. */
function sequence(nodes: readonly SgfNode[]): string {
if (nodes.length === 1) {
const [node] = nodes as [SgfNode];
return `\n;${node.properties}${sequence(node.children)}`;
}
return nodes.map((node) => `(\n;${node.properties}${sequence(node.children)})`).join("");
}
/** Les propriétés du nœud d'un événement, ou null s'il n'en fait pas
* (début, dés suivis d'un coup, fin de partie). */
function nodeProperties(
event: GameEvent,
byId: ReadonlyMap<string, GameEvent>,
hasChildren: boolean,
): string | null {
switch (event.type) {
case "MovePlayed": {
const rolled = event.parent === null ? undefined : byId.get(event.parent);
if (rolled?.type !== "DiceRolled") {
throw new Error(`coup sans jet de dés : ${event.id}`);
}
const moves = event.halfMoves.map((h) => halfMove(h, event.side)).join("");
return `${letter(event.side)}[${rolled.roll[0]}${rolled.roll[1]}${moves}]`;
}
case "CubeOffered":
return `${letter(event.side)}[double]`;
case "CubeTaken":
return `${letter(event.side)}[take]`;
case "CubeDropped":
return `${letter(event.side)}[drop]`;
case "DiceRolled": {
// Des dés pas encore joués, au bout d'une branche.
if (hasChildren) {
return null;
}
const [a, b] = event.roll;
const side = event.side ?? (a > b ? "player" : a < b ? "computer" : null);
return side === null ? null : `PL[${letter(side)}]DI[${a}${b}]`;
}
case "GameStarted":
case "GameEnded":
return null;
}
}
/** Les propriétés des nœuds qui posent une position de départ particulière
* (aucun pour la position habituelle, videau au centre, jet d'ouverture à
* faire). */
async function setupProperties(
engine: Engine,
start: Extract<GameEvent, { type: "GameStarted" }>,
): Promise<string[]> {
const usual =
start.position === START_POSITION &&
start.firstTurn === null &&
start.cube.value === 1 &&
start.cube.owner === null;
if (usual) {
return [];
}
const onRoll: Side = start.firstTurn ?? "player";
const owner =
start.cube.owner === null
? "centered"
: start.cube.owner === onRoll
? "onRoll"
: "opponent";
const board = await engine.board(start.position, { value: start.cube.value, owner });
// Chaque camp avec sa propre numérotation : W en 'a' + (c - 1), B en
// 'x' - (c - 1), la barre en 'y' (comme les coups).
const stones = (side: Side, points: readonly number[], bar: number) => {
const out: string[] = [];
points.forEach((count, i) => {
for (let k = 0; k < count; k++) {
out.push(`[${pointLetter(i + 1, side)}]`);
}
});
for (let k = 0; k < bar; k++) {
out.push("[y]");
}
return out.join("");
};
const other: Side = onRoll === "player" ? "computer" : "player";
const sides = { [onRoll]: board.onRoll, [other]: board.opponent } as Record<
Side,
typeof board.onRoll
>;
const white = stones("computer", sides.computer.points, sides.computer.bar);
const black = stones("player", sides.player.points, sides.player.bar);
const nodes = [
`PL[${letter(onRoll)}]AE[a:y]${white === "" ? "" : `AW${white}`}${black === "" ? "" : `AB${black}`}`,
];
if (start.cube.value !== 1) {
nodes.push(`CV[${start.cube.value}]`);
}
if (start.cube.owner !== null) {
nodes.push(`CP[${start.cube.owner === "player" ? "b" : "w"}]`);
}
return nodes;
}
function letter(side: Side): "B" | "W" {
return side === "player" ? "B" : "W";
}
function halfMove(h: HalfMove, side: Side): string {
return pointLetter(h.from, side) + pointLetter(h.to, side);
}
/** Une case vue du camp side : 1 à 24, BAR ou OFF. */
function pointLetter(point: number, side: Side): string {
if (point === BAR) {
return "y";
}
if (point === OFF) {
return "z";
}
const code =
side === "player" ? "x".charCodeAt(0) - (point - 1) : "a".charCodeAt(0) + point - 1;
return String.fromCharCode(code);
}
/** Échappe un texte libre dans une valeur SGF. */
function escapeText(text: string): string {
return text.replace(/[\\\]]/g, (c) => `\\${c}`);
}
+100 -7
View File
@@ -5,36 +5,120 @@
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
import { switchToBranch } from "./domain/game/branches.js";
import type { Level } from "./domain/game/engine.js";
import { Game } from "./domain/game/game.js";
import { exportSgf } from "./domain/game/sgf.js";
import { createWasmEngine } from "./engine/wasm-engine.js";
import { startEngineWorker } from "./engine/worker-run.js";
import { createGameStore } from "./storage/game-store.js";
import { newGameId } from "./storage/game-library.js";
import { openGameLibrary } from "./storage/open-library.js";
import { startApp } from "./ui/app.js";
import { GAMES_TEXTS } from "./ui/games-texts.js";
import { createGamesView } from "./ui/games-view.js";
// Ce fichier est servi sous app/ ; le Worker et le service worker sont à la
// racine du site.
const worker = startEngineWorker(new URL("../engine-worker.mjs", import.meta.url));
const engine = createWasmEngine(worker.run);
const store = createGameStore(window.localStorage);
// Les parties enregistrées (IndexedDB, ou localStorage en repli). Chaque
// accès au stockage du navigateur peut lever une exception (stockage
// interdit) : on fait alors sans.
const { library } = await openGameLibrary({
indexedDB: attempt(() => window.indexedDB),
localStorage: attempt(() => window.localStorage),
});
/** Le niveau de l'ordinateur (celui de Game.start, STANDARD_PLY). */
const LEVEL: Level = "2ply";
const saved = await library.current().catch(() => null);
// La partie à l'écran ; un nouvel identifiant à chaque nouvelle partie.
let shownId = saved?.summary.id ?? newGameId();
// Vrai une fois qu'on recharge la page pour reprendre une autre partie :
// plus rien n'est enregistré sous l'identifiant de celle qu'on quitte.
let leaving = false;
startApp({
engineReady: worker.ready,
newGame: () => Game.start(engine),
newGame: () => {
shownId = newGameId();
return Game.start(engine);
},
savedGame: () => {
const journal = store.load();
if (journal === null) {
if (saved === null) {
return null;
}
try {
return Game.resume(engine, journal);
return Game.resume(engine, saved.journal);
} catch {
return null; // partie illisible : on en commence une nouvelle
}
},
saveGame: (journal) => store.save(journal),
saveGame: (journal) => {
if (!leaving) {
// Stockage plein ou interdit : la partie continue, elle ne
// pourra seulement pas être reprise.
library.save(shownId, journal, { level: LEVEL }).catch(() => {});
}
},
confirm: (question) => window.confirm(question),
});
// « Mes parties ». Point d'accroche provisoire : un bouton ajouté devant
// celui de l'aide, en attendant le menu de l'application (révision UX).
// Reprendre une partie la désigne comme partie en cours, puis recharge la
// page, qui l'ouvre.
const gamesView = createGamesView({
list: () => library.list(),
journal: async (id) => (await library.load(id))?.journal ?? null,
shownId: () => shownId,
resume: async (id, branchHead) => {
const game = await library.load(id);
if (game === null) {
throw new Error("partie introuvable");
}
leaving = true;
if (branchHead === undefined) {
await library.setCurrent(id);
} else {
const journal = switchToBranch(game.journal, branchHead);
await library.save(id, journal, { level: game.summary.level });
}
window.location.reload();
},
remove: (id) => library.remove(id),
exportSgf: async (id) => {
const game = await library.load(id);
if (game === null) {
throw new Error("partie introuvable");
}
const started = new Date(game.summary.startedAt);
const date = [started.getFullYear(), started.getMonth() + 1, started.getDate()]
.map((n) => String(n).padStart(2, "0"))
.join("-");
const time = [started.getHours(), started.getMinutes()]
.map((n) => String(n).padStart(2, "0"))
.join("");
const text = await exportSgf(engine, game.journal, {
playerName: "Vous",
computerName: "Ustagammon",
date,
});
return { fileName: `ustagammon-${date}-${time}.sgf`, text };
},
confirm: (question) => window.confirm(question),
});
const helpButton = document.getElementById("help-button");
if (helpButton !== null) {
const gamesButton = document.createElement("button");
gamesButton.id = "games-button";
gamesButton.type = "button";
gamesButton.className = "secondary";
gamesButton.textContent = GAMES_TEXTS.button;
gamesButton.onclick = () => void gamesView.open();
helpButton.before(gamesButton);
}
// Hors ligne : le service worker met tout le site en cache. Le navigateur le
// refuse hors https (sauf localhost) : l'application marche alors sans.
// Il est enregistré une fois le moteur chargé : si le Worker et le service
@@ -46,3 +130,12 @@ if ("serviceWorker" in navigator) {
const sw = new URL("../sw.js", import.meta.url);
worker.ready.then(() => navigator.serviceWorker.register(sw)).catch(() => {});
}
/** La valeur, ou undefined si l'obtenir lève une exception. */
function attempt<T>(get: () => T): T | undefined {
try {
return get();
} catch {
return undefined;
}
}
+140
View File
@@ -0,0 +1,140 @@
// Les parties enregistrées sur l'appareil : leur résumé, leur journal
// d'événements, et laquelle est la partie en cours.
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
//
// Deux stockages derrière la même interface (GameLibrary) :
// - IndexedDB, le stockage normal du navigateur (indexeddb-backend.ts) ;
// - un stockage clé-valeur (localStorage), en repli quand IndexedDB est
// indisponible, par exemple en navigation privée de certains navigateurs.
// Les opérations sont exécutées l'une après l'autre, dans l'ordre des
// appels : un enregistrement lancé sans être attendu est donc toujours fait
// avant la lecture qui suit.
import type { GameJournal } from "../domain/game/events.js";
import {
type SavedGameDetails,
type SavedGameSummary,
summarizeGame,
} from "../domain/game/saved-game.js";
/** Une partie enregistrée : son résumé et son journal. */
export interface SavedGame {
readonly summary: SavedGameSummary;
readonly journal: GameJournal;
}
export interface GameLibrary {
/** Les parties, la dernière jouée d'abord. */
list(): Promise<SavedGameSummary[]>;
/** Une partie, ou null si elle n'existe pas ou est illisible. */
load(id: string): Promise<SavedGame | null>;
/** Enregistre la partie id (nouvelle ou non), qui devient la partie en
* cours. Renvoie son résumé. */
save(id: string, journal: GameJournal, details: SavedGameDetails): Promise<SavedGameSummary>;
/** Supprime une partie (rien si elle n'existe pas). */
remove(id: string): Promise<void>;
/** La partie en cours : la dernière enregistrée ou choisie (setCurrent),
* si elle existe encore ; sinon la dernière jouée ; null s'il n'y en a
* aucune. */
current(): Promise<SavedGame | null>;
/** Fait d'une partie enregistrée la partie en cours, sans la modifier
* (pour la reprendre). */
setCurrent(id: string): Promise<void>;
}
/** Ce que chaque stockage sait faire ; GameLibrary ajoute l'ordre des
* opérations, les dates et la validation. */
export interface LibraryBackend {
summaries(): Promise<SavedGameSummary[]>;
summary(id: string): Promise<SavedGameSummary | undefined>;
journal(id: string): Promise<unknown>;
/** Écrit le résumé et le journal ensemble. */
write(summary: SavedGameSummary, journal: GameJournal): Promise<void>;
remove(id: string): Promise<void>;
currentId(): Promise<string | undefined>;
setCurrentId(id: string): Promise<void>;
}
export interface LibraryOptions {
/** L'horloge, en millisecondes ; Date.now par défaut. */
readonly now?: () => number;
}
/** Un identifiant de partie : la date et du hasard, sans dépendre de
* crypto.randomUUID, absent hors https. */
export function newGameId(now: number = Date.now()): string {
const random = Math.floor(Math.random() * 36 ** 6)
.toString(36)
.padStart(6, "0");
return `${now.toString(36)}-${random}`;
}
/** Un journal lisible : la forme attendue, sans juger de son contenu (le
* domaine le vérifie en reprenant la partie). */
export function isJournal(value: unknown): value is GameJournal {
if (typeof value !== "object" || value === null) {
return false;
}
const journal = value as { events?: unknown; head?: unknown };
return (
Array.isArray(journal.events) && (journal.head === null || typeof journal.head === "string")
);
}
export function createGameLibrary(
backend: LibraryBackend,
options: LibraryOptions = {},
): GameLibrary {
const now = options.now ?? Date.now;
let queue: Promise<unknown> = Promise.resolve();
/** Exécute op après toutes les opérations déjà demandées. */
const enqueue = <T>(op: () => Promise<T>): Promise<T> => {
const result = queue.then(op, op);
queue = result.catch(() => {});
return result;
};
const load = async (id: string): Promise<SavedGame | null> => {
const summary = await backend.summary(id);
const journal = await backend.journal(id);
return summary === undefined || !isJournal(journal) ? null : { summary, journal };
};
const list = async () => (await backend.summaries()).sort((a, b) => b.updatedAt - a.updatedAt);
return {
list: () => enqueue(list),
load: (id) => enqueue(() => load(id)),
save: (id, journal, details) =>
enqueue(async () => {
const time = now();
const startedAt = (await backend.summary(id))?.startedAt ?? time;
const summary = summarizeGame(id, journal, details, {
startedAt,
updatedAt: Math.max(time, startedAt),
});
await backend.write(summary, journal);
await backend.setCurrentId(id);
return summary;
}),
remove: (id) => enqueue(() => backend.remove(id)),
current: () =>
enqueue(async () => {
const id = await backend.currentId();
const chosen = id === undefined ? null : await load(id);
if (chosen !== null) {
return chosen;
}
const latest = (await list())[0];
return latest === undefined ? null : load(latest.id);
}),
setCurrent: (id) =>
enqueue(async () => {
if ((await backend.summary(id)) === undefined) {
throw new Error(`partie introuvable : ${id}`);
}
await backend.setCurrentId(id);
}),
};
}
-47
View File
@@ -1,47 +0,0 @@
// L'enregistrement de la partie en cours, pour la reprendre après fermeture
// de la page : son journal d'événements, en JSON, dans un stockage clé-valeur
// (localStorage dans le navigateur).
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
import type { GameJournal } from "../domain/game/events.js";
/** Ce qu'il faut d'un stockage clé-valeur (localStorage en a davantage). */
export interface KeyValueStorage {
getItem(key: string): string | null;
setItem(key: string, value: string): void;
}
export interface GameStore {
/** Le journal enregistré, ou null s'il n'y en a pas de lisible. */
load(): GameJournal | null;
save(journal: GameJournal): void;
}
const KEY = "ustagammon.current-game";
export function createGameStore(storage: KeyValueStorage): GameStore {
return {
load() {
try {
const journal = JSON.parse(storage.getItem(KEY) ?? "null");
const valid =
journal !== null &&
Array.isArray(journal.events) &&
(journal.head === null || typeof journal.head === "string");
return valid ? journal : null;
} catch {
return null;
}
},
save(journal) {
try {
storage.setItem(KEY, JSON.stringify(journal));
} catch {
// Stockage plein ou interdit (navigation privée) : la partie
// continue, elle ne pourra seulement pas être reprise.
}
},
};
}
+81
View File
@@ -0,0 +1,81 @@
// Les parties dans IndexedDB, le stockage de données du navigateur.
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
//
// Base « ustagammon », trois magasins d'objets :
// - games : les résumés des parties (SavedGameSummary), clé id ;
// - journals : leurs journaux, { id, journal }, clé id ;
// - settings : { key, value }, dont la partie en cours (key « current »).
// Le résumé et le journal d'une partie sont écrits dans la même transaction.
// L'API IndexedDB est utilisée directement, sans bibliothèque.
import type { GameJournal } from "../domain/game/events.js";
import type { SavedGameSummary } from "../domain/game/saved-game.js";
import type { LibraryBackend } from "./game-library.js";
const DATABASE = "ustagammon";
const VERSION = 1;
const GAMES = "games";
const JOURNALS = "journals";
const SETTINGS = "settings";
/** Le résultat d'une requête IndexedDB, en promesse. */
function done<T>(request: IDBRequest<T>): Promise<T> {
return new Promise((resolve, reject) => {
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
/** La fin d'une transaction (toutes ses écritures faites), en promesse. */
function committed(transaction: IDBTransaction): Promise<void> {
return new Promise((resolve, reject) => {
transaction.oncomplete = () => resolve();
transaction.onerror = () => reject(transaction.error);
transaction.onabort = () => reject(transaction.error ?? new Error("transaction annulée"));
});
}
/** Ouvre la base, en la créant au besoin. Rejetée si IndexedDB est
* indisponible ou refusé. */
export async function openIndexedDbBackend(factory: IDBFactory): Promise<LibraryBackend> {
const request = factory.open(DATABASE, VERSION);
request.onupgradeneeded = () => {
const db = request.result;
db.createObjectStore(GAMES, { keyPath: "id" });
db.createObjectStore(JOURNALS, { keyPath: "id" });
db.createObjectStore(SETTINGS, { keyPath: "key" });
};
const db = await done(request);
const get = async <T>(store: string, key: string): Promise<T | undefined> =>
done(db.transaction(store).objectStore(store).get(key));
return {
summaries: async () => done(db.transaction(GAMES).objectStore(GAMES).getAll()),
summary: (id) => get<SavedGameSummary>(GAMES, id),
journal: async (id) => (await get<{ journal: unknown }>(JOURNALS, id))?.journal,
write: async (summary: SavedGameSummary, journal: GameJournal) => {
const transaction = db.transaction([GAMES, JOURNALS], "readwrite");
transaction.objectStore(JOURNALS).put({ id: summary.id, journal });
transaction.objectStore(GAMES).put(summary);
await committed(transaction);
},
remove: async (id) => {
const transaction = db.transaction([GAMES, JOURNALS], "readwrite");
transaction.objectStore(GAMES).delete(id);
transaction.objectStore(JOURNALS).delete(id);
await committed(transaction);
},
currentId: async () => {
const setting = await get<{ value: unknown }>(SETTINGS, "current");
return typeof setting?.value === "string" ? setting.value : undefined;
},
setCurrentId: async (id) => {
const transaction = db.transaction(SETTINGS, "readwrite");
transaction.objectStore(SETTINGS).put({ key: "current", value: id });
await committed(transaction);
},
};
}
+99
View File
@@ -0,0 +1,99 @@
// Les parties dans un stockage clé-valeur (localStorage), en JSON : le
// repli quand IndexedDB est indisponible, et la lecture de la partie
// enregistrée par les versions précédentes, pour la migrer.
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
import type { GameJournal } from "../domain/game/events.js";
import type { SavedGameSummary } from "../domain/game/saved-game.js";
import { type GameLibrary, isJournal, type LibraryBackend, newGameId } from "./game-library.js";
/** Ce qu'il faut d'un stockage clé-valeur (localStorage en a davantage). */
export interface KeyValueStorage {
getItem(key: string): string | null;
setItem(key: string, value: string): void;
removeItem(key: string): void;
}
const SUMMARIES = "ustagammon.games";
const CURRENT = "ustagammon.current-game-id";
const journalKey = (id: string) => `ustagammon.game.${id}`;
/** La partie unique des versions précédentes (étape 0), à migrer. */
const LEGACY_GAME = "ustagammon.current-game";
/** Lit une clé en JSON ; undefined si elle est absente, illisible, ou si le
* stockage est interdit. */
function read(storage: KeyValueStorage, key: string): unknown {
try {
const text = storage.getItem(key);
return text === null ? undefined : JSON.parse(text);
} catch {
return undefined;
}
}
export function createKeyValueBackend(storage: KeyValueStorage): LibraryBackend {
const summaries = (): SavedGameSummary[] => {
const value = read(storage, SUMMARIES);
return Array.isArray(value) ? value : [];
};
const writeSummaries = (list: readonly SavedGameSummary[]) =>
storage.setItem(SUMMARIES, JSON.stringify(list));
return {
summaries: async () => summaries(),
summary: async (id) => summaries().find((s) => s.id === id),
journal: async (id) => read(storage, journalKey(id)),
write: async (summary, journal) => {
// Le journal d'abord : un résumé ne désigne jamais un journal
// absent.
storage.setItem(journalKey(summary.id), JSON.stringify(journal));
writeSummaries([...summaries().filter((s) => s.id !== summary.id), summary]);
},
remove: async (id) => {
writeSummaries(summaries().filter((s) => s.id !== id));
storage.removeItem(journalKey(id));
},
currentId: async () => {
const id = read(storage, CURRENT);
return typeof id === "string" ? id : undefined;
},
setCurrentId: async (id) => storage.setItem(CURRENT, JSON.stringify(id)),
};
}
/**
* Reprend dans library la partie enregistrée par les versions précédentes
* (une seule partie, sous une clé de localStorage), qui devient la partie
* en cours ; puis efface l'ancienne clé. Sans effet s'il n'y en a pas. Un
* journal illisible est effacé. Renvoie l'identifiant de la partie migrée.
*/
export async function migrateLegacyGame(
storage: KeyValueStorage,
library: GameLibrary,
now: number = Date.now(),
): Promise<string | null> {
let present: boolean;
try {
present = storage.getItem(LEGACY_GAME) !== null;
} catch {
return null;
}
if (!present) {
return null;
}
const journal: unknown = read(storage, LEGACY_GAME);
let id: string | null = null;
if (isJournal(journal) && journal.events.length > 0) {
id = newGameId(now);
// Les parties de l'étape 0 se jouaient toutes au niveau standard.
await library.save(id, journal as GameJournal, { level: "2ply" });
}
try {
storage.removeItem(LEGACY_GAME);
} catch {
// Stockage interdit : la migration sera refaite, sans dommage.
}
return id;
}
+96
View File
@@ -0,0 +1,96 @@
// Ouverture des parties enregistrées au démarrage : IndexedDB si possible,
// sinon localStorage, puis migration de la partie des versions précédentes.
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
import { createGameLibrary, type GameLibrary, type LibraryOptions } from "./game-library.js";
import { openIndexedDbBackend } from "./indexeddb-backend.js";
import {
createKeyValueBackend,
type KeyValueStorage,
migrateLegacyGame,
} from "./key-value-backend.js";
/** Les stockages du navigateur ; chacun peut manquer ou être refusé. */
export interface BrowserStorage {
readonly indexedDB?: IDBFactory;
readonly localStorage?: KeyValueStorage;
}
export interface OpenedLibrary {
readonly library: GameLibrary;
/** Le stockage retenu : IndexedDB, son repli localStorage, ou la seule
* mémoire de la page (rien n'est gardé après sa fermeture). */
readonly kind: "indexeddb" | "localStorage" | "memory";
}
/** Au-delà, IndexedDB est tenu pour indisponible (une ouverture bloquée ne
* doit pas empêcher de jouer). */
const OPEN_TIMEOUT_MS = 5000;
export async function openGameLibrary(
storage: BrowserStorage,
options: LibraryOptions = {},
): Promise<OpenedLibrary> {
const opened = await open(storage, options);
if (storage.localStorage !== undefined) {
try {
await migrateLegacyGame(storage.localStorage, opened.library);
} catch {
// Migration impossible (stockage plein ou refusé) : l'ancienne
// partie reste où elle est, on la retentera au prochain démarrage.
}
}
return opened;
}
async function open(storage: BrowserStorage, options: LibraryOptions): Promise<OpenedLibrary> {
if (storage.indexedDB !== undefined) {
let timer: ReturnType<typeof setTimeout> | undefined;
try {
const backend = await Promise.race([
openIndexedDbBackend(storage.indexedDB),
new Promise<never>((_, reject) => {
timer = setTimeout(() => reject(new Error("IndexedDB muet")), OPEN_TIMEOUT_MS);
}),
]);
return { library: createGameLibrary(backend, options), kind: "indexeddb" };
} catch {
// Indisponible (navigation privée de certains navigateurs) : repli.
} finally {
clearTimeout(timer);
}
}
if (storage.localStorage !== undefined && usable(storage.localStorage)) {
return {
library: createGameLibrary(createKeyValueBackend(storage.localStorage), options),
kind: "localStorage",
};
}
return {
library: createGameLibrary(createKeyValueBackend(memoryStorage()), options),
kind: "memory",
};
}
/** localStorage peut exister et refuser toute écriture. */
function usable(storage: KeyValueStorage): boolean {
try {
storage.setItem("ustagammon.probe", "1");
storage.removeItem("ustagammon.probe");
return true;
} catch {
return false;
}
}
/** Un stockage clé-valeur en mémoire. */
export function memoryStorage(): KeyValueStorage {
const items = new Map<string, string>();
return {
getItem: (key) => items.get(key) ?? null,
setItem: (key, value) => void items.set(key, value),
removeItem: (key) => void items.delete(key),
};
}
+65
View File
@@ -0,0 +1,65 @@
// Les textes de « Mes parties » (ADR 0007, point 5), à part de texts.ts le
// temps que l'écran rejoigne le menu de l'application.
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
import type { ResultKind } from "../domain/game/engine.js";
import type { Side } from "../domain/game/side.js";
const RESULT_KIND: Record<ResultKind, string> = {
single: "partie simple",
gammon: "gammon",
backgammon: "backgammon",
};
function moves(n: number): string {
return n > 1 ? `${n} coups` : `${n} coup`;
}
function points(n: number): string {
return n > 1 ? `${n} points` : `${n} point`;
}
export const GAMES_TEXTS = {
button: "Mes parties",
title: "Mes parties",
empty: "Aucune partie enregistrée.",
close: "Fermer",
resume: "Reprendre",
showBranches: "Branches",
resumeBranch: "Reprendre cette branche",
exportSgf: "Exporter (SGF)",
remove: "Supprimer",
confirmRemove: "Supprimer définitivement cette partie et toutes ses branches ?",
shown: "partie affichée",
failed: (message: string) => `Erreur : ${message}`,
/** « 3 nov. 2026, 14:05 ». */
date: (ms: number) =>
new Date(ms).toLocaleString("fr-FR", { dateStyle: "medium", timeStyle: "short" }),
status: (
result: { winner: Side; kind: ResultKind; points: number } | null,
moveCount: number,
) => {
if (result === null) {
return `En cours, ${moves(moveCount)}`;
}
const who = result.winner === "player" ? "Gagnée" : "Perdue";
return `${who} (${RESULT_KIND[result.kind]}, ${points(result.points)}), ${moves(moveCount)}`;
},
branchCount: (n: number) => (n > 1 ? `${n} branches` : ""),
/** Une branche : d'où elle part et où elle en est. */
branch: (forkMoveCount: number | null, moveCount: number, over: boolean) => {
const start =
forkMoveCount === null
? "Partie d'origine"
: forkMoveCount === 0
? "Rejouée depuis le début"
: `Rejouée après le coup ${forkMoveCount}`;
return `${start} : ${moves(moveCount)}${over ? ", finie" : ""}`;
},
currentBranch: " (branche courante)",
};
+205
View File
@@ -0,0 +1,205 @@
// « Mes parties » : la liste des parties enregistrées, pour en reprendre une
// (ou l'une de ses branches), l'exporter en SGF ou la supprimer.
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
//
// Un écran autonome, dans une fenêtre (<dialog>) qu'il crée lui-même : il ne
// dépend ni de index.html ni de l'écran de jeu (app.ts). Comme tout l'écran,
// il ne connaît ni le stockage ni le moteur : main.ts lui passe les
// opérations (GamesViewOptions).
import { branches } from "../domain/game/branches.js";
import type { GameJournal } from "../domain/game/events.js";
import type { SavedGameSummary } from "../domain/game/saved-game.js";
import { GAMES_TEXTS } from "./games-texts.js";
export interface GamesViewOptions {
/** Les parties, la dernière jouée d'abord. */
readonly list: () => Promise<SavedGameSummary[]>;
/** Le journal d'une partie (null si elle a disparu). */
readonly journal: (id: string) => Promise<GameJournal | null>;
/** L'identifiant de la partie affichée à l'écran. */
readonly shownId: () => string | null;
/** Reprend une partie, au bout de la branche branchHead si elle est
* donnée (sinon là où elle en était). */
readonly resume: (id: string, branchHead?: string) => Promise<void>;
readonly remove: (id: string) => Promise<void>;
/** Le fichier SGF d'une partie : son nom et son contenu. */
readonly exportSgf: (id: string) => Promise<{ fileName: string; text: string }>;
readonly confirm: (question: string) => boolean;
}
export interface GamesView {
/** Ouvre la liste, à jour. */
open(): Promise<void>;
}
/** Crée la fenêtre « Mes parties » (fermée) dans la page. */
export function createGamesView(options: GamesViewOptions): GamesView {
const dialog = element("dialog", "confirm games");
dialog.id = "games-dialog";
dialog.setAttribute("aria-labelledby", "games-title");
const title = element("h2");
title.id = "games-title";
title.textContent = GAMES_TEXTS.title;
const list = element("ul", "games-list");
const message = element("p", "games-message");
message.setAttribute("aria-live", "polite");
const close = button(GAMES_TEXTS.close, () => dialog.close(), "secondary");
close.id = "games-close";
dialog.append(title, message, list, close);
document.body.append(dialog);
/** Lance une action ; une erreur s'affiche dans la fenêtre. */
const run = (action: () => Promise<void>) => async () => {
message.textContent = "";
try {
await action();
} catch (error) {
message.textContent = GAMES_TEXTS.failed(
error instanceof Error ? error.message : String(error),
);
}
};
async function render(): Promise<void> {
const games = await options.list();
list.replaceChildren(...games.map((game) => gameItem(game, game.id === options.shownId())));
if (games.length === 0) {
message.textContent = GAMES_TEXTS.empty;
}
}
function gameItem(game: SavedGameSummary, shown: boolean): HTMLElement {
const item = element("li", "games-item");
item.dataset.id = game.id;
const heading = element("p");
heading.textContent = [
GAMES_TEXTS.date(game.startedAt),
GAMES_TEXTS.status(game.result, game.moveCount),
GAMES_TEXTS.branchCount(game.branchCount),
shown ? GAMES_TEXTS.shown : "",
]
.filter((part) => part !== "")
.join(" · ");
const branchList = element("ul", "games-branches");
branchList.hidden = true;
const actions = element("div", "confirm-actions");
actions.append(
button(
GAMES_TEXTS.resume,
run(() => options.resume(game.id)),
"",
"resume",
),
button(
GAMES_TEXTS.showBranches,
run(async () => {
if (branchList.hidden) {
await renderBranches(game.id, branchList);
}
branchList.hidden = !branchList.hidden;
}),
"secondary",
"branches",
),
button(
GAMES_TEXTS.exportSgf,
run(async () => download(await options.exportSgf(game.id))),
"secondary",
"export",
),
button(
GAMES_TEXTS.remove,
run(async () => {
if (options.confirm(GAMES_TEXTS.confirmRemove)) {
await options.remove(game.id);
await render();
}
}),
"secondary",
"remove",
),
);
item.append(heading, actions, branchList);
return item;
}
async function renderBranches(id: string, target: HTMLElement): Promise<void> {
const journal = await options.journal(id);
const all = journal === null ? [] : branches(journal);
target.replaceChildren(
...all.map((branch) => {
const item = element("li", "games-branch");
item.dataset.head = branch.head;
const text = element("span");
text.textContent =
GAMES_TEXTS.branch(
branch.forkedFrom === null ? null : branch.forkMoveCount,
branch.moveCount,
branch.over,
) + (branch.containsHead ? GAMES_TEXTS.currentBranch : "");
item.append(
text,
button(
GAMES_TEXTS.resumeBranch,
run(() => options.resume(id, branch.head)),
"secondary",
"resume-branch",
),
);
return item;
}),
);
}
return {
async open() {
message.textContent = "";
await render();
if (!dialog.open) {
dialog.showModal();
}
},
};
}
function element<K extends keyof HTMLElementTagNameMap>(
tag: K,
className = "",
): HTMLElementTagNameMap[K] {
const created = document.createElement(tag);
if (className !== "") {
created.className = className;
}
return created;
}
function button(
text: string,
onClick: () => unknown,
className = "",
action = "",
): HTMLButtonElement {
const created = element("button", className);
created.type = "button";
created.textContent = text;
if (action !== "") {
created.dataset.action = action;
}
created.onclick = () => void onClick();
return created;
}
/** Fait télécharger un fichier texte au navigateur. */
function download(file: { fileName: string; text: string }): void {
const url = URL.createObjectURL(new Blob([file.text], { type: "application/x-go-sgf" }));
const link = element("a");
link.href = url;
link.download = file.fileName;
document.body.append(link);
link.click();
link.remove();
setTimeout(() => URL.revokeObjectURL(url), 10_000);
}