Fusion de main (révision UX) ; Mes parties dans le menu, reprise sans rechargement

« Reprendre l'ancienne » relit l'ancienne partie dans le stockage par son
identifiant et supprime la nouvelle (GameSession).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Pierre MARTIN
2026-11-03 20:17:53 +01:00
co-authored by Claude Opus 5.5
35 changed files with 4578 additions and 893 deletions
+46 -93
View File
@@ -5,120 +5,73 @@
// 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 { newGameId } from "./storage/game-library.js";
import { openGameLibrary } from "./storage/open-library.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";
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);
// Les parties enregistrées (IndexedDB, ou localStorage en repli). Chaque
// accès au stockage du navigateur peut lever une exception (stockage
// 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).
const { library } = await openGameLibrary({
indexedDB: attempt(() => window.indexedDB),
localStorage: attempt(() => window.localStorage),
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;
// 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();
startApp({
engineReady: worker.ready,
newGame: () => {
shownId = newGameId();
return Game.start(engine);
newGame: () => session.newGame(),
savedGame: () => saved,
resumePrevious: () => session.resumePrevious(),
saveGame: (journal) => session.save(journal),
games: {
list: () => library.list(),
journal: async (id) => (await library.load(id))?.journal ?? null,
shownId: () => session.shownId,
open: (id, branchHead) => session.open(id, branchHead),
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 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 };
},
},
savedGame: () => {
if (saved === null) {
return null;
}
try {
return Game.resume(engine, saved.journal);
} catch {
return null; // partie illisible : on en commence une nouvelle
}
},
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),
cachedBoard: () => settings.loadBoard(),
saveBoard: (board) => settings.saveBoard(board),
loadSettings: () => settings.loadSettings(),
saveSettings: (json) => settings.saveSettings(json),
});
// « 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