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
+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;
}
}