Files
Ustagammon/app/src/main.ts
T
Pierre MARTINandClaude Opus 5.5 2a1fe1b04c « Demander à une IA » : bouton, feuille du prompt à son adresse, copie
Le bouton, secondaire, dans la feuille du maître, sur le bilan, dans
l'analyse et sur la fiche ouverte du glossaire. Il ouvre la feuille du
prompt (…/ia, amendement de l'ADR 0013), où le texte se relit avant la
copie : navigator.clipboard, repli par sélection du texte, bandeau
« Prompt copié » (2 s). Gabarits copiés par make site (amendement de
l'ADR 0008), scénario « Demander à une IA », 1f marquée faite.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 20:56:56 +01:00

188 lines
8.4 KiB
TypeScript

// Point d'entrée de l'application dans le navigateur (index.html) : le seul
// endroit qui assemble le domaine, le moteur, le stockage et l'interface
// (ADR 0007, vérifié par dependency-cruiser).
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
import { ANALYSIS_PLY, analyseDecision } from "./domain/analysis/analyse.js";
import { runAnalysis } from "./domain/analysis/game-analysis.js";
import { Game } from "./domain/game/game.js";
import { linkOf, readPosition } from "./domain/game/position-link.js";
import { levelOf } from "./domain/game/saved-game.js";
import { exportSgf } from "./domain/game/sgf.js";
import {
illustratedMove,
MovePractice,
shouldTake,
tutorialBoard,
} from "./domain/learning/tutorial.js";
import { positionBefore } from "./domain/prompts/position.js";
import { conceptExample, summaryPosition } from "./domain/prompts/prompts.js";
import { createWasmEngine } from "./engine/wasm-engine.js";
import { type EngineWorker, startEngineWorker } from "./engine/worker-run.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 { startUpdates } from "./ui/updates.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);
// Le conseil du maître (étape 1d) : les analyses à 2 plis tournent sur un
// second moteur, dans son propre Web Worker. Le premier ne traite qu'une
// commande à la fois : une analyse y retarderait le coup de l'ordinateur.
// Le second démarre à la première analyse demandée, une fois le premier
// chargé et le service worker installé (au plus 20 s d'attente) : si deux
// téléchargements des données du moteur se croisent, Chromium fait échouer
// l'installation hors ligne (voir startUpdates plus bas). Ensuite, les
// données viennent du cache.
let analysisWorker: Promise<EngineWorker> | null = null;
function startAnalysisWorker(): Promise<EngineWorker> {
analysisWorker ??= worker.ready
.then(() => {
const installed =
"serviceWorker" in navigator ? navigator.serviceWorker.ready : Promise.resolve();
return Promise.race([installed, new Promise((resolve) => setTimeout(resolve, 20_000))]);
})
.then(() => {
const started = startEngineWorker(new URL("../engine-worker.mjs", import.meta.url));
return started.ready.then(() => started);
});
return analysisWorker;
}
const analysisEngine = createWasmEngine(async (args) => (await startAnalysisWorker()).run(args));
// 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 et le match de chaque partie viennent de l'écran « Nouvelle
// partie » (ou de la partie précédente du match).
const session = createGameSession(library, {
start: (newGame) => Game.start(engine, newGame ?? {}),
resume: (journal) => Game.resume(engine, journal),
});
const saved = await session.restore();
return { library, session, saved };
})();
let stored: Awaited<typeof opening> | null = null;
/** Le stockage, ouvert (toujours le cas une fois engineReady résolue). */
function storage(): Awaited<typeof opening> {
if (stored === null) {
throw new Error("stockage pas encore ouvert");
}
return stored;
}
startApp({
engineReady: Promise.all([
worker.ready,
opening.then((opened) => {
stored = opened;
}),
]),
newGame: (newGame) => storage().session.newGame(newGame),
positions: {
read: (link) => readPosition(engine, link),
linkOf: (game) => linkOf(engine, game.state),
},
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),
view: async (id) => {
const saved = await storage().library.load(id);
if (saved === null) {
return null;
}
try {
return Game.resume(engine, saved.journal);
} catch {
return null;
}
},
saveAnalyses: (id, journal) => {
storage()
.library.saveAnalyses(id, journal, { level: levelOf(journal) })
.catch(() => {});
},
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 };
},
},
cachedBoard: () => settings.loadBoard(),
saveBoard: (board) => settings.saveBoard(board),
loadSettings: () => settings.loadSettings(),
saveSettings: (json) => settings.saveSettings(json),
// Le tutoriel : positions, coups illustrés et gestes jugés par le
// domaine, sur le moteur.
tutorial: {
board: (position, onRoll) => tutorialBoard(engine, position, onRoll),
illustrate: (position, roll, move) => illustratedMove(engine, position, roll, move),
practice: (position, roll, expected) =>
MovePractice.start(engine, position, roll, expected),
shouldTake: (position) => shouldTake(engine, position),
},
// 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
// worker téléchargent les données du moteur (8,7 Mo) en même temps,
// Chromium fait échouer l'installation (« Cache.add() encountered a
// network error »), et l'application n'est pas disponible hors ligne.
// Cela évite aussi de télécharger deux fois ces données à la première
// visite.
analyse: (journal, eventId) => analyseDecision(analysisEngine, journal, eventId),
// Le bilan et l'analyse de partie (étape 1e) : les analyses qui
// manquent, une à une, sur le même moteur à part.
analyseGame: (journal, task) => runAnalysis(analysisEngine, journal, task),
// Les prompts pour une IA (étape 1f) : la position à décrire, lue par
// le moteur, et l'évaluation de la position d'exemple d'un concept.
prompts: {
before: (journal, eventId) => positionBefore(engine, journal, eventId),
summary: (journal) => summaryPosition(engine, journal),
concept: (journal) => conceptExample(engine, journal, ANALYSIS_PLY),
},
startUpdates: (onAvailable) =>
startUpdates(new URL("../sw.js", import.meta.url), worker.ready, onAvailable),
});
/** La valeur, ou undefined si l'obtenir lève une exception. */
function attempt<T>(get: () => T): T | undefined {
try {
return get();
} catch {
return undefined;
}
}