Compare commits
14
Commits
revision-ux
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
edcbcea131 | ||
|
|
5c75f29f32 | ||
|
|
3cdd017768 | ||
|
|
19a54193f9 | ||
|
|
2c4448549d | ||
|
|
d228d70387 | ||
|
|
e3aaa0b3a3 | ||
|
|
71ee3605d4 | ||
|
|
196ccd4b9f | ||
|
|
ae8a2d6ad2 | ||
|
|
9420bf8bd6 | ||
|
|
231dd02ac0 | ||
|
|
31815134e5 | ||
|
|
274fbf6252 |
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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<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}`);
|
||||
}
|
||||
+75
-17
@@ -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<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: 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<T>(get: () => T): T | undefined {
|
||||
try {
|
||||
return get();
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -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<Game | null>;
|
||||
/** 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<Game | null>;
|
||||
/** 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<Game>;
|
||||
}
|
||||
|
||||
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<Game | null> => {
|
||||
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;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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.
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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<KeyValueStorage, "getItem" | "setItem">,
|
||||
): SettingsStore {
|
||||
const write = (key: string, value: string) => {
|
||||
try {
|
||||
storage.setItem(key, value);
|
||||
|
||||
+64
-7
@@ -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<Game | null>;
|
||||
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<SavedGameSummary[]>;
|
||||
readonly journal: (id: string) => Promise<GameJournal | null>;
|
||||
/** 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<Game>;
|
||||
readonly remove: (id: string) => Promise<void>;
|
||||
readonly exportSgf: (id: string) => Promise<{ fileName: string; text: string }>;
|
||||
}
|
||||
|
||||
function byId<T extends HTMLElement>(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<HTMLButtonElement>("menu-close"),
|
||||
menuNewGame: byId<HTMLButtonElement>("menu-new-game"),
|
||||
menuGames: byId<HTMLButtonElement>("menu-games"),
|
||||
menuFullscreen: byId<HTMLButtonElement>("menu-fullscreen"),
|
||||
menuHelp: byId<HTMLButtonElement>("menu-help"),
|
||||
menuTheme: byId<HTMLButtonElement>("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<typeof setTimeout> | 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) });
|
||||
|
||||
@@ -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<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 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)",
|
||||
};
|
||||
@@ -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<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 }>;
|
||||
}
|
||||
|
||||
export interface GamesView {
|
||||
/** Ouvre la liste, à jour. */
|
||||
open(): Promise<void>;
|
||||
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<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", "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<HTMLElement>('[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<HTMLElement>('[data-action="remove"]');
|
||||
if (remove !== null) {
|
||||
remove.hidden = shown;
|
||||
}
|
||||
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(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<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);
|
||||
}
|
||||
@@ -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/";
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<number, number>) =>
|
||||
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/);
|
||||
});
|
||||
@@ -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<void> {
|
||||
for (let i = 0; i < moves; i++) {
|
||||
await playMoves(game, engine, 1);
|
||||
await library.save(shownId, game.journal, { level: "0ply" });
|
||||
}
|
||||
}
|
||||
|
||||
async function reopen(): Promise<void> {
|
||||
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<void> {
|
||||
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);
|
||||
});
|
||||
@@ -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<string, string>();
|
||||
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");
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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 });
|
||||
}
|
||||
}
|
||||
@@ -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 <dossier>
|
||||
//
|
||||
// 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
|
||||
// <nom>.sgf (la partie exportée, cette branche en premier : c'est celle que
|
||||
// gnubg lit) et <nom>.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<void> {
|
||||
const out = process.argv[2];
|
||||
if (out === undefined) {
|
||||
throw new Error("usage : sgf-samples.js <dossier>");
|
||||
}
|
||||
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();
|
||||
+2
-34
@@ -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<string, string>();
|
||||
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.");
|
||||
|
||||
@@ -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.
|
||||
|
||||
+1
-1
@@ -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 |
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -151,3 +151,24 @@ fichiers servent d'aide et de tests.
|
||||
alphabétique, avec un champ de recherche en tête.
|
||||
- **Ton** : celui du maître de café, patient et concret. Pas de « Étant
|
||||
donné que », pas de vocabulaire technique (moteur, scénario, événement).
|
||||
|
||||
## Liens (ADR 0013, amendement : une adresse par écran)
|
||||
|
||||
Chaque élément qui mène ailleurs est un vrai lien (`<a href>`), qu'on peut
|
||||
ouvrir, partager et retrouver avec le bouton « retour » du téléphone.
|
||||
|
||||
| Élément | Adresse |
|
||||
| --- | --- |
|
||||
| « Je découvre », « Toutes les leçons » | `#/tutoriel/1` |
|
||||
| Étape *n* du tutoriel (« Continuer », « Retour », segments de la barre de progression) | `#/tutoriel/<n>` |
|
||||
| « Je connais les règles », « Reprendre la partie » | l'écran de jeu |
|
||||
| Un terme du jeu dans un texte (blot, barre, camp…) | `#/glossaire/<terme>` |
|
||||
| Une position illustrée, « Voir sur le plateau » | `#/position/<Position ID>/<Match ID>` |
|
||||
| Aide, onglet « Jouer » | `#/aide` ; une question ouverte : `#/aide/<scénario>` |
|
||||
| Menu | `#/menu` |
|
||||
|
||||
- Les segments de la barre de progression du tutoriel sont des liens vers les
|
||||
étapes déjà vues, pas vers les suivantes.
|
||||
- Le bouton « retour » du téléphone ferme d'abord la feuille ou la question
|
||||
ouverte, puis revient à l'écran précédent ; il ne quitte jamais une partie
|
||||
sans retour possible.
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
# Fin de partie et analyse (1e)
|
||||
|
||||
- **Date** : 2026-11-03
|
||||
- **Étape** : 1e (`docs/etape-1.md`)
|
||||
- **Statut** : prévu (après 1d)
|
||||
- **Exécutant** : sous-agent general-purpose, dans un worktree
|
||||
|
||||
## Objectif
|
||||
|
||||
Que le joueur comprenne sa partie : le bilan de fin de partie (ADR 0011), puis un écran d'analyse avec graphe d'équité, liste des erreurs, visualiseur et « Rejouer d'ici ».
|
||||
|
||||
## Consignes
|
||||
|
||||
Respecter les ADR 0007, 0008, 0011 et 0013. Chaque écran a son adresse : `#/parties/<id>/bilan` et `#/parties/<id>/analyse/<coup>`, à ajouter au tableau de l'ADR 0013 par un amendement. Pour la charte, suivre les maquettes « Fin de partie » du canevas. Aucune nouvelle dépendance : le graphe est dessiné en SVG à la main.
|
||||
|
||||
1. **Analyse de la partie.** Elle réutilise les analyses rangées par 1d (coups et videau) et complète celles qui manquent en arrière-plan, de façon progressive et annulable. Elle donne :
|
||||
- le taux d'erreur à la manière de gnubg ;
|
||||
- la chance aux dés (`luck`) pour chaque camp ;
|
||||
- le décompte des coups par appréciation.
|
||||
2. **Bilan**, dans le panneau de fin de partie existant :
|
||||
- le résultat ;
|
||||
- les coups par appréciation ;
|
||||
- le taux d'erreur comparé à la chance, en phrases simples ;
|
||||
- « le coup à revoir » : la plus grosse erreur, avec sa position, le coup joué et le meilleur coup ;
|
||||
- un emplacement pour le bouton « Demander à une IA » (1f) ;
|
||||
- « Voir l'analyse ».
|
||||
3. **Écran d'analyse** :
|
||||
- un graphe d'équité où toucher un point ouvre la position ;
|
||||
- la liste des erreurs, filtrable par appréciation ;
|
||||
- un visualiseur pas à pas qui montre sur le plateau le coup joué contre le meilleur coup, avec des pions animés ;
|
||||
- « Rejouer d'ici », qui crée une branche (domaine de 1b) sans toucher l'original.
|
||||
4. **Tests** :
|
||||
- scénarios Gherkin : bilan, coup à revoir, rejouer d'ici, chacun avec des dés imposés ;
|
||||
- fiches du glossaire : taux d'erreur, graphe d'équité si absentes ;
|
||||
- `make lint`, `make test`, `make test-browser`, avec des captures pour la recette design.
|
||||
|
||||
## Critères de réussite
|
||||
|
||||
- Après une partie, le bilan nomme le coup à revoir, conforme à l'analyse du moteur.
|
||||
- L'analyse s'ouvre par son adresse.
|
||||
- « Rejouer d'ici » crée une branche.
|
||||
- Les trois commandes passent.
|
||||
|
||||
## Résultat
|
||||
|
||||
À remplir à la clôture.
|
||||
@@ -0,0 +1,36 @@
|
||||
# Cinq premières leçons et accueil complet (1g)
|
||||
|
||||
- **Date** : 2026-11-03
|
||||
- **Étape** : 1g (`docs/etape-1.md`) ; le tutoriel des règles est traité en 0e.5
|
||||
- **Statut** : prévu (après 0e.5 et 1d)
|
||||
- **Exécutant** : sous-agent general-purpose, dans un worktree
|
||||
|
||||
## Objectif
|
||||
|
||||
Mettre en place les cinq premières leçons numérotées de l'ADR 0011. Chaque leçon est faite d'écrans courts : une explication, une position animée, puis « à vous de jouer ». La progression est stockée localement, et l'accueil propose « Continuer la leçon ».
|
||||
|
||||
## Consignes
|
||||
|
||||
1. **Contenus.** Ils vivent dans une source unique sous `docs/lecons/`, sous licence CC BY-SA 4.0, au même format que le tutoriel de 0e.5 (réutiliser son moteur d'écrans). Cinq leçons du parcours 2 du cadrage, au niveau débutant :
|
||||
1. Les ouvertures : bien jouer ses premiers dés.
|
||||
2. Construire ses cases et faire des points.
|
||||
3. Frapper ou ne pas frapper : sécurité et risque.
|
||||
4. La course et le compte de points.
|
||||
5. Sortir ses pions sans se faire frapper.
|
||||
|
||||
Chaque leçon compte deux à quatre exercices « à vous ». Leurs positions sont données par identifiants gnubg. La réponse attendue est le meilleur coup du moteur à 2 plis, vérifié par un test. Le texte n'affirme rien que le moteur ne confirme. Les contenus sont livrés en brouillon, marqués « à relire par un joueur fort » (cadrage, risque « contenus »), et Pierre les valide.
|
||||
2. **Progression.** Elle est enregistrée dans le stockage de 1b, dans le magasin `settings` ou dans un nouveau magasin avec montée de version (ADR 0007 amendé). Chaque leçon a son état : à commencer, « 2 exercices sur 4 », terminée.
|
||||
3. **Adresses** : `#/lecons` et `#/lecons/<n>/<écran>`, à ajouter au tableau de l'ADR 0013.
|
||||
4. **Accueil.** Il reçoit la carte « prochaine leçon » prévue par la charte.
|
||||
5. **Tests.**
|
||||
- Pour chaque exercice, la position est valide et la réponse attendue est bien le meilleur coup du moteur.
|
||||
- Scénarios Gherkin.
|
||||
- `make lint`, `make test`, `make test-browser`, avec captures.
|
||||
|
||||
## Critères de réussite
|
||||
|
||||
Les cinq leçons se suivent jusqu'au bout. Chaque exercice accepte le coup du moteur et commente les autres avec bienveillance. Les trois commandes passent.
|
||||
|
||||
## Résultat
|
||||
|
||||
À remplir à la clôture.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,32 @@
|
||||
# Plateau soigné : gestes et finitions (1i)
|
||||
|
||||
- **Date** : 2026-11-03
|
||||
- **Étape** : 1i (`docs/etape-1.md`)
|
||||
- **Statut** : prévu
|
||||
- **Exécutant** : sous-agent general-purpose, dans un worktree
|
||||
|
||||
## Objectif
|
||||
|
||||
Terminer le plateau soigné du cadrage, une fois la révision UX en place :
|
||||
- les gestes naturels (principe d'expérience 2) ;
|
||||
- les écarts restants avec `docs/identite/charte/board.js`.
|
||||
|
||||
## Consignes
|
||||
|
||||
1. **Glisser-déposer.** On peut glisser un pion en plus du toucher pion puis destination. Le pion suit le doigt, et le lâcher hors d'une case permise le ramène à sa place. Les cibles restent celles du moteur.
|
||||
2. **Double toucher.** Un double toucher sur un pion joue le seul coup évident (cadrage). Un « coup évident » est défini ainsi : un seul coup légal complet commence par ce pion, ou le moteur ne propose qu'un coup. Sinon, rien ne se passe.
|
||||
3. **Écarts restants avec board.js.** Les relever par comparaison visuelle (filet des pions, numéros sur le cadre, videau…) et les corriger.
|
||||
4. **Clavier.** Faire ce que la révision UX a laissé :
|
||||
- le choix des pions aux flèches ;
|
||||
- l'affichage des raccourcis en `kbd` quand un clavier est détecté (interaction.md).
|
||||
5. **Tests.**
|
||||
- Scénarios Gherkin si un nouveau comportement est visible.
|
||||
- `make lint`, `make test`, `make test-browser` (glisser-déposer simulé, double toucher), avec captures pour la recette design.
|
||||
|
||||
## Critères de réussite
|
||||
|
||||
Un coup se joue entièrement au glisser-déposer. Le double toucher joue le coup évident. Les trois commandes passent.
|
||||
|
||||
## Résultat
|
||||
|
||||
À remplir à la clôture.
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
# Progression (1h)
|
||||
|
||||
- **Date** : 2026-11-03
|
||||
- **Étape** : 1h (`docs/etape-1.md`)
|
||||
- **Statut** : prévu (après 1e)
|
||||
- **Exécutant** : sous-agent general-purpose, dans un worktree
|
||||
|
||||
## Objectif
|
||||
|
||||
Mettre en place la progression du cadrage pour la V1 : l'historique des parties et la courbe du taux d'erreur.
|
||||
|
||||
## Consignes
|
||||
|
||||
1. **Écran `#/progression`.** Il est conforme à la charte et s'ajoute au tableau de l'ADR 0013. Il montre :
|
||||
- la courbe du taux d'erreur par partie terminée, en SVG à la main, sans dépendance ;
|
||||
- la liste des parties, reliée à leur bilan et à leur analyse (1e) ;
|
||||
- un résumé en une phrase.
|
||||
2. **Accueil.** Il reçoit la « ligne de progression » de la charte.
|
||||
3. **Données.** Elles proviennent des analyses enregistrées : rien n'est recalculé si l'analyse existe. Une partie non analysée est analysée en arrière-plan.
|
||||
4. **Tests.**
|
||||
- Scénarios Gherkin.
|
||||
- `make lint`, `make test`, `make test-browser` avec trois parties jouées, et captures.
|
||||
|
||||
## Critères de réussite
|
||||
|
||||
Après trois parties, la courbe a trois points égaux aux taux d'erreur des bilans. Les trois commandes passent.
|
||||
|
||||
## Résultat
|
||||
|
||||
À remplir à la clôture.
|
||||
@@ -0,0 +1,42 @@
|
||||
# Prompts à copier (1f)
|
||||
|
||||
- **Date** : 2026-11-03
|
||||
- **Étape** : 1f (`docs/etape-1.md`)
|
||||
- **Statut** : prévu (après 1e)
|
||||
- **Exécutant** : sous-agent general-purpose, dans un worktree
|
||||
|
||||
## Objectif
|
||||
|
||||
Mettre en place la section « IA générative en V1 » du cadrage. Un bouton « Demander à une IA » copie un prompt rempli avec le contexte exact. Le moteur décide, l'IA explique. L'app n'appelle aucune IA.
|
||||
|
||||
## Consignes
|
||||
|
||||
1. **Gabarits.** Les cinq gabarits du cadrage sont versionnés dans une source unique sous `docs/prompts/`, sous licence CC BY-SA 4.0 :
|
||||
- « pourquoi ce coup est meilleur » ;
|
||||
- « expliquer cette décision de videau » ;
|
||||
- « bilan de ma partie » ;
|
||||
- « expliquer ce concept » ;
|
||||
- « créer des exercices », à garder pour la progression.
|
||||
|
||||
Chaque gabarit contient :
|
||||
- le rôle et le niveau du joueur ;
|
||||
- le Position ID et le Match ID ;
|
||||
- un diagramme texte de la position, produit à partir du plateau décodé par le moteur ;
|
||||
- les chiffres du moteur, avec le niveau d'évaluation ;
|
||||
- la consigne de ne pas contredire ces chiffres ;
|
||||
- la langue.
|
||||
|
||||
Ajouter aussi le lien `#/position/...` de l'ADR 0013, avec l'adresse publique du site.
|
||||
2. **Remplissage.** Une fonction pure remplit les gabarits. Elle est testée sur des analyses réelles du moteur : aucun champ vide ni `{inconnu}`, et les chiffres sont identiques à ceux du moteur.
|
||||
3. **Boutons.** Ils sont placés sur le conseil du maître (feuille d'explication), le bilan, l'analyse et la fiche d'un terme du glossaire. La copie passe par `navigator.clipboard`, avec un repli de sélection du texte. Un bandeau « Prompt copié » l'accompagne, et le texte reste visible pour qui veut le relire.
|
||||
4. **Tests.**
|
||||
- Scénarios Gherkin : copier le prompt d'une erreur, et celui d'un bilan.
|
||||
- `make lint`, `make test` et `make test-browser`, avec lecture du presse-papiers dans Chromium et captures.
|
||||
|
||||
## Critères de réussite
|
||||
|
||||
Le prompt copié après une erreur contient la position, les identifiants gnubg et le top 3 du moteur avec les mêmes chiffres que l'app. Les trois commandes passent.
|
||||
|
||||
## Résultat
|
||||
|
||||
À remplir à la clôture.
|
||||
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
+133
-8
@@ -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");
|
||||
|
||||
@@ -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, <nom>.sgf et <nom>.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"))
|
||||
+22
-1
@@ -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)"
|
||||
|
||||
|
||||
+41
@@ -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 {
|
||||
|
||||
@@ -93,6 +93,7 @@
|
||||
<button id="menu-close" type="button" class="icon-button"></button>
|
||||
</div>
|
||||
<button id="menu-new-game" type="button" class="item"></button>
|
||||
<button id="menu-games" type="button" class="item"></button>
|
||||
<button id="menu-fullscreen" type="button" class="item"></button>
|
||||
<button id="menu-help" type="button" class="item"></button>
|
||||
<button id="menu-theme" type="button" class="item"><span class="item-label"></span><span class="item-value"></span></button>
|
||||
|
||||
Reference in New Issue
Block a user