Compare commits

...
Author SHA1 Message Date
Pierre MARTINandClaude Opus 5.5 2b8fa1a0b0 Recette design des adresses : barre de l'aide collée, boutons sur une ligne, position en paysage
Barre haute de l'aide en position sticky ; question ou terme ouvert par
son adresse arrêté 12 px sous elle ; surlignage du terme effacé après 2 s.
Boutons en white-space: nowrap ; actions de la position empilées
(Jouer, Copier, Retour). En paysage, plateau à gauche sur toute la
hauteur avec la géométrie du jeu (videau entier, sans menu ni annuler),
colonne de 288 px à droite. Test navigateur : aucun bouton sur deux
lignes, barre de l'aide en vue, actions visibles sans défiler, videau
entier.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 18:52:50 +01:00
Pierre MARTINandClaude Opus 5.5 d1db2b33c8 Adresses des écrans : plan clos
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 14:41:06 +01:00
Pierre MARTIN f74b03fa0d Fusion de main dans adresses-ecrans 2026-11-09 14:41:03 +01:00
Pierre MARTINandClaude Opus 5.5 d26033b7bb Plan 0e.4 : résultat
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 14:40:16 +01:00
Pierre MARTINandClaude Opus 5.5 2f1d36413e Test navigateur : le coup joué a 10 s pour arriver dans IndexedDB
L'application enregistre sans attendre ; sur le disque dur de la machine
de développement, l'écriture de Firefox peut suivre de loin. En cas
d'échec, le message dit ce que montre l'écran.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 14:16:48 +01:00
Pierre MARTINandClaude Opus 5.5 9a5a0bb677 Fin de partie : la vibration attend la fin de l'animation du dernier coup
Le dessin lancé au début de l'animation annonçait la fin ; une prise de
l'ordinateur pendant ce dernier coup vibrait ensuite et la remplaçait (vu
dans Firefox par make test-browser).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 14:16:48 +01:00
Pierre MARTINandClaude Opus 5.5 b40b28d25b Fusion de la MR !39 : plan 0e.5, mise à jour de l'application en un toucher
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 13:46:28 +01:00
Pierre MARTINandClaude Opus 5.5 9a6e82a2db Plan 0e.5 : mise à jour de l'application en un toucher
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 13:45:27 +01:00
Pierre MARTINandClaude Opus 5.5 97a098e98f Navigation : un retour en cours retient l'adresse suivante ; test des adresses
history.go est asynchrone : une adresse demandée pendant un retour attend
son arrivée. Le test dans le navigateur ouvre chaque adresse du tableau de
l'ADR 0013 dans un nouvel onglet, vérifie le retour, le rôle link des liens
et l'aller-retour d'un lien de position, et range ses captures dans
browser-shots/adresses/.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 13:45:17 +01:00
Pierre MARTINandClaude Opus 5.5 f4914b30ab Une adresse pour chaque écran, de vrais liens pour y aller
Routeur pur (fragment vers écran et inversement) et navigation sur
hashchange : aide et ses questions, glossaire et ses termes, à propos,
menu, mes parties et chaque partie, vue d'une position avec « Jouer à
partir d'ici » et « Retour à ma partie », « Copier le lien de la
position ».

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 21:21:45 +01:00
Pierre MARTINandClaude Opus 5.5 43031d1ec6 Domaine : lire et écrire le lien d'une position (ADR 0013)
Le moteur écrit et décode le Match ID ; readPosition valide un lien,
linkOf donne celui d'une partie, setupOf la partie à jouer depuis la
position. Scénario « Partager une position », fiche « Lien de position ».

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 21:21:45 +01:00
Pierre MARTINandClaude Opus 5.5 edcbcea131 Fusion de la MR !38 : plans 1e à 1i
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 21:03:51 +01:00
Pierre MARTINandClaude Opus 5.5 5c75f29f32 Plans 1e à 1i : bilan et analyse, prompts, leçons, progression, plateau soigné
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 21:03:46 +01:00
Pierre MARTINandClaude Opus 5.5 3cdd017768 Fusion de la MR !37 : parties enregistrées, branches et export SGF (étape 1b)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 20:55:17 +01:00
Pierre MARTINandClaude Opus 5.5 19a54193f9 ADR 0007 : section Amendements
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 20:55:00 +01:00
Pierre MARTINandClaude Opus 5.5 2c4448549d Parties enregistrées : plan clos ; stockage déclaré format durable (ADR 0007)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 20:51:01 +01:00
Pierre MARTINandClaude Opus 5.5 d228d70387 Mes parties : la feuille se ferme à la reprise ; plan 1b : résultat
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 20:50:33 +01:00
Pierre MARTINandClaude Opus 5.5 e3aaa0b3a3 Stockage ouvert pendant le chargement du moteur ; test navigateur : nombre de parties
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 20:29:18 +01:00
Pierre MARTINandClaude Opus 5.5 71ee3605d4 Mes parties : suppression en deux touchers, sans boîte du navigateur
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 20:24:24 +01:00
Pierre MARTINandClaude Opus 5.5 196ccd4b9f Fusion de main (révision UX) ; Mes parties dans le menu, reprise sans rechargement
« Reprendre l'ancienne » relit l'ancienne partie dans le stockage par son
identifiant et supprime la nouvelle (GameSession).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 20:17:53 +01:00
pierre ae8a2d6ad2 Fusion de la MR !36 : liens 0e.5 2026-11-03 19:15:06 +00:00
Pierre MARTINandClaude Opus 5.5 9420bf8bd6 Charte : liens des écrans d'accueil, du tutoriel et de l'aide
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 20:15:03 +01:00
Pierre MARTINandClaude Opus 5.5 31815134e5 Mes parties : scénarios, glossaire, test navigateur sur IndexedDB
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 15:36:54 +01:00
Pierre MARTINandClaude Opus 5.5 274fbf6252 Parties enregistrées : IndexedDB, branches, export SGF relu par gnubg
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 15:29:01 +01:00
63 changed files with 5344 additions and 377 deletions
+2 -1
View File
@@ -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.
+91
View File
@@ -0,0 +1,91 @@
// Les branches d'une partie : les lignes de jeu de son journal.
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
//
// Le journal est un arbre (events.ts) : chaque événement pointe vers son
// parent, et « Rejouer d'ici » accroche un nouvel événement à un ancien. Une
// branche est le chemin du premier événement jusqu'à une feuille de l'arbre.
// Les identifiants des événements sont attribués dans l'ordre d'ajout : de
// deux enfants d'un même événement, le plus ancien a le plus petit indice
// dans le journal, et c'est lui qui continue la ligne d'origine.
import { currentLine, type GameEvent, type GameJournal, goBackTo } from "./events.js";
import { replay } from "./state.js";
/**
* Une branche de la partie : la suite d'événements du début jusqu'à head.
*
* Glossaire : docs/glossaire/branche.md
*/
export interface Branch {
/** Le dernier événement de la branche (une feuille de l'arbre). */
readonly head: string;
/** L'événement à partir duquel elle a été rejouée (« Rejouer d'ici ») ;
* null pour la ligne d'origine de la partie. */
readonly forkedFrom: string | null;
/** Les coups joués avant de rejouer (0 : depuis le début) ; 0 aussi pour
* la ligne d'origine. */
readonly forkMoveCount: number;
/** Les coups joués sur toute la branche, depuis le début. */
readonly moveCount: number;
/** La partie est finie au bout de cette branche. */
readonly over: boolean;
/** L'événement courant de la partie est sur cette branche. */
readonly containsHead: boolean;
}
/**
* Les branches de la partie, dans l'ordre de leur création (celui de leur
* premier événement propre) : la ligne d'origine d'abord. Liste vide pour un
* journal vide.
*/
export function branches(journal: GameJournal): Branch[] {
const order = new Map(journal.events.map((event, index) => [event.id, index]));
const children = new Map<string, GameEvent[]>();
for (const event of journal.events) {
if (event.parent !== null) {
children.set(event.parent, [...(children.get(event.parent) ?? []), event]);
}
}
const leaves = journal.events.filter((event) => !children.has(event.id));
return leaves
.map((leaf) => {
const line = currentLine({ events: journal.events, head: leaf.id });
// Le point de départ de la branche : le parent de l'événement le
// plus récent du chemin qui n'est pas le premier enfant de son
// parent (la ligne d'origine n'en a aucun).
let forkIndex = -1;
for (let i = line.length - 1; i > 0; i--) {
const event = line[i] as GameEvent;
const first = children.get(event.parent as string)?.[0];
if (first !== undefined && first.id !== event.id) {
forkIndex = i - 1;
break;
}
}
const moves = (events: readonly GameEvent[]) =>
events.filter((event) => event.type === "MovePlayed").length;
const fork = forkIndex >= 0 ? (line[forkIndex] as GameEvent) : null;
const start = line[forkIndex + 1] as GameEvent;
return {
created: order.get(start.id) ?? 0,
head: leaf.id,
forkedFrom: fork?.id ?? null,
forkMoveCount: fork === null ? 0 : moves(line.slice(0, forkIndex + 1)),
moveCount: moves(line),
over: replay(line).phase === "over",
containsHead: line.some((event) => event.id === journal.head),
};
})
.sort((a, b) => a.created - b.created)
.map(({ created: _, ...branch }) => branch);
}
/** Passe à une autre branche : la partie reprend au bout de celle-ci. */
export function switchToBranch(journal: GameJournal, head: string): GameJournal {
if (!branches(journal).some((branch) => branch.head === head)) {
throw new Error(`branche introuvable : ${head}`);
}
return goBackTo(journal, head);
}
+30
View File
@@ -74,6 +74,36 @@ export interface Engine {
/** La chance d'un jet pour le joueur au trait. */
luck(position: PositionId, cube: EngineCube, roll: Roll): Promise<Luck>;
/** Le Match ID d'une partie libre, joueur 0 au trait, avec ce videau vu
* de lui, sans dés. */
encodeMatch(cube: EngineCube): Promise<MatchId>;
/** Un Match ID décodé ; erreur s'il est illisible pour le moteur. */
decodeMatch(matchId: MatchId): Promise<MatchDescription>;
}
/**
* Identifiant de match (Match ID de gnubg, 12 caractères) : score, longueur
* du match, videau, joueur au trait. Le domaine ne le décode jamais
* lui-même : il le passe au moteur.
*
* Glossaire : docs/glossaire/identifiant-de-match.md
*/
export type MatchId = string;
/** Un Match ID décodé par le moteur. Les joueurs sont numérotés 0 et 1,
* comme dans gnubg. Les dés qu'il peut contenir ne sont pas lus. */
export interface MatchDescription {
/** 0 : partie libre. */
readonly matchLength: number;
readonly score: readonly [number, number];
readonly cubeValue: number;
/** -1 : videau au centre ; sinon le joueur qui le possède. */
readonly cubeOwner: -1 | 0 | 1;
readonly playerOnRoll: 0 | 1;
readonly crawford: boolean;
readonly jacoby: boolean;
}
/**
+14
View File
@@ -8,6 +8,7 @@
// (state.ts). Les règles viennent toutes du moteur : coups légaux, coup de
// l'ordinateur, videau, fin de partie et points.
import { type Branch, branches, switchToBranch } from "./branches.js";
import { CENTERED_CUBE, type Cube } from "./cube.js";
import type { BoardSide, Engine, EngineCube, Ply } from "./engine.js";
import {
@@ -415,6 +416,19 @@ export class Game {
this.#journal = goBackTo(this.#journal, eventId);
}
/** Les branches de la partie (voir branches.ts), la ligne d'origine
* d'abord. */
get branches(): Branch[] {
return branches(this.#journal);
}
/** Passe à une autre branche, désignée par son dernier événement : la
* partie reprend au bout de celle-ci ; les autres restent intactes. */
switchBranch(head: string): void {
this.cancelHalfMoves();
this.#journal = switchToBranch(this.#journal, head);
}
/** Annule le dernier événement : la partie repart de son parent. */
undo(): void {
const last = this.events.at(-1);
+126
View File
@@ -0,0 +1,126 @@
// Une position du plateau dans un lien (ADR 0013) : le Position ID et le
// Match ID de GNU Backgammon, lus et écrits par le moteur.
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
//
// Un lien décrit la position du point de vue du joueur au trait : c'est lui
// qu'on montre en bas (« vous »), et c'est lui que l'on joue avec « Jouer à
// partir d'ici ». Rien n'est décodé ici : le moteur valide et décode les deux
// identifiants (aucune règle de représentation n'est réimplémentée).
import type { Cube } from "./cube.js";
import type { BoardResult, Engine, EngineCube, MatchId } from "./engine.js";
import type { GameSetup, SideBoards } from "./game.js";
import type { PositionId } from "./position.js";
import type { Side } from "./side.js";
import type { GameState } from "./state.js";
/**
* Ce que porte l'adresse d'une position : son Position ID, et son Match ID
* s'il est donné (sans lui : partie libre, videau au centre).
*
* Glossaire : docs/glossaire/lien-de-position.md
*/
export interface PositionLink {
readonly position: PositionId;
readonly matchId: MatchId | null;
}
/** Une position partagée, lue par le moteur. Le camp « player » est celui
* au trait. */
export interface SharedPosition {
readonly link: PositionLink;
readonly sides: SideBoards;
readonly cube: Cube;
/** null en partie libre ; sinon la longueur du match et le score. */
readonly match: {
readonly length: number;
readonly score: Readonly<Record<Side, number>>;
} | null;
/** La partie est déjà finie dans cette position (null sinon). */
readonly result: BoardResult | null;
}
/** Un lien que le moteur ne sait pas lire. */
export class UnreadableLinkError extends Error {
constructor(reason: string) {
super(reason);
this.name = "UnreadableLinkError";
}
}
/**
* Lit une position partagée : le moteur décode le Match ID (s'il est
* donné), puis la position avec son videau. Erreur UnreadableLinkError si
* l'un des deux est illisible.
*/
export async function readPosition(engine: Engine, link: PositionLink): Promise<SharedPosition> {
let cube: EngineCube = { value: 1, owner: "centered" };
let match: SharedPosition["match"] = null;
if (link.matchId !== null) {
const description = await engine.decodeMatch(link.matchId).catch((error: unknown) => {
throw new UnreadableLinkError(`Match ID illisible : ${message(error)}`);
});
const onRoll = description.playerOnRoll;
cube = {
value: description.cubeValue,
owner:
description.cubeOwner === -1
? "centered"
: description.cubeOwner === onRoll
? "onRoll"
: "opponent",
};
if (description.matchLength > 0) {
match = {
length: description.matchLength,
score: {
player: description.score[onRoll],
computer: description.score[onRoll === 0 ? 1 : 0],
},
};
}
}
const board = await engine.board(link.position, cube).catch((error: unknown) => {
throw new UnreadableLinkError(`Position ID illisible : ${message(error)}`);
});
return {
link,
sides: { player: board.onRoll, computer: board.opponent },
cube: {
value: cube.value,
owner:
cube.owner === "centered" ? null : cube.owner === "onRoll" ? "player" : "computer",
},
match,
result: board.result,
};
}
/**
* Le lien de la position d'une partie : la position vue du camp au trait,
* et le Match ID que le moteur écrit pour son videau (partie libre, sans
* dés). Pendant le jet d'ouverture, la position de départ, vue du joueur.
*/
export async function linkOf(engine: Engine, state: GameState): Promise<PositionLink> {
const onRoll: Side = state.turn ?? "player";
const cube: EngineCube =
state.cube.owner === null
? { value: state.cube.value, owner: "centered" }
: {
value: state.cube.value,
owner: state.cube.owner === onRoll ? "onRoll" : "opponent",
};
return { position: state.position, matchId: await engine.encodeMatch(cube) };
}
/** La partie à commencer avec « Jouer à partir d'ici » : le joueur prend la
* place du camp au trait, avec le même videau ; partie libre. */
export function setupOf(shared: SharedPosition): GameSetup {
return { position: shared.link.position, firstTurn: "player", cube: shared.cube };
}
function message(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
+64
View File
@@ -0,0 +1,64 @@
// Une partie enregistrée, telle que la liste « Mes parties » la présente.
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
//
// Le journal reste la source (ADR 0007, point 3) : le résumé n'en est qu'une
// lecture, recalculée à chaque enregistrement, pour afficher la liste sans
// relire tous les journaux.
import { branches } from "./branches.js";
import type { Level } from "./engine.js";
import { currentLine, type GameJournal } from "./events.js";
import { type GameResult, replay } from "./state.js";
/**
* Le résumé d'une partie enregistrée.
*
* Glossaire : docs/glossaire/partie-enregistree.md
*/
export interface SavedGameSummary {
/** Identifiant de la partie, unique sur l'appareil. */
readonly id: string;
/** Début de la partie et dernière action, en millisecondes depuis 1970. */
readonly startedAt: number;
readonly updatedAt: number;
/** Niveau de l'ordinateur, l'adversaire. */
readonly level: Level;
/** En cours ou finie, sur la branche courante. */
readonly status: "playing" | "over";
/** Le résultat sur la branche courante (null si elle n'est pas finie). */
readonly result: GameResult | null;
/** Coups joués sur la branche courante. */
readonly moveCount: number;
/** Nombre de branches (1 : jamais rejouée). */
readonly branchCount: number;
}
/** Ce qu'il faut savoir d'une partie pour l'enregistrer, en plus de son
* journal. */
export interface SavedGameDetails {
readonly level: Level;
}
/** Le résumé d'un journal ; startedAt et updatedAt viennent de l'appelant
* (le domaine ne lit pas l'horloge). */
export function summarizeGame(
id: string,
journal: GameJournal,
details: SavedGameDetails,
times: { readonly startedAt: number; readonly updatedAt: number },
): SavedGameSummary {
const line = currentLine(journal);
const state = replay(line);
return {
id,
startedAt: times.startedAt,
updatedAt: times.updatedAt,
level: details.level,
status: state.phase === "over" ? "over" : "playing",
result: state.result,
moveCount: line.filter((event) => event.type === "MovePlayed").length,
branchCount: branches(journal).length,
};
}
+226
View File
@@ -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}`);
}
+37 -1
View File
@@ -22,12 +22,19 @@ import type {
Level,
Luck,
LuckRating,
MatchDescription,
MatchId,
Move,
MoveAnalysis,
Ply,
Skill,
} from "../domain/game/engine.js";
import { formatHalfMoves, type HalfMove, type PositionId } from "../domain/game/position.js";
import {
formatHalfMoves,
type HalfMove,
type PositionId,
START_POSITION,
} from "../domain/game/position.js";
import type { Roll } from "../domain/game/roll.js";
/** Exécute une commande d'engine-cli et renvoie son JSON décodé ; la
@@ -208,6 +215,35 @@ export function createWasmEngine(run: RunCommand): Engine {
return { value: answer.luck, rating };
},
encodeMatch: matchId,
async decodeMatch(id: MatchId): Promise<MatchDescription> {
// engine-cli n'a pas de commande qui décode seule un Match ID :
// l'évaluation à 0 pli (quelques millisecondes) le décode, le
// valide (SetCubeInfo de gnubg) et le renvoie lisible.
const answer = (await run(["--ply", "0", "eval", START_POSITION, id])) as {
match: {
match_length: number;
score: [number, number];
cube_value: number;
cube_owner: number;
player_on_roll: number;
crawford: boolean;
jacoby: boolean;
};
};
const m = answer.match;
return {
matchLength: m.match_length,
score: m.score,
cubeValue: m.cube_value,
cubeOwner: m.cube_owner === 0 || m.cube_owner === 1 ? m.cube_owner : -1,
playerOnRoll: m.player_on_roll === 1 ? 1 : 0,
crawford: m.crawford,
jacoby: m.jacoby,
};
},
async checkHalfMoves(
position: PositionId,
roll: Roll,
+83 -17
View File
@@ -6,9 +6,12 @@
// Licence : GPLv3 ou ultérieure (voir LICENSE).
import { Game } from "./domain/game/game.js";
import { linkOf, readPosition } from "./domain/game/position-link.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 +19,79 @@ 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: (setup) => Game.start(engine, setup === undefined ? {} : { setup }),
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: (setup) => storage().session.newGame(setup),
positions: {
read: (link) => readPosition(engine, link),
linkOf: (game) => linkOf(engine, game.state),
},
savedGame: () => storage().saved,
resumePrevious: () => storage().session.resumePrevious(),
saveGame: (journal) => storage().session.save(journal),
games: {
list: () => storage().library.list(),
journal: async (id) => (await storage().library.load(id))?.journal ?? null,
shownId: () => storage().session.shownId,
open: (id, branchHead) => storage().session.open(id, branchHead),
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 +109,12 @@ if ("serviceWorker" in navigator) {
const sw = new URL("../sw.js", import.meta.url);
worker.ready.then(() => navigator.serviceWorker.register(sw)).catch(() => {});
}
/** La valeur, ou undefined si l'obtenir lève une exception. */
function attempt<T>(get: () => T): T | undefined {
try {
return get();
} catch {
return undefined;
}
}
+140
View File
@@ -0,0 +1,140 @@
// Les parties enregistrées sur l'appareil : leur résumé, leur journal
// d'événements, et laquelle est la partie en cours.
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
//
// Deux stockages derrière la même interface (GameLibrary) :
// - IndexedDB, le stockage normal du navigateur (indexeddb-backend.ts) ;
// - un stockage clé-valeur (localStorage), en repli quand IndexedDB est
// indisponible, par exemple en navigation privée de certains navigateurs.
// Les opérations sont exécutées l'une après l'autre, dans l'ordre des
// appels : un enregistrement lancé sans être attendu est donc toujours fait
// avant la lecture qui suit.
import type { GameJournal } from "../domain/game/events.js";
import {
type SavedGameDetails,
type SavedGameSummary,
summarizeGame,
} from "../domain/game/saved-game.js";
/** Une partie enregistrée : son résumé et son journal. */
export interface SavedGame {
readonly summary: SavedGameSummary;
readonly journal: GameJournal;
}
export interface GameLibrary {
/** Les parties, la dernière jouée d'abord. */
list(): Promise<SavedGameSummary[]>;
/** Une partie, ou null si elle n'existe pas ou est illisible. */
load(id: string): Promise<SavedGame | null>;
/** Enregistre la partie id (nouvelle ou non), qui devient la partie en
* cours. Renvoie son résumé. */
save(id: string, journal: GameJournal, details: SavedGameDetails): Promise<SavedGameSummary>;
/** Supprime une partie (rien si elle n'existe pas). */
remove(id: string): Promise<void>;
/** La partie en cours : la dernière enregistrée ou choisie (setCurrent),
* si elle existe encore ; sinon la dernière jouée ; null s'il n'y en a
* aucune. */
current(): Promise<SavedGame | null>;
/** Fait d'une partie enregistrée la partie en cours, sans la modifier
* (pour la reprendre). */
setCurrent(id: string): Promise<void>;
}
/** Ce que chaque stockage sait faire ; GameLibrary ajoute l'ordre des
* opérations, les dates et la validation. */
export interface LibraryBackend {
summaries(): Promise<SavedGameSummary[]>;
summary(id: string): Promise<SavedGameSummary | undefined>;
journal(id: string): Promise<unknown>;
/** Écrit le résumé et le journal ensemble. */
write(summary: SavedGameSummary, journal: GameJournal): Promise<void>;
remove(id: string): Promise<void>;
currentId(): Promise<string | undefined>;
setCurrentId(id: string): Promise<void>;
}
export interface LibraryOptions {
/** L'horloge, en millisecondes ; Date.now par défaut. */
readonly now?: () => number;
}
/** Un identifiant de partie : la date et du hasard, sans dépendre de
* crypto.randomUUID, absent hors https. */
export function newGameId(now: number = Date.now()): string {
const random = Math.floor(Math.random() * 36 ** 6)
.toString(36)
.padStart(6, "0");
return `${now.toString(36)}-${random}`;
}
/** Un journal lisible : la forme attendue, sans juger de son contenu (le
* domaine le vérifie en reprenant la partie). */
export function isJournal(value: unknown): value is GameJournal {
if (typeof value !== "object" || value === null) {
return false;
}
const journal = value as { events?: unknown; head?: unknown };
return (
Array.isArray(journal.events) && (journal.head === null || typeof journal.head === "string")
);
}
export function createGameLibrary(
backend: LibraryBackend,
options: LibraryOptions = {},
): GameLibrary {
const now = options.now ?? Date.now;
let queue: Promise<unknown> = Promise.resolve();
/** Exécute op après toutes les opérations déjà demandées. */
const enqueue = <T>(op: () => Promise<T>): Promise<T> => {
const result = queue.then(op, op);
queue = result.catch(() => {});
return result;
};
const load = async (id: string): Promise<SavedGame | null> => {
const summary = await backend.summary(id);
const journal = await backend.journal(id);
return summary === undefined || !isJournal(journal) ? null : { summary, journal };
};
const list = async () => (await backend.summaries()).sort((a, b) => b.updatedAt - a.updatedAt);
return {
list: () => enqueue(list),
load: (id) => enqueue(() => load(id)),
save: (id, journal, details) =>
enqueue(async () => {
const time = now();
const startedAt = (await backend.summary(id))?.startedAt ?? time;
const summary = summarizeGame(id, journal, details, {
startedAt,
updatedAt: Math.max(time, startedAt),
});
await backend.write(summary, journal);
await backend.setCurrentId(id);
return summary;
}),
remove: (id) => enqueue(() => backend.remove(id)),
current: () =>
enqueue(async () => {
const id = await backend.currentId();
const chosen = id === undefined ? null : await load(id);
if (chosen !== null) {
return chosen;
}
const latest = (await list())[0];
return latest === undefined ? null : load(latest.id);
}),
setCurrent: (id) =>
enqueue(async () => {
if ((await backend.summary(id)) === undefined) {
throw new Error(`partie introuvable : ${id}`);
}
await backend.setCurrentId(id);
}),
};
}
+131
View File
@@ -0,0 +1,131 @@
// 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, GameSetup } 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: (setup?: GameSetup) => 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 (depuis la position de setup si elle est donnée) ;
* celle qu'elle remplace reste reprenable par resumePrevious, jusqu'au
* prochain changement de partie. */
newGame(setup?: GameSetup): 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(setup) {
previousId = shownId;
shownId = newGameId();
return games.start(setup);
},
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;
},
};
}
-47
View File
@@ -1,47 +0,0 @@
// L'enregistrement de la partie en cours, pour la reprendre après fermeture
// de la page : son journal d'événements, en JSON, dans un stockage clé-valeur
// (localStorage dans le navigateur).
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
import type { GameJournal } from "../domain/game/events.js";
/** Ce qu'il faut d'un stockage clé-valeur (localStorage en a davantage). */
export interface KeyValueStorage {
getItem(key: string): string | null;
setItem(key: string, value: string): void;
}
export interface GameStore {
/** Le journal enregistré, ou null s'il n'y en a pas de lisible. */
load(): GameJournal | null;
save(journal: GameJournal): void;
}
const KEY = "ustagammon.current-game";
export function createGameStore(storage: KeyValueStorage): GameStore {
return {
load() {
try {
const journal = JSON.parse(storage.getItem(KEY) ?? "null");
const valid =
journal !== null &&
Array.isArray(journal.events) &&
(journal.head === null || typeof journal.head === "string");
return valid ? journal : null;
} catch {
return null;
}
},
save(journal) {
try {
storage.setItem(KEY, JSON.stringify(journal));
} catch {
// Stockage plein ou interdit (navigation privée) : la partie
// continue, elle ne pourra seulement pas être reprise.
}
},
};
}
+81
View File
@@ -0,0 +1,81 @@
// Les parties dans IndexedDB, le stockage de données du navigateur.
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
//
// Base « ustagammon », trois magasins d'objets :
// - games : les résumés des parties (SavedGameSummary), clé id ;
// - journals : leurs journaux, { id, journal }, clé id ;
// - settings : { key, value }, dont la partie en cours (key « current »).
// Le résumé et le journal d'une partie sont écrits dans la même transaction.
// L'API IndexedDB est utilisée directement, sans bibliothèque.
import type { GameJournal } from "../domain/game/events.js";
import type { SavedGameSummary } from "../domain/game/saved-game.js";
import type { LibraryBackend } from "./game-library.js";
const DATABASE = "ustagammon";
const VERSION = 1;
const GAMES = "games";
const JOURNALS = "journals";
const SETTINGS = "settings";
/** Le résultat d'une requête IndexedDB, en promesse. */
function done<T>(request: IDBRequest<T>): Promise<T> {
return new Promise((resolve, reject) => {
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
/** La fin d'une transaction (toutes ses écritures faites), en promesse. */
function committed(transaction: IDBTransaction): Promise<void> {
return new Promise((resolve, reject) => {
transaction.oncomplete = () => resolve();
transaction.onerror = () => reject(transaction.error);
transaction.onabort = () => reject(transaction.error ?? new Error("transaction annulée"));
});
}
/** Ouvre la base, en la créant au besoin. Rejetée si IndexedDB est
* indisponible ou refusé. */
export async function openIndexedDbBackend(factory: IDBFactory): Promise<LibraryBackend> {
const request = factory.open(DATABASE, VERSION);
request.onupgradeneeded = () => {
const db = request.result;
db.createObjectStore(GAMES, { keyPath: "id" });
db.createObjectStore(JOURNALS, { keyPath: "id" });
db.createObjectStore(SETTINGS, { keyPath: "key" });
};
const db = await done(request);
const get = async <T>(store: string, key: string): Promise<T | undefined> =>
done(db.transaction(store).objectStore(store).get(key));
return {
summaries: async () => done(db.transaction(GAMES).objectStore(GAMES).getAll()),
summary: (id) => get<SavedGameSummary>(GAMES, id),
journal: async (id) => (await get<{ journal: unknown }>(JOURNALS, id))?.journal,
write: async (summary: SavedGameSummary, journal: GameJournal) => {
const transaction = db.transaction([GAMES, JOURNALS], "readwrite");
transaction.objectStore(JOURNALS).put({ id: summary.id, journal });
transaction.objectStore(GAMES).put(summary);
await committed(transaction);
},
remove: async (id) => {
const transaction = db.transaction([GAMES, JOURNALS], "readwrite");
transaction.objectStore(GAMES).delete(id);
transaction.objectStore(JOURNALS).delete(id);
await committed(transaction);
},
currentId: async () => {
const setting = await get<{ value: unknown }>(SETTINGS, "current");
return typeof setting?.value === "string" ? setting.value : undefined;
},
setCurrentId: async (id) => {
const transaction = db.transaction(SETTINGS, "readwrite");
transaction.objectStore(SETTINGS).put({ key: "current", value: id });
await committed(transaction);
},
};
}
+99
View File
@@ -0,0 +1,99 @@
// Les parties dans un stockage clé-valeur (localStorage), en JSON : le
// repli quand IndexedDB est indisponible, et la lecture de la partie
// enregistrée par les versions précédentes, pour la migrer.
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
import type { GameJournal } from "../domain/game/events.js";
import type { SavedGameSummary } from "../domain/game/saved-game.js";
import { type GameLibrary, isJournal, type LibraryBackend, newGameId } from "./game-library.js";
/** Ce qu'il faut d'un stockage clé-valeur (localStorage en a davantage). */
export interface KeyValueStorage {
getItem(key: string): string | null;
setItem(key: string, value: string): void;
removeItem(key: string): void;
}
const SUMMARIES = "ustagammon.games";
const CURRENT = "ustagammon.current-game-id";
const journalKey = (id: string) => `ustagammon.game.${id}`;
/** La partie unique des versions précédentes (étape 0), à migrer. */
const LEGACY_GAME = "ustagammon.current-game";
/** Lit une clé en JSON ; undefined si elle est absente, illisible, ou si le
* stockage est interdit. */
function read(storage: KeyValueStorage, key: string): unknown {
try {
const text = storage.getItem(key);
return text === null ? undefined : JSON.parse(text);
} catch {
return undefined;
}
}
export function createKeyValueBackend(storage: KeyValueStorage): LibraryBackend {
const summaries = (): SavedGameSummary[] => {
const value = read(storage, SUMMARIES);
return Array.isArray(value) ? value : [];
};
const writeSummaries = (list: readonly SavedGameSummary[]) =>
storage.setItem(SUMMARIES, JSON.stringify(list));
return {
summaries: async () => summaries(),
summary: async (id) => summaries().find((s) => s.id === id),
journal: async (id) => read(storage, journalKey(id)),
write: async (summary, journal) => {
// Le journal d'abord : un résumé ne désigne jamais un journal
// absent.
storage.setItem(journalKey(summary.id), JSON.stringify(journal));
writeSummaries([...summaries().filter((s) => s.id !== summary.id), summary]);
},
remove: async (id) => {
writeSummaries(summaries().filter((s) => s.id !== id));
storage.removeItem(journalKey(id));
},
currentId: async () => {
const id = read(storage, CURRENT);
return typeof id === "string" ? id : undefined;
},
setCurrentId: async (id) => storage.setItem(CURRENT, JSON.stringify(id)),
};
}
/**
* Reprend dans library la partie enregistrée par les versions précédentes
* (une seule partie, sous une clé de localStorage), qui devient la partie
* en cours ; puis efface l'ancienne clé. Sans effet s'il n'y en a pas. Un
* journal illisible est effacé. Renvoie l'identifiant de la partie migrée.
*/
export async function migrateLegacyGame(
storage: KeyValueStorage,
library: GameLibrary,
now: number = Date.now(),
): Promise<string | null> {
let present: boolean;
try {
present = storage.getItem(LEGACY_GAME) !== null;
} catch {
return null;
}
if (!present) {
return null;
}
const journal: unknown = read(storage, LEGACY_GAME);
let id: string | null = null;
if (isJournal(journal) && journal.events.length > 0) {
id = newGameId(now);
// Les parties de l'étape 0 se jouaient toutes au niveau standard.
await library.save(id, journal as GameJournal, { level: "2ply" });
}
try {
storage.removeItem(LEGACY_GAME);
} catch {
// Stockage interdit : la migration sera refaite, sans dommage.
}
return id;
}
+96
View File
@@ -0,0 +1,96 @@
// Ouverture des parties enregistrées au démarrage : IndexedDB si possible,
// sinon localStorage, puis migration de la partie des versions précédentes.
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
import { createGameLibrary, type GameLibrary, type LibraryOptions } from "./game-library.js";
import { openIndexedDbBackend } from "./indexeddb-backend.js";
import {
createKeyValueBackend,
type KeyValueStorage,
migrateLegacyGame,
} from "./key-value-backend.js";
/** Les stockages du navigateur ; chacun peut manquer ou être refusé. */
export interface BrowserStorage {
readonly indexedDB?: IDBFactory;
readonly localStorage?: KeyValueStorage;
}
export interface OpenedLibrary {
readonly library: GameLibrary;
/** Le stockage retenu : IndexedDB, son repli localStorage, ou la seule
* mémoire de la page (rien n'est gardé après sa fermeture). */
readonly kind: "indexeddb" | "localStorage" | "memory";
}
/** Au-delà, IndexedDB est tenu pour indisponible (une ouverture bloquée ne
* doit pas empêcher de jouer). */
const OPEN_TIMEOUT_MS = 5000;
export async function openGameLibrary(
storage: BrowserStorage,
options: LibraryOptions = {},
): Promise<OpenedLibrary> {
const opened = await open(storage, options);
if (storage.localStorage !== undefined) {
try {
await migrateLegacyGame(storage.localStorage, opened.library);
} catch {
// Migration impossible (stockage plein ou refusé) : l'ancienne
// partie reste où elle est, on la retentera au prochain démarrage.
}
}
return opened;
}
async function open(storage: BrowserStorage, options: LibraryOptions): Promise<OpenedLibrary> {
if (storage.indexedDB !== undefined) {
let timer: ReturnType<typeof setTimeout> | undefined;
try {
const backend = await Promise.race([
openIndexedDbBackend(storage.indexedDB),
new Promise<never>((_, reject) => {
timer = setTimeout(() => reject(new Error("IndexedDB muet")), OPEN_TIMEOUT_MS);
}),
]);
return { library: createGameLibrary(backend, options), kind: "indexeddb" };
} catch {
// Indisponible (navigation privée de certains navigateurs) : repli.
} finally {
clearTimeout(timer);
}
}
if (storage.localStorage !== undefined && usable(storage.localStorage)) {
return {
library: createGameLibrary(createKeyValueBackend(storage.localStorage), options),
kind: "localStorage",
};
}
return {
library: createGameLibrary(createKeyValueBackend(memoryStorage()), options),
kind: "memory",
};
}
/** localStorage peut exister et refuser toute écriture. */
function usable(storage: KeyValueStorage): boolean {
try {
storage.setItem("ustagammon.probe", "1");
storage.removeItem("ustagammon.probe");
return true;
} catch {
return false;
}
}
/** Un stockage clé-valeur en mémoire. */
export function memoryStorage(): KeyValueStorage {
const items = new Map<string, string>();
return {
getItem: (key) => items.get(key) ?? null,
setItem: (key, value) => void items.set(key, value),
removeItem: (key) => void items.delete(key),
};
}
+4 -2
View File
@@ -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);
+477 -66
View File
@@ -14,11 +14,19 @@
// d'information, consigne, barre d'actions) ; en paysage, le plateau est
// l'écran et porte ses commandes (ADR 0012). Toutes les actions passent par
// une même table (actions), qu'on les déclenche au toucher ou au clavier.
//
// Navigation (ADR 0013, amendement du 2026-11-03) : chaque écran a son
// adresse, et l'adresse est la source de vérité. On change d'écran par de
// vrais liens (aide, glossaire, menu, mes parties, position) ; applyRoute
// dessine l'écran que désigne l'adresse, au démarrage et à chaque
// changement. Les actions de jeu restent des boutons.
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 type { Game, GameSetup, MoveSteps, SideBoards } from "../domain/game/game.js";
import { formatHalfMoves, type HalfMove } from "../domain/game/position.js";
import { type PositionLink, type SharedPosition, setupOf } from "../domain/game/position-link.js";
import type { SavedGameSummary } from "../domain/game/saved-game.js";
import { otherSide, type Side } from "../domain/game/side.js";
import {
boardModel,
@@ -39,10 +47,14 @@ 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";
import { createHelpView, loadGlossaryEntry } from "./help.js";
import { icon } from "./icons.js";
import { replacingLink, startNavigation } from "./navigation.js";
import { isHelpScreen, type Route, routeHash } from "./router.js";
import { longPrompts, nextTheme, parseSettings, type Settings } from "./settings.js";
import { type PromptText, TEXTS } from "./texts.js";
@@ -64,13 +76,19 @@ export interface CachedBoard {
export interface AppOptions {
/** Résolue quand le moteur est chargé, rejetée s'il ne peut pas l'être. */
readonly engineReady: Promise<unknown>;
/** Une nouvelle partie (le jet d'ouverture est déjà tiré). */
readonly newGame: () => Game;
/** Une nouvelle partie (le jet d'ouverture est déjà tiré), ou depuis
* une position donnée (setup). */
readonly newGame: (setup?: GameSetup) => Game;
/** Les liens vers une position (ADR 0013). */
readonly positions: PositionsAccess;
/** 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 +97,28 @@ 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 }>;
}
/** Ce que la vue d'une position demande au domaine et au moteur. */
export interface PositionsAccess {
/** La position d'un lien, lue par le moteur (erreur si illisible). */
readonly read: (link: PositionLink) => Promise<SharedPosition>;
/** Le lien de la position d'une partie. */
readonly linkOf: (game: Game) => Promise<PositionLink>;
}
function byId<T extends HTMLElement>(id: string): T {
const element = document.getElementById(id);
if (element === null) {
@@ -142,20 +182,32 @@ export function startApp(options: AppOptions): void {
again: byId<HTMLButtonElement>("again"),
moveHint: byId("move-hint"),
replay: byId<HTMLButtonElement>("replay"),
helpButton: byId<HTMLButtonElement>("help-button"),
copyLink: byId<HTMLButtonElement>("copy-link"),
helpButton: byId<HTMLAnchorElement>("help-button"),
fullscreenButton: byId<HTMLButtonElement>("fullscreen-button"),
menuButton: byId<HTMLButtonElement>("menu-button"),
helpBack: byId<HTMLButtonElement>("help-back"),
menuButton: byId<HTMLAnchorElement>("menu-button"),
helpBack: byId<HTMLAnchorElement>("help-back"),
helpTabs: byId("help-tabs"),
game: byId("game"),
help: byId("help"),
helpContent: byId("help-content"),
position: byId("position-view"),
positionTitle: byId("position-title"),
positionMessage: byId("position-message"),
positionBoard: byId("position-board") as unknown as SVGSVGElement,
positionDetail: byId("position-detail"),
positionBack: byId<HTMLAnchorElement>("position-back"),
positionPlay: byId<HTMLButtonElement>("position-play"),
positionCopy: byId<HTMLButtonElement>("position-copy"),
menuVeil: byId("menu-veil"),
menu: byId("menu"),
menuTitle: byId("menu-title"),
menuClose: byId<HTMLButtonElement>("menu-close"),
menuNewGame: byId<HTMLButtonElement>("menu-new-game"),
menuGames: byId<HTMLAnchorElement>("menu-games"),
menuCopyLink: byId<HTMLButtonElement>("menu-copy-link"),
menuFullscreen: byId<HTMLButtonElement>("menu-fullscreen"),
menuHelp: byId<HTMLButtonElement>("menu-help"),
menuHelp: byId<HTMLAnchorElement>("menu-help"),
menuTheme: byId<HTMLButtonElement>("menu-theme"),
menuVibrations: byId<HTMLButtonElement>("menu-vibrations"),
menuScreenOn: byId<HTMLButtonElement>("menu-screen-on"),
@@ -197,7 +249,8 @@ export function startApp(options: AppOptions): void {
// ---------------------------------------------------- Textes fixes
ui.board.setAttribute("aria-label", TEXTS.boardLabel);
const label = (button: HTMLButtonElement, text: string, name?: Parameters<typeof icon>[0]) => {
ui.positionBoard.setAttribute("aria-label", TEXTS.boardLabel);
const label = (button: HTMLElement, text: string, name?: Parameters<typeof icon>[0]) => {
button.replaceChildren();
if (name !== undefined) {
button.append(icon(name));
@@ -213,6 +266,9 @@ export function startApp(options: AppOptions): void {
ui.replay.append(icon("replay"));
ui.replay.setAttribute("aria-label", TEXTS.replayTitle);
ui.replay.title = TEXTS.replayTitle;
ui.copyLink.append(icon("copy"));
ui.copyLink.setAttribute("aria-label", TEXTS.copyPositionLink);
ui.copyLink.title = TEXTS.copyPositionLink;
ui.roll.textContent = TEXTS.roll;
ui.double.textContent = TEXTS.double;
ui.take.textContent = TEXTS.take;
@@ -225,10 +281,20 @@ export function startApp(options: AppOptions): void {
ui.doubleConfirm.textContent = TEXTS.double;
ui.doubleCancel.textContent = TEXTS.cancel;
ui.helpBack.textContent = TEXTS.back;
ui.helpTabs.setAttribute("aria-label", TEXTS.helpTabs);
for (const tab of ui.helpTabs.querySelectorAll<HTMLElement>("a[data-screen]")) {
const screen = tab.dataset.screen as "help" | "glossary" | "about";
tab.textContent = TEXTS.helpTabNames[screen];
}
ui.positionBack.textContent = TEXTS.backToGame;
ui.positionPlay.textContent = TEXTS.playFromHere;
label(ui.positionCopy, TEXTS.copyPositionLink, "copy");
ui.menuTitle.textContent = TEXTS.menu;
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.menuCopyLink, TEXTS.copyPositionLink, "copy");
label(ui.menuHelp, TEXTS.helpAndGlossary, "help");
ui.menuTheme.prepend(icon("theme"));
child(ui.menuTheme, ".item-label").textContent = TEXTS.theme;
@@ -238,7 +304,12 @@ export function startApp(options: AppOptions): void {
child(ui.menuScreenOn, ".item-label").textContent = TEXTS.keepScreenOn;
ui.bannerText.textContent = TEXTS.newGameStarted;
ui.resumeOld.textContent = TEXTS.resumeOld;
child(ui.bubble, ".link-button").textContent = TEXTS.seeGlossary;
const glossaryLink = child<HTMLAnchorElement>(ui.bubble, ".link-button");
glossaryLink.textContent = TEXTS.seeGlossary;
glossaryLink.href = routeHash({
screen: "glossary",
term: TEXTS.pipCountFile.replace(/\.md$/, ""),
});
// ------------------------------------------------ État de l'écran
@@ -264,12 +335,23 @@ 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é.
let immersive = false;
let rotated = false;
let bubbleSide: Side | null = null;
// Le lien de la position de la partie, recalculé à chaque mise à jour :
// « Copier le lien » écrit alors tout de suite dans le presse-papiers.
let currentLink: PositionLink | null = null;
// Un message lié à l'adresse (adresse inconnue, lien copié…), montré
// dans la ligne d'information jusqu'à la prochaine action.
let notice: string | null = null;
// La position partagée affichée (#/position/...), une fois lue.
let shownPosition: SharedPosition | null = null;
let positionRequest = 0;
const landscapeQuery = window.matchMedia("(orientation: landscape) and (max-height: 40rem)");
const portraitQuery = window.matchMedia("(orientation: portrait)");
@@ -325,6 +407,7 @@ export function startApp(options: AppOptions): void {
if (game.pendingHalfMoves.length === 0) {
options.saveBoard({ sides, cube: game.state.cube });
}
currentLink = await options.positions.linkOf(game).catch(() => null);
}
/** Recalcule ce qui demande le moteur, puis redessine. */
@@ -379,7 +462,9 @@ export function startApp(options: AppOptions): void {
// Ligne d'information : un jet sans coup possible reste annoncé
// jusqu'à l'action suivante, même après un rechargement de la page.
if (stuck && state.roll !== null) {
if (notice !== null) {
ui.info.textContent = notice;
} else if (stuck && state.roll !== null) {
ui.info.textContent = TEXTS.youCannotMove(state.roll);
} else if (computerCouldNotMove(game)) {
ui.info.textContent = TEXTS.computerCannotMove(rollBefore(game));
@@ -410,6 +495,8 @@ export function startApp(options: AppOptions): void {
show(ui.drop, doubled);
show(ui.again, over && panelDismissed);
show(ui.replay, shownMove !== null);
// Le coup de l'ordinateur montré : sa position se copie en lien.
show(ui.copyLink, shownMove !== null && currentLink !== null);
for (const button of [
ui.roll,
ui.double,
@@ -420,9 +507,11 @@ export function startApp(options: AppOptions): void {
ui.replay,
ui.again,
ui.menuNewGame,
ui.positionPlay,
]) {
button.disabled = busy;
}
ui.menuCopyLink.disabled = currentLink === null;
ui.cancel.disabled = busy || game.pendingHalfMoves.length === 0;
renderPanel(game, landscape, doubled);
renderMenu();
@@ -462,11 +551,17 @@ export function startApp(options: AppOptions): void {
ui.skip,
ui.again,
ui.replay,
ui.copyLink,
]) {
button.hidden = true;
}
ui.moveHint.hidden = true;
ui.menuNewGame.disabled = true;
ui.menuCopyLink.disabled = true;
ui.positionPlay.disabled = true;
if (notice !== null) {
ui.info.textContent = notice;
}
renderMenu();
}
@@ -551,7 +646,9 @@ export function startApp(options: AppOptions): void {
/** Une seule vibration par fin de partie. */
function announceEnd(g: Game): void {
const head = g.journal.head;
if (g.state.phase !== "over" || head === announcedEnd) {
// Pendant l'animation du dernier coup, une prise vibrerait après
// la fin et la remplacerait : on l'annonce au dessin qui suit.
if (g.state.phase !== "over" || head === announcedEnd || animating) {
return;
}
announcedEnd = head;
@@ -701,6 +798,7 @@ export function startApp(options: AppOptions): void {
targets = new Map();
shownMove = null;
bubbleSide = null;
notice = null;
ui.bubble.hidden = true;
render();
try {
@@ -714,18 +812,24 @@ export function startApp(options: AppOptions): void {
}
}
function startNewGame(): void {
game = options.newGame();
function startNewGame(setup?: GameSetup): void {
game = options.newGame(setup);
panelDismissed = false;
announcedEnd = null;
saveSettings({ gamesStarted: settings.gamesStarted + 1 });
const opening = game.events.findLast((e) => e.type === "DiceRolled");
info =
opening?.type === "DiceRolled" ? TEXTS.opening(opening.roll[0], opening.roll[1]) : "";
setup !== undefined
? TEXTS.startedFromPosition
: opening?.type === "DiceRolled"
? TEXTS.opening(opening.roll[0], opening.roll[1])
: "";
}
/** « Nouvelle partie » : tout de suite, sans confirmation ; une partie en
* cours reste reprenable pendant 10 s (interaction.md). */
function newGame(): void {
/** « Nouvelle partie » (ou « Jouer à partir d'ici », depuis une
* position) : tout de suite, sans confirmation ; une partie en cours
* reste reprenable pendant 10 s (interaction.md). */
function newGame(setup?: GameSetup): void {
closeOverlays();
if (busy || game === null) {
return;
@@ -734,7 +838,7 @@ export function startApp(options: AppOptions): void {
if (old !== null) {
showBanner(old);
}
void act(() => startNewGame());
void act(() => startNewGame(setup));
}
function showBanner(journal: GameJournal): void {
@@ -766,14 +870,48 @@ 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 = "";
}
// L'adresse de la page dit quel écran montrer (applyRoute, plus bas).
const navigation = startNavigation((route) => applyRoute(route));
const isSheet = (route: Route) => route.screen === "menu" || route.screen === "games";
// « 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();
leaveSheet();
await act(() => showGame(other));
},
remove: options.games.remove,
exportSgf: options.games.exportSgf,
onClose: () => leaveSheet(),
});
const helpView = createHelpView(ui.helpContent, ui.helpTabs);
/** 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 {
@@ -868,32 +1006,37 @@ export function startApp(options: AppOptions): void {
void settle(true);
},
again: () => newGame(),
// Le menu a son adresse : l'ouvrir, c'est aller à #/menu (comme son
// lien) ; le refermer, revenir à l'écran d'avant.
menu: () => {
if (ui.menuVeil.hidden) {
if (!ui.menuVeil.hidden) {
closeOverlays();
renderMenu();
openOverlay(ui.menuVeil, ui.menuNewGame);
} else {
closeOverlays();
closeTransient();
navigation.go("#/menu");
}
},
help: () => {
closeOverlays();
ui.game.hidden = true;
ui.help.hidden = false;
ui.root.classList.add("help-open");
window.scrollTo(0, 0);
void showHelp(ui.helpContent);
closeTransient();
navigation.go("#/aide");
},
fullscreen: () => {
closeOverlays();
void (immersive ? exitImmersive() : enterImmersive());
},
// Échap : la confirmation ou la bulle ouverte, sinon l'écran courant
// (feuille, aide, position), comme le retour du téléphone.
close: () => {
if (!ui.help.hidden) {
closeHelp();
} else {
closeOverlays();
const route = navigation.route;
if (!ui.doubleVeil.hidden || !ui.bubble.hidden) {
closeTransient();
render();
} else if (isSheet(route)) {
leaveSheet();
} else if (isHelpScreen(route)) {
navigation.leave(isHelpScreen);
} else if (route.screen === "position") {
navigation.leave((r) => r.screen === "position");
}
},
};
@@ -907,11 +1050,10 @@ export function startApp(options: AppOptions): void {
focus.focus();
}
/** Ferme le menu, la confirmation du double et la bulle ; ne touche
* jamais à la partie. */
function closeOverlays(): void {
const open = !ui.menuVeil.hidden || !ui.doubleVeil.hidden;
ui.menuVeil.hidden = true;
/** Ferme la confirmation du double et la bulle, qui n'ont pas
* d'adresse ; ne touche jamais à la partie. */
function closeTransient(): void {
const open = !ui.doubleVeil.hidden;
ui.doubleVeil.hidden = true;
bubbleSide = null;
ui.bubble.hidden = true;
@@ -920,24 +1062,40 @@ export function startApp(options: AppOptions): void {
}
}
function closeHelp(): void {
ui.help.hidden = true;
ui.game.hidden = false;
ui.root.classList.remove("help-open");
render();
/** Ferme aussi le menu ou « Mes parties » : on quitte leur adresse. */
function closeOverlays(): void {
closeTransient();
if (isSheet(navigation.route)) {
leaveSheet();
}
}
for (const veil of [ui.menuVeil, ui.doubleVeil]) {
veil.addEventListener("click", (event) => {
if (event.target === veil) {
closeOverlays();
}
});
/** Quitte le menu ou « Mes parties » : la feuille se ferme tout de
* suite, l'adresse suit (le retour dans l'historique est asynchrone). */
function leaveSheet(): void {
const menuWasOpen = !ui.menuVeil.hidden;
ui.menuVeil.hidden = true;
gamesView?.hide();
if (menuWasOpen) {
(menuReturnFocus ?? ui.menuButton).focus();
}
navigation.leave(isSheet);
}
ui.doubleCancel.onclick = () => closeOverlays();
ui.menuVeil.addEventListener("click", (event) => {
if (event.target === ui.menuVeil) {
closeOverlays();
}
});
ui.doubleVeil.addEventListener("click", (event) => {
if (event.target === ui.doubleVeil) {
closeTransient();
}
});
ui.doubleCancel.onclick = () => closeTransient();
ui.doubleConfirm.onclick = () => {
closeOverlays();
closeTransient();
void act(async (g) => {
await g.double();
info = g.state.phase === "over" ? "" : TEXTS.computerTakes(g.state.cube.value);
@@ -962,17 +1120,231 @@ export function startApp(options: AppOptions): void {
// --------------------------------------------------------- Menu
ui.menuButton.onclick = actions.menu;
// Le bouton Menu et l'aide sont des liens (#/menu, #/aide) : rien à
// brancher. Les liens du menu remplacent son adresse : le retour du
// téléphone ne rouvre pas le menu.
replacingLink(ui.menuGames, navigation);
replacingLink(ui.menuHelp, navigation);
ui.menuClose.onclick = () => closeOverlays();
ui.menuNewGame.onclick = () => newGame();
ui.menuCopyLink.onclick = () => {
const link = currentLink;
closeOverlays();
if (link !== null) {
void copyLink(link);
}
};
ui.menuFullscreen.onclick = actions.fullscreen;
ui.menuHelp.onclick = actions.help;
ui.menuTheme.onclick = () => saveSettings({ theme: nextTheme(settings.theme) });
ui.menuVibrations.onclick = () => saveSettings({ vibrations: !settings.vibrations });
ui.menuScreenOn.onclick = () => saveSettings({ keepScreenOn: !settings.keepScreenOn });
ui.helpButton.onclick = actions.help;
ui.helpBack.onclick = closeHelp;
ui.fullscreenButton.onclick = actions.fullscreen;
// « Retour » : à l'écran d'avant l'aide (la partie, si l'aide a été
// ouverte par son adresse).
leavingLink(ui.helpBack, isHelpScreen);
leavingLink(ui.positionBack, (route) => route.screen === "position");
/** Un lien « Retour » : il revient dans l'historique plutôt que d'y
* empiler la partie (sauf ouvert dans un nouvel onglet). */
function leavingLink(link: HTMLAnchorElement, stay: (route: Route) => boolean): void {
link.addEventListener("click", (event) => {
if (event.button !== 0 || event.ctrlKey || event.metaKey || event.shiftKey) {
return;
}
event.preventDefault();
navigation.leave(stay);
});
}
// ----------------------------------------------------------- Adresses
let menuReturnFocus: HTMLElement | null = null;
/** Dessine l'écran que désigne l'adresse (ADR 0013, amendement). */
function applyRoute(route: Route): void {
const help = isHelpScreen(route);
const position = route.screen === "position";
const wasPage = ui.game.hidden;
ui.game.hidden = help || position;
ui.help.hidden = !help;
ui.position.hidden = !position;
ui.root.classList.toggle("page-open", help || position);
if (wasPage && !ui.game.hidden) {
window.scrollTo(0, 0);
}
// Feuilles : le menu et « Mes parties ».
const menuOpen = route.screen === "menu";
if (menuOpen && ui.menuVeil.hidden) {
closeTransient();
menuReturnFocus =
document.activeElement instanceof HTMLElement ? document.activeElement : null;
renderMenu();
ui.menuVeil.hidden = false;
ui.menuNewGame.focus();
} else if (!menuOpen && !ui.menuVeil.hidden) {
ui.menuVeil.hidden = true;
if (route.screen === "game") {
(menuReturnFocus ?? ui.menuButton).focus();
}
}
if (route.screen === "games") {
const expanded = route.game;
// La liste vient du stockage, ouvert avec le moteur.
void options.engineReady.then(
() => {
const now = navigation.route;
if (now.screen === "games" && now.game === expanded) {
void gamesView?.show(expanded);
}
},
() => undefined,
);
} else {
gamesView?.hide();
}
if (help) {
void helpView.show(route);
}
if (position) {
void showPosition(route);
} else {
positionRequest++;
shownPosition = null;
}
// Écrans à venir (0e.5) et adresses inconnues : la partie, avec un
// message, à l'adresse de la partie.
if (route.screen === "home" || route.screen === "tutorial" || route.screen === "unknown") {
notice = route.screen === "unknown" ? TEXTS.unknownAddress : TEXTS.screenNotYet;
navigation.replace("#/");
return;
}
render();
}
// -------------------------------------------- Vue d'une position
/** #/position/... : lit la position par le moteur, puis la montre. */
async function showPosition(route: Extract<Route, { screen: "position" }>): Promise<void> {
const request = ++positionRequest;
shownPosition = null;
renderPosition({ kind: "loading" });
try {
await options.engineReady;
const shared = await options.positions.read({
position: route.position,
matchId: route.match,
});
if (request === positionRequest) {
shownPosition = shared;
renderPosition({ kind: "shown", shared });
}
} catch {
if (request === positionRequest) {
renderPosition({ kind: "unreadable" });
}
}
}
type PositionState =
| { kind: "loading" }
| { kind: "unreadable" }
| { kind: "shown"; shared: SharedPosition };
function renderPosition(state: PositionState): void {
const shown = state.kind === "shown";
ui.positionTitle.textContent =
state.kind === "unreadable" ? TEXTS.unreadablePositionTitle : TEXTS.positionTitle;
ui.positionMessage.textContent =
state.kind === "loading"
? TEXTS.prompts.loading.short
: state.kind === "unreadable"
? TEXTS.unreadablePosition
: "";
ui.positionBoard.toggleAttribute("hidden", !shown);
ui.position.classList.toggle("with-board", shown);
ui.positionDetail.hidden = !shown;
ui.positionCopy.hidden = !shown;
ui.positionPlay.hidden = state.kind !== "shown" || state.shared.result !== null;
if (state.kind !== "shown") {
return;
}
const shared = state.shared;
// En paysage, le plateau occupe toute la hauteur, à gauche de la
// colonne du titre et des actions (app.css), avec la géométrie du
// jeu : la colonne gauche du cadre garde le videau, sans menu ni
// bouton annuler (il ne se joue pas).
const landscape = layout() === "landscape";
const box = ui.positionBoard.getBoundingClientRect();
const width = box.width || Math.min(window.innerWidth, 640);
const height = box.height || window.innerHeight;
const model = boardModel(shared.sides);
renderBoard(
ui.positionBoard,
landscape
? boardGeometry("landscape", width / height, height / 520)
: boardGeometry("portrait", PORTRAIT_ASPECT, width / 640),
model,
{
dice: [],
cube: shared.cube,
...NO_MARKS,
band: null,
controls: null,
},
);
ui.positionDetail.textContent = TEXTS.positionDetail(
model.pipCount,
shared.cube,
shared.match,
shared.result === null
? null
: shared.result.winner === "onRoll"
? "player"
: "computer",
);
}
ui.positionPlay.onclick = () => {
const shared = shownPosition;
if (shared === null || busy || game === null) {
return;
}
// La nouvelle partie est l'écran de jeu, à la place de la position
// (le retour du téléphone ne rouvre pas la position).
navigation.replace("#/");
newGame(setupOf(shared));
};
ui.positionCopy.onclick = () => {
if (shownPosition !== null) {
void copyLink(shownPosition.link, ui.positionMessage);
}
};
ui.copyLink.onclick = () => {
if (currentLink !== null) {
void copyLink(currentLink);
}
};
/** Copie l'adresse complète d'une position ; le résultat s'affiche dans
* target (par défaut, la ligne d'information de la partie). */
async function copyLink(link: PositionLink, target?: HTMLElement): Promise<void> {
const url = new URL(location.href);
url.hash = routeHash({
screen: "position",
position: link.position,
match: link.matchId,
});
const text = (await copyText(url.href)) ? TEXTS.linkCopied : TEXTS.linkNotCopied(url.href);
if (target === undefined) {
notice = text;
render();
} else {
target.textContent = text;
}
}
// ------------------------------------------------- Plein écran (ADR 0012)
@@ -1043,6 +1415,9 @@ export function startApp(options: AppOptions): void {
if (!animating) {
render();
}
if (shownPosition !== null) {
renderPosition({ kind: "shown", shared: shownPosition });
}
});
};
window.addEventListener("resize", relayout);
@@ -1073,7 +1448,6 @@ export function startApp(options: AppOptions): void {
for (const side of ["computer", "player"] as const) {
ui.strips[side].count.onclick = () => void toggleBubble(side);
}
child<HTMLButtonElement>(ui.bubble, ".link-button").onclick = actions.help;
// ------------------------------------------------------ Touchers
@@ -1096,6 +1470,10 @@ export function startApp(options: AppOptions): void {
closeOverlays();
render();
}
// Un lien du plateau (le menu, en paysage) : le navigateur le suit.
if (target?.closest("a[href]") != null) {
return;
}
const action = target?.closest("[data-action]")?.getAttribute("data-action");
if (action != null && action in actions) {
actions[action as ActionName]();
@@ -1127,7 +1505,8 @@ export function startApp(options: AppOptions): void {
if (event.altKey || event.metaKey || (event.ctrlKey && event.key.toLowerCase() !== "z")) {
return;
}
const onButton = event.target instanceof HTMLButtonElement;
const onButton =
event.target instanceof HTMLButtonElement || event.target instanceof HTMLAnchorElement;
let name: ActionName | null = null;
switch (event.key) {
case " ":
@@ -1167,6 +1546,11 @@ export function startApp(options: AppOptions): void {
name = "close";
break;
}
// Hors de l'écran de jeu (aide, position), seules les touches de
// navigation servent.
if (ui.game.hidden && name !== null && !["close", "help", "menu"].includes(name)) {
name = null;
}
if (name !== null) {
event.preventDefault();
actions[name]();
@@ -1175,8 +1559,9 @@ export function startApp(options: AppOptions): void {
// ---------------------------------------------------------- Démarrage
// Réponse immédiate : le plateau est dessiné avant que le moteur arrive.
render();
// Réponse immédiate : le plateau est dessiné avant que le moteur arrive,
// ou l'écran que désigne l'adresse.
applyRoute(navigation.route);
options.engineReady.then(
async () => {
game = options.savedGame();
@@ -1210,6 +1595,32 @@ export function startApp(options: AppOptions): void {
// ------------------------------------------------- Lecture de la partie
/** Écrit un texte dans le presse-papiers ; faux si le navigateur refuse.
* Hors contexte sûr (http sur le réseau local), navigator.clipboard
* n'existe pas : repli sur une zone de texte sélectionnée, hors de l'écran. */
async function copyText(text: string): Promise<boolean> {
try {
await navigator.clipboard.writeText(text);
return true;
} catch {
// Repli ci-dessous.
}
const area = document.createElement("textarea");
area.value = text;
area.readOnly = true;
area.className = "offscreen";
document.body.append(area);
area.select();
let copied = false;
try {
copied = document.execCommand("copy");
} catch {
copied = false;
}
area.remove();
return copied;
}
/** La partie attend-elle encore un coup ou un jet (ni double, ni fin) ? */
function isPlaying(game: Game): boolean {
const phase = game.state.phase;
+20 -1
View File
@@ -532,7 +532,26 @@ function drawControls(
transform: `translate(${x - size / 2},${y - size / 2}) scale(${scale})`,
});
add(icon, "path", { class: "control-icon", d: ICON_PATHS[name] });
if (on) {
if (!on) {
return;
}
if (action === "menu") {
// Le menu a son adresse (ADR 0013) : un vrai lien, comme dans
// la barre haute du portrait.
const link = add(hits, "a", {
href: "#/menu",
"data-action": action,
role: "link",
"aria-label": label,
});
add(link, "rect", {
class: "hit action",
x: Math.max(0, x - g.target / 2),
y: Math.max(0, y - g.target / 2),
width: g.target,
height: g.target,
});
} else {
addAction(g, hits, action, label, { x, y }, g.target);
}
};
+66
View File
@@ -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)",
};
+278
View File
@@ -0,0 +1,278 @@
// « 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.
//
// La feuille a son adresse (#/parties), et chaque partie la sienne
// (#/parties/<id>, ses branches dépliées) : l'écran de jeu l'ouvre et la
// ferme selon l'adresse (show, hide), et « Branches » est un vrai lien.
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";
import { routeHash } from "./router.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), et quitte la feuille. */
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 }>;
/** La croix ou le voile touché : quitter la feuille (l'écran de jeu
* change d'adresse, puis appelle hide). */
readonly onClose: () => void;
}
export interface GamesView {
/** Ouvre la feuille (la liste, à jour, si elle était fermée), avec les
* branches de la partie expanded dépliées. */
show(expanded: string | null): Promise<void>;
hide(): 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("", () => options.onClose(), "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) {
options.onClose();
}
});
// La partie dont les branches sont dépliées (adresse #/parties/<id>).
let expanded: string | null = null;
/** 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(() => options.resume(game.id)),
"",
"resume",
),
branchesLink(game.id),
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();
await expand();
}),
"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;
}
/** « Branches » : le lien qui déplie les branches d'une partie (ou les
* replie, quand elles le sont déjà). */
function branchesLink(id: string): HTMLAnchorElement {
const link = element("a", "button secondary");
link.textContent = GAMES_TEXTS.showBranches;
link.dataset.action = "branches";
link.href = routeHash({ screen: "games", game: id });
link.setAttribute("aria-expanded", "false");
return link;
}
async function renderBranches(id: string, target: HTMLElement): Promise<void> {
const journal = await options.journal(id);
const all = journal === null ? [] : branches(journal);
target.replaceChildren(
...all.map((branch) => {
const item = element("li", "games-branch");
item.dataset.head = branch.head;
const text = element("span");
text.textContent =
GAMES_TEXTS.branch(
branch.forkedFrom === null ? null : branch.forkMoveCount,
branch.moveCount,
branch.over,
) + (branch.containsHead ? GAMES_TEXTS.currentBranch : "");
item.append(
text,
button(
GAMES_TEXTS.resumeBranch,
run(() => options.resume(id, branch.head)),
"secondary",
"resume-branch",
),
);
return item;
}),
);
}
/** Déplie les branches de la partie expanded, replie les autres. */
async function expand(): Promise<void> {
for (const item of list.querySelectorAll<HTMLElement>(".games-item")) {
const id = item.dataset.id ?? "";
const open = id === expanded;
const link = item.querySelector<HTMLAnchorElement>('[data-action="branches"]');
link?.setAttribute("aria-expanded", String(open));
link?.setAttribute("href", routeHash({ screen: "games", game: open ? null : id }));
const branchList = item.querySelector<HTMLElement>(".games-branches");
if (branchList === null) {
continue;
}
if (open && branchList.hidden) {
await run(() => renderBranches(id, branchList))();
}
branchList.hidden = !open;
}
}
return {
async show(next) {
expanded = next;
if (veil.hidden) {
message.textContent = "";
veil.hidden = false;
await run(render)();
close.focus();
}
await expand();
},
hide() {
veil.hidden = true;
},
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);
}
+185 -48
View File
@@ -1,22 +1,31 @@
// La page d'aide : le scénario « Jouer une partie contre l'ordinateur » et le
// glossaire, lus à l'exécution dans les copies de docs/ faites par make site
// (ADR 0008). Rien n'est recopié dans le code.
// L'aide : les scénarios (onglet « Jouer », une question par scénario), le
// glossaire (« Les mots du jeu ») et « À propos », lus à l'exécution dans
// les copies de docs/ faites par make site (ADR 0008). Rien n'est recopié
// dans le code.
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
//
// Chaque onglet, chaque question et chaque terme a son adresse (ADR 0013,
// amendement) : on les ouvre par de vrais liens, et l'adresse dit ce qui
// est ouvert (show).
import {
type GlossaryEntry,
type HelpFeature,
type HelpScenario,
parseFeature,
parseGlossaryEntry,
parseGlossaryIndex,
} from "./help-text.js";
import { type HelpRoute, routeHash, slug } from "./router.js";
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/partager-une-position.feature",
"docs/scenarios/regler-l-application.feature",
];
const GLOSSARY = "docs/glossaire/";
@@ -29,10 +38,19 @@ async function fetchText(url: string): Promise<string> {
return response.text();
}
async function loadGlossary(): Promise<GlossaryEntry[]> {
/** Une fiche du glossaire et son nom de fichier, sans .md. */
interface GlossaryCard extends GlossaryEntry {
readonly name: string;
}
async function loadGlossary(): Promise<GlossaryCard[]> {
const files = parseGlossaryIndex(await fetchText(`${GLOSSARY}README.md`));
const texts = await Promise.all(files.map((file) => fetchText(GLOSSARY + file)));
return texts.map(parseGlossaryEntry).filter((entry) => entry !== null);
return texts.flatMap((text, i) => {
const entry = parseGlossaryEntry(text);
const name = (files[i] ?? "").replace(/\.md$/, "");
return entry === null ? [] : [{ ...entry, name }];
});
}
/** Une fiche du glossaire (nom de fichier dans docs/glossaire/), lue dans la
@@ -54,56 +72,175 @@ function element<K extends keyof HTMLElementTagNameMap>(
return child;
}
function renderFeature(parent: HTMLElement, feature: HelpFeature): void {
element(parent, "h2", feature.title);
for (const paragraph of feature.description) {
element(parent, "p", paragraph);
}
for (const scenario of feature.scenarios) {
element(parent, "h3", scenario.title);
for (const paragraph of scenario.description) {
element(parent, "p", paragraph);
/** Une question de l'onglet « Jouer » : son lien et sa réponse. */
interface Question {
readonly id: string;
readonly section: HTMLElement;
readonly link: HTMLAnchorElement;
readonly answer: HTMLElement;
}
export interface HelpView {
/** Montre l'onglet, la question ou le terme que désigne l'adresse
* (l'aide est lue au premier appel). */
show(route: HelpRoute): Promise<void>;
}
/**
* L'aide dans container : un onglet par panneau (Jouer, Les mots du jeu, À
* propos). Les liens des onglets sont dans tabs, déjà dans la page.
*/
export function createHelpView(container: HTMLElement, tabs: HTMLElement): HelpView {
const play = document.createElement("div");
play.id = "help-play";
const glossary = document.createElement("div");
glossary.id = "help-glossary";
const about = document.createElement("div");
about.id = "help-about";
const notice = document.createElement("p");
notice.className = "help-notice";
notice.setAttribute("role", "status");
const questions: Question[] = [];
const terms = new Map<string, HTMLElement>();
let loading: Promise<boolean> | null = null;
// La question ouverte au dernier affichage : refermée, elle reste en vue.
let lastOpen: Question | null = null;
function renderScenario(parent: HTMLElement, scenario: HelpScenario): void {
let id = slug(scenario.title);
for (let n = 2; questions.some((q) => q.id === id); n++) {
id = `${slug(scenario.title)}-${n}`;
}
const steps = element(parent, "ul");
const section = element(parent, "section");
section.className = "question";
section.id = `aide-${id}`;
const heading = element(section, "h3");
const link = element(heading, "a", scenario.title);
link.href = routeHash({ screen: "help", question: id });
link.setAttribute("aria-controls", `aide-${id}-reponse`);
link.setAttribute("aria-expanded", "false");
const answer = element(section, "div");
answer.id = `aide-${id}-reponse`;
answer.className = "answer";
answer.hidden = true;
for (const paragraph of scenario.description) {
element(answer, "p", paragraph);
}
const steps = element(answer, "ul");
steps.className = "steps";
for (const step of scenario.steps) {
element(steps, "li", step);
}
questions.push({ id, section, link, answer });
}
}
function renderGlossary(parent: HTMLElement, entries: readonly GlossaryEntry[]): void {
element(parent, "h2", TEXTS.glossaryTitle);
const list = element(parent, "dl");
list.className = "glossary";
for (const entry of entries) {
element(list, "dt", entry.term);
element(list, "dd", entry.brief);
}
}
/** Remplit container avec l'aide (une seule fois ; les appels suivants ne
* font rien). */
export async function showHelp(container: HTMLElement): Promise<void> {
if (container.dataset.loaded === "true") {
return;
}
container.replaceChildren();
const status = element(container, "p", TEXTS.helpLoading);
try {
const [features, glossary] = await Promise.all([
Promise.all(FEATURES.map(async (url) => parseFeature(await fetchText(url)))),
loadGlossary(),
]);
status.remove();
for (const feature of features) {
renderFeature(container, feature);
function renderFeature(feature: HelpFeature): void {
element(play, "h2", feature.title);
for (const paragraph of feature.description) {
element(play, "p", paragraph);
}
for (const scenario of feature.scenarios) {
renderScenario(play, scenario);
}
renderGlossary(container, glossary);
const about = element(container, "p", TEXTS.about);
about.className = "about";
container.dataset.loaded = "true";
} catch {
status.textContent = TEXTS.helpFailed;
}
function renderGlossary(entries: readonly GlossaryCard[]): void {
element(glossary, "h2", TEXTS.glossaryTitle);
const list = element(glossary, "dl");
list.className = "glossary";
for (const entry of entries) {
const term = element(list, "dt");
term.id = `glossaire-${entry.name}`;
const link = element(term, "a", entry.term);
link.href = routeHash({ screen: "glossary", term: entry.name });
element(list, "dd", entry.brief);
terms.set(entry.name, term);
}
}
function renderAbout(): void {
element(about, "h2", TEXTS.aboutTitle);
const text = element(about, "p", TEXTS.about);
text.className = "about";
}
async function load(): Promise<boolean> {
container.replaceChildren(notice);
notice.textContent = TEXTS.helpLoading;
try {
const [features, entries] = await Promise.all([
Promise.all(FEATURES.map(async (url) => parseFeature(await fetchText(url)))),
loadGlossary(),
]);
for (const feature of features) {
renderFeature(feature);
}
renderGlossary(entries);
renderAbout();
notice.textContent = "";
container.append(play, glossary, about);
container.dataset.loaded = "true";
return true;
} catch {
notice.textContent = TEXTS.helpFailed;
loading = null; // on réessaiera à la prochaine ouverture
return false;
}
}
return {
async show(route) {
for (const tab of tabs.querySelectorAll<HTMLAnchorElement>("a[data-screen]")) {
if (tab.dataset.screen === route.screen) {
tab.setAttribute("aria-current", "page");
} else {
tab.removeAttribute("aria-current");
}
}
loading ??= load();
if (!(await loading)) {
return;
}
play.hidden = route.screen !== "help";
glossary.hidden = route.screen !== "glossary";
about.hidden = route.screen !== "about";
const question = route.screen === "help" ? route.question : null;
for (const q of questions) {
const open = q.id === question;
q.answer.hidden = !open;
q.link.setAttribute("aria-expanded", String(open));
// Le lien d'une question ouverte la referme.
q.link.href = routeHash({ screen: "help", question: open ? null : q.id });
q.section.classList.toggle("open", open);
}
const term = route.screen === "glossary" ? route.term : null;
for (const [name, dt] of terms) {
dt.classList.toggle("current", name === term);
if (name === term) {
dt.setAttribute("aria-current", "true");
} else {
dt.removeAttribute("aria-current");
}
}
const missing =
(question !== null && !questions.some((q) => q.id === question)) ||
(term !== null && !terms.has(term));
notice.textContent = missing ? TEXTS.helpNotFound : "";
const opened = questions.find((q) => q.id === question) ?? null;
const closed = route.screen === "help" && opened === null ? lastOpen : null;
lastOpen = opened;
// La barre haute de l'aide reste en vue (app.css) : ce qu'on
// amène en haut s'arrête 12 px sous elle (scroll-margin-top).
const target = opened?.section ?? (term === null ? undefined : terms.get(term));
if (target !== undefined) {
target.scrollIntoView({ block: "start" });
} else if (closed !== null) {
closed.section.scrollIntoView({ block: "nearest" });
} else {
window.scrollTo(0, 0);
}
},
};
}
+1
View File
@@ -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",
+166
View File
@@ -0,0 +1,166 @@
// La navigation entre les écrans : l'adresse de la page est la source de
// vérité (ADR 0013, amendement du 2026-11-03). On change d'écran par de
// vrais liens (<a href="#/aide">) ; l'application écoute hashchange et
// dessine l'écran que désigne l'adresse.
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
//
// Pour que « Retour », Échap ou la croix d'une feuille reviennent à l'écran
// d'avant sans empiler d'adresse (le bouton retour du téléphone ne doit pas
// rouvrir ce qu'on vient de fermer), chaque entrée de l'historique de
// l'onglet garde, dans history.state, la suite des adresses visitées
// jusqu'à elle. Elle survit au rechargement de la page.
import { parseRoute, type Route, stepsBack } from "./router.js";
/** Au plus autant d'adresses gardées par entrée de l'historique. */
const TRAIL_MAX = 50;
export interface Navigation {
/** L'écran que désigne l'adresse actuelle. */
readonly route: Route;
/** Va à une adresse, comme un lien (empile une entrée). */
go(hash: string): void;
/** Remplace l'adresse actuelle, sans empiler. */
replace(hash: string): void;
/** Quitte l'écran courant : revient à la dernière adresse de l'onglet
* qui ne vérifie pas stay, ou, s'il n'y en a pas (lien ouvert dans un
* nouvel onglet), remplace l'adresse par celle de la partie. */
leave(stay: (route: Route) => boolean): void;
}
interface TrailState {
readonly ustagammonTrail: readonly string[];
}
function trailOf(state: unknown): readonly string[] | null {
const trail = (state as Partial<TrailState> | null)?.ustagammonTrail;
return Array.isArray(trail) ? trail : null;
}
/** Le fragment actuel, « #/ » s'il est vide. */
function currentHash(): string {
return location.hash === "" || location.hash === "#" ? "#/" : location.hash;
}
/**
* Branche la navigation sur la page : onChange est appelé à chaque
* changement d'adresse (pas au démarrage : l'appelant lit route).
*/
export function startNavigation(onChange: (route: Route) => void): Navigation {
let trail: readonly string[] = [];
// Un retour dans l'historique est en cours (history.go est asynchrone) :
// une adresse demandée entre-temps attend son arrivée.
let leaving: ReturnType<typeof setTimeout> | null = null;
let queued: string | null = null;
const remember = (next: readonly string[]) => {
trail = next.slice(-TRAIL_MAX);
try {
history.replaceState({ ...(history.state ?? {}), ustagammonTrail: trail }, "");
} catch {
// Historique indisponible (page dans un cadre) : la navigation
// marche quand même, « Retour » ramène alors à la partie.
}
};
// L'entrée actuelle : déjà connue (rechargement), ou la première.
const known = trailOf(history.state);
remember(known !== null && known.at(-1) === currentHash() ? known : [currentHash()]);
window.addEventListener("hashchange", () => {
const hash = currentHash();
const saved = trailOf(history.state);
if (saved !== null && saved.at(-1) === hash) {
// Retour ou avance dans l'historique : une entrée déjà connue.
trail = saved;
} else {
// Un lien suivi : une nouvelle entrée.
remember([...trail, hash]);
}
onChange(parseRoute(hash));
if (leaving !== null) {
clearTimeout(leaving);
leaving = null;
const next = queued;
queued = null;
if (next !== null) {
go(next);
}
}
});
function go(hash: string): void {
if (leaving !== null) {
queued = hash;
} else if (hash !== currentHash()) {
location.hash = hash;
}
}
function replace(hash: string): void {
if (hash === currentHash()) {
return;
}
const url = new URL(location.href);
url.hash = hash;
const next = [...trail.slice(0, -1), hash];
try {
history.replaceState({ ...(history.state ?? {}), ustagammonTrail: next }, "", url);
} catch {
location.replace(url);
return;
}
trail = next;
// replaceState ne déclenche pas hashchange.
onChange(parseRoute(hash));
}
return {
get route() {
return parseRoute(currentHash());
},
go,
replace,
leave(stay) {
if (leaving !== null) {
return; // déjà en route
}
const steps = stepsBack(trail, stay);
if (steps === null) {
replace("#/");
return;
}
// Au cas où le navigateur ne reviendrait pas (historique
// indisponible), on n'attend pas plus d'une seconde.
leaving = setTimeout(() => {
leaving = null;
queued = null;
}, 1000);
history.go(steps);
},
};
}
/**
* Un lien qui remplace l'adresse au lieu d'en empiler une (les liens d'une
* feuille, comme le menu : le retour du téléphone ne rouvre pas la feuille
* fermée en suivant le lien). Ouvert dans un nouvel onglet, avec le bouton
* du milieu ou une touche, il reste un lien ordinaire.
*/
export function replacingLink(link: HTMLAnchorElement, navigation: Navigation): void {
link.addEventListener("click", (event) => {
if (
event.button !== 0 ||
event.ctrlKey ||
event.metaKey ||
event.shiftKey ||
event.altKey
) {
return;
}
event.preventDefault();
navigation.replace(new URL(link.href).hash);
});
}
+191
View File
@@ -0,0 +1,191 @@
// Les adresses des écrans (ADR 0013, amendement du 2026-11-03) : chaque
// écran a la sienne, dans le fragment de l'adresse (après #). Ce module
// traduit un fragment en écran et un écran en fragment ; fonctions pures,
// testées sous Node (app/tests/router.test.ts).
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
/** Un écran de l'application, tel que le désigne son adresse. */
export type Route =
/** #/ : la partie en cours. */
| { readonly screen: "game" }
/** #/accueil (écran à venir, étape 0e.5). */
| { readonly screen: "home" }
/** #/aide, et une question ouverte : #/aide/<identifiant>. */
| { readonly screen: "help"; readonly question: string | null }
/** #/glossaire, et un terme : #/glossaire/<fiche sans .md>. */
| { readonly screen: "glossary"; readonly term: string | null }
/** #/a-propos. */
| { readonly screen: "about" }
/** #/tutoriel, et une étape : #/tutoriel/<n> (écran à venir). */
| { readonly screen: "tutorial"; readonly step: number | null }
/** #/parties, et une partie : #/parties/<identifiant>. */
| { readonly screen: "games"; readonly game: string | null }
/** #/menu. */
| { readonly screen: "menu" }
/** #/position/<Position ID>/<Match ID> (Match ID facultatif). */
| { readonly screen: "position"; readonly position: string; readonly match: string | null }
/** Une adresse que l'application ne connaît pas. */
| { readonly screen: "unknown"; readonly hash: string };
export type Screen = Route["screen"];
export const GAME_ROUTE: Route = { screen: "game" };
/** Longueur d'un Position ID de gnubg. */
const POSITION_ID_LENGTH = 14;
/** Un identifiant d'écran (question, terme, partie) : lettres minuscules,
* chiffres et tirets. */
const NAME = /^[a-z0-9][a-z0-9-]*$/;
/** L'écran que désigne un fragment (avec ou sans # au début). */
export function parseRoute(hash: string): Route {
const fragment = hash.startsWith("#") ? hash.slice(1) : hash;
if (fragment === "" || fragment === "/") {
return GAME_ROUTE;
}
const unknown: Route = { screen: "unknown", hash: `#${fragment}` };
if (!fragment.startsWith("/")) {
return unknown;
}
const [head = "", ...rest] = fragment.slice(1).split("/");
// Une barre finale (#/aide/) ne change rien.
if (rest.at(-1) === "") {
rest.pop();
}
if (head === "position") {
return positionRoute(rest.join("/")) ?? unknown;
}
if (rest.length > 1) {
return unknown;
}
const name = rest[0] ?? null;
if (name !== null && !NAME.test(name)) {
return unknown;
}
switch (head) {
case "":
return name === null ? GAME_ROUTE : unknown;
case "accueil":
return name === null ? { screen: "home" } : unknown;
case "aide":
return { screen: "help", question: name };
case "glossaire":
return { screen: "glossary", term: name };
case "a-propos":
return name === null ? { screen: "about" } : unknown;
case "menu":
return name === null ? { screen: "menu" } : unknown;
case "parties":
return { screen: "games", game: name };
case "tutoriel": {
if (name === null) {
return { screen: "tutorial", step: null };
}
const step = Number(name);
return /^[1-9][0-9]?$/.test(name) ? { screen: "tutorial", step } : unknown;
}
default:
return unknown;
}
}
/**
* #/position/... : le Position ID (14 caractères), puis, après une barre, le
* Match ID. Chaque identifiant est encodé avec encodeURIComponent, mais une
* adresse collée telle quelle (le base64 de gnubg contient + et /) est
* comprise aussi : le texte est décodé en entier, puis découpé à la
* longueur du Position ID. Leur validité est l'affaire du moteur.
*/
function positionRoute(text: string): Route | null {
let decoded: string;
try {
decoded = decodeURIComponent(text);
} catch {
return null;
}
if (decoded === "") {
return null;
}
if (decoded.length > POSITION_ID_LENGTH && decoded[POSITION_ID_LENGTH] === "/") {
const match = decoded.slice(POSITION_ID_LENGTH + 1);
return {
screen: "position",
position: decoded.slice(0, POSITION_ID_LENGTH),
match: match === "" ? null : match,
};
}
return { screen: "position", position: decoded, match: null };
}
/** Le fragment d'un écran, # compris. */
export function routeHash(route: Route): string {
switch (route.screen) {
case "game":
return "#/";
case "home":
return "#/accueil";
case "help":
return route.question === null ? "#/aide" : `#/aide/${route.question}`;
case "glossary":
return route.term === null ? "#/glossaire" : `#/glossaire/${route.term}`;
case "about":
return "#/a-propos";
case "tutorial":
return route.step === null ? "#/tutoriel" : `#/tutoriel/${route.step}`;
case "games":
return route.game === null ? "#/parties" : `#/parties/${route.game}`;
case "menu":
return "#/menu";
case "position": {
const position = `#/position/${encodeURIComponent(route.position)}`;
return route.match === null
? position
: `${position}/${encodeURIComponent(route.match)}`;
}
case "unknown":
return route.hash;
}
}
/** Les écrans de l'aide : on passe de l'un à l'autre par ses onglets. */
export type HelpRoute = Extract<Route, { screen: "help" | "glossary" | "about" }>;
/** L'adresse est-elle celle d'un écran de l'aide ? */
export function isHelpScreen(route: Route): route is HelpRoute {
return route.screen === "help" || route.screen === "glossary" || route.screen === "about";
}
/** L'identifiant d'une question de l'aide : son titre, en minuscules sans
* accents, les mots séparés par des tirets. */
export function slug(title: string): string {
return title
.normalize("NFD")
.replace(/[̀-ͯ]/g, "")
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
}
/**
* Où revenir en quittant un écran (« Retour », Échap, la croix d'une
* feuille) : la dernière adresse de l'historique de l'onglet qui n'est pas
* de cet écran. trail est la suite des adresses de l'onglet jusqu'à
* l'actuelle (comprise). Renvoie le nombre de pas à reculer (négatif), ou
* null si aucune adresse ne convient : il faut alors remplacer l'adresse
* par celle de la partie, sans rien empiler.
*/
export function stepsBack(
trail: readonly string[],
stay: (route: Route) => boolean,
): number | null {
for (let i = trail.length - 2; i >= 0; i--) {
const hash = trail[i];
if (hash !== undefined && !stay(parseRoute(hash))) {
return i - (trail.length - 1);
}
}
return null;
}
+50 -1
View File
@@ -153,8 +153,57 @@ export const TEXTS = {
// Aide
helpTitle: "Aide",
glossaryTitle: "Glossaire",
helpTabs: "Rubriques de l'aide",
helpTabNames: { help: "Jouer", glossary: "Les mots du jeu", about: "À propos" },
glossaryTitle: "Les mots du jeu",
aboutTitle: "À propos",
helpLoading: "Chargement de l'aide…",
helpFailed: "L'aide n'a pas pu se charger.",
helpNotFound: "Cette page de l'aide n'existe pas, ou plus : voici le sommaire.",
about: "Ustagammon est un logiciel libre (GPLv3). L'ordinateur joue avec GNU Backgammon.",
// Adresses (ADR 0013)
unknownAddress: "Cette adresse ne mène à aucun écran : voici votre partie.",
screenNotYet: "Cet écran n'existe pas encore : voici votre partie.",
// Lien vers une position (ADR 0013)
copyPositionLink: "Copier le lien de la position",
linkCopied: "Lien de la position copié.",
linkNotCopied: (url: string) => `Copie impossible ; le lien : ${url}`,
positionTitle: "Position partagée",
unreadablePositionTitle: "Lien illisible",
unreadablePosition:
"Ce lien ne décrit pas une position que GNU Backgammon sait lire. Il a peut-être été coupé en le copiant.",
backToGame: "Retour à ma partie",
playFromHere: "Jouer à partir d'ici",
startedFromPosition: "Nouvelle partie à partir de la position du lien.",
/** Ce que montre la vue d'une position, sous le plateau. */
positionDetail: (
pips: Readonly<Record<"player" | "computer", number>>,
cube: { value: number; owner: "player" | "computer" | null },
match: { length: number; score: Readonly<Record<"player" | "computer", number>> } | null,
winner: "player" | "computer" | null,
) =>
[
winner === null
? "Vous avez la main : vos pions ivoire, en bas."
: `La partie est finie dans cette position : ${
winner === "player" ? "vous gagnez" : "l'ordinateur gagne"
}.`,
`Compte de points : vous ${pips.player}, l'ordinateur ${pips.computer}.`,
cube.value > 1 || cube.owner !== null
? `Videau à ${cube.value}, ${
cube.owner === null
? "au centre"
: cube.owner === "player"
? "chez vous"
: "chez l'ordinateur"
}.`
: "",
match === null
? ""
: `Match en ${points(match.length)}, ${match.score.player} à ${match.score.computer} : vous le jouerez en partie libre.`,
]
.filter((part) => part !== "")
.join(" "),
} as const;
+172
View File
@@ -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/);
});
+10
View File
@@ -59,6 +59,16 @@ function fakeEngine(overrides: Partial<Engine> = {}): Engine {
skill: "none",
}),
luck: async () => ({ value: 0, rating: "none" }),
encodeMatch: async () => "MATCH",
decodeMatch: async () => ({
matchLength: 0,
score: [0, 0],
cubeValue: 1,
cubeOwner: -1,
playerOnRoll: 0,
crawford: false,
jacoby: true,
}),
...overrides,
};
}
+99
View File
@@ -0,0 +1,99 @@
// Tests des liens de position (app/src/domain/game/position-link.ts, ADR
// 0013) sur le vrai moteur WebAssembly : Match ID écrit puis relu, videau
// vu du joueur au trait, identifiants illisibles.
//
// 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 type { EngineCube } from "../src/domain/game/engine.js";
import { Game } from "../src/domain/game/game.js";
import { START_POSITION } from "../src/domain/game/position.js";
import {
linkOf,
readPosition,
setupOf,
UnreadableLinkError,
} from "../src/domain/game/position-link.js";
import { fixedDice } from "../src/domain/game/roll.js";
import { parseRoute, routeHash } from "../src/ui/router.js";
import { loadNodeEngine } from "./support/node-engine.js";
test("le moteur relit le Match ID qu'il écrit, pour chaque état du videau", async () => {
const engine = await loadNodeEngine();
const cases: [EngineCube, -1 | 0 | 1][] = [
[{ value: 1, owner: "centered" }, -1],
[{ value: 2, owner: "onRoll" }, 0],
[{ value: 4, owner: "opponent" }, 1],
];
for (const [cube, owner] of cases) {
const id = await engine.encodeMatch(cube);
assert.equal(id.length, 12);
const match = await engine.decodeMatch(id);
assert.equal(match.matchLength, 0);
assert.equal(match.playerOnRoll, 0);
assert.equal(match.cubeValue, cube.value);
assert.equal(match.cubeOwner, owner);
}
});
test("sans Match ID : partie libre, videau au centre", async () => {
const engine = await loadNodeEngine();
const shared = await readPosition(engine, { position: START_POSITION, matchId: null });
assert.deepEqual(shared.cube, { value: 1, owner: null });
assert.equal(shared.match, null);
assert.equal(shared.result, null);
assert.equal(shared.sides.player.pipCount, 167);
assert.equal(shared.sides.computer.pipCount, 167);
assert.deepEqual(setupOf(shared), {
position: START_POSITION,
firstTurn: "player",
cube: { value: 1, owner: null },
});
});
test("des identifiants illisibles donnent une erreur claire", async () => {
const engine = await loadNodeEngine();
for (const link of [
{ position: "abc", matchId: null },
{ position: "4HPwATDgc/ABM*", matchId: null },
{ position: START_POSITION, matchId: "pas un id" },
{ position: START_POSITION, matchId: "" },
]) {
await assert.rejects(readPosition(engine, link), UnreadableLinkError, JSON.stringify(link));
}
});
test("le lien d'une partie rouvre sa position, son videau compris, par l'adresse", async () => {
const engine = await loadNodeEngine();
// Videau chez l'ordinateur, à 2 : une partie commencée ainsi.
const game = Game.start(engine, {
dice: fixedDice([3, 1]),
setup: {
position: "jGfkASjg8wcBMA",
firstTurn: "player",
cube: { value: 2, owner: "computer" },
},
});
const link = await linkOf(engine, game.state);
const route = parseRoute(
routeHash({ screen: "position", position: link.position, match: link.matchId }),
);
assert.ok(route.screen === "position");
const shared = await readPosition(engine, { position: route.position, matchId: route.match });
assert.deepEqual(shared.sides, await game.displayedBoard());
assert.deepEqual(shared.cube, { value: 2, owner: "computer" });
});
test("le lien pris pendant le tour de l'ordinateur le met au trait, en bas", async () => {
const engine = await loadNodeEngine();
// Jet d'ouverture 1-6 : l'ordinateur commence.
const game = Game.start(engine, { dice: fixedDice([1, 6]) });
assert.equal(game.state.turn, "computer");
const link = await linkOf(engine, game.state);
assert.equal(link.position, game.state.position);
const shared = await readPosition(engine, link);
const board = await engine.board(game.state.position, { value: 1, owner: "centered" });
assert.deepEqual(shared.sides, { player: board.onRoll, computer: board.opponent });
});
+138
View File
@@ -0,0 +1,138 @@
// Tests du routeur (app/src/ui/router.ts) : chaque adresse du tableau de
// l'ADR 0013 (amendement du 2026-11-03) mène au bon écran, et chaque écran
// redonne son adresse.
//
// 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 {
isHelpScreen,
parseRoute,
type Route,
routeHash,
slug,
stepsBack,
} from "../src/ui/router.js";
const START = "4HPwATDgc/ABMA";
const MATCH = "cAkAAAAAAAAA";
/** Les adresses du tableau de l'ADR, et l'écran de chacune. */
const TABLE: [string, Route][] = [
["#/", { screen: "game" }],
["#/accueil", { screen: "home" }],
["#/aide", { screen: "help", question: null }],
["#/aide/comment-jouer", { screen: "help", question: "comment-jouer" }],
["#/glossaire", { screen: "glossary", term: null }],
["#/glossaire/videau", { screen: "glossary", term: "videau" }],
["#/a-propos", { screen: "about" }],
["#/tutoriel", { screen: "tutorial", step: null }],
["#/tutoriel/3", { screen: "tutorial", step: 3 }],
["#/parties", { screen: "games", game: null }],
["#/parties/mh2k3x-a1b2c3", { screen: "games", game: "mh2k3x-a1b2c3" }],
["#/menu", { screen: "menu" }],
[
"#/position/4HPwATDgc%2FABMA/cAkAAAAAAAAA",
{ screen: "position", position: START, match: MATCH },
],
["#/position/4HPwATDgc%2FABMA", { screen: "position", position: START, match: null }],
];
test("chaque adresse du tableau de l'ADR 0013 mène à son écran, et inversement", () => {
for (const [hash, route] of TABLE) {
assert.deepEqual(parseRoute(hash), route, hash);
assert.equal(routeHash(route), hash, hash);
}
});
test("une adresse vide ou « # » est la partie en cours ; une barre finale ne change rien", () => {
assert.deepEqual(parseRoute(""), { screen: "game" });
assert.deepEqual(parseRoute("#"), { screen: "game" });
assert.deepEqual(parseRoute("#/aide/"), { screen: "help", question: null });
assert.deepEqual(parseRoute("#/glossaire/blot/"), { screen: "glossary", term: "blot" });
});
test("une adresse inconnue est reconnue comme telle", () => {
for (const hash of [
"#/inconnu",
"#aide",
"#/aide/a/b",
"#/aide/Majuscules",
"#/menu/plus",
"#/tutoriel/0",
"#/tutoriel/x",
"#/a-propos/x",
"#/position",
"#/position/%E0%A4%A",
]) {
assert.equal(parseRoute(hash).screen, "unknown", hash);
}
assert.equal(routeHash(parseRoute("#/inconnu")), "#/inconnu");
});
test("un lien de position collé sans encodage (+ et / du base64) est compris", () => {
assert.deepEqual(parseRoute(`#/position/${START}/${MATCH}`), {
screen: "position",
position: START,
match: MATCH,
});
assert.deepEqual(parseRoute("#/position/jGfkASjg8wcBMA/cAkAAAAAAAAA"), {
screen: "position",
position: "jGfkASjg8wcBMA",
match: MATCH,
});
// + reste un + (pas une espace), encodé ou non.
const plus = "AAAA+AAAAAAAAA";
assert.equal(
routeHash({ screen: "position", position: plus, match: null }),
"#/position/AAAA%2BAAAAAAAAA",
);
assert.deepEqual(parseRoute("#/position/AAAA%2BAAAAAAAAA"), {
screen: "position",
position: plus,
match: null,
});
assert.deepEqual(parseRoute(`#/position/${plus}`), {
screen: "position",
position: plus,
match: null,
});
});
test("un identifiant de position de mauvaise longueur arrive tel quel au moteur, qui jugera", () => {
assert.deepEqual(parseRoute("#/position/abc"), {
screen: "position",
position: "abc",
match: null,
});
});
test("l'identifiant d'une question : le titre sans accents ni ponctuation", () => {
assert.equal(slug("Jouer un coup, puis l'annuler"), "jouer-un-coup-puis-l-annuler");
assert.equal(slug(" Été : déjà vu ? "), "ete-deja-vu");
assert.equal(
slug("Rejouer d'ici sans perdre l'original"),
"rejouer-d-ici-sans-perdre-l-original",
);
});
test("les écrans de l'aide : jouer, les mots du jeu, à propos", () => {
assert.ok(isHelpScreen(parseRoute("#/aide/x")));
assert.ok(isHelpScreen(parseRoute("#/glossaire")));
assert.ok(isHelpScreen(parseRoute("#/a-propos")));
assert.ok(!isHelpScreen(parseRoute("#/menu")));
});
test("quitter un écran revient à la dernière adresse qui n'en est pas", () => {
const help = (route: Route) => isHelpScreen(route);
// Partie, aide, une question : « Retour » remonte de deux pas.
assert.equal(stepsBack(["#/", "#/aide", "#/aide/q"], help), -2);
// Le menu ouvert depuis l'aide : on revient à l'aide.
const menu = (route: Route) => route.screen === "menu";
assert.equal(stepsBack(["#/", "#/aide", "#/menu"], menu), -1);
// Ouvert directement dans un nouvel onglet : rien avant, on remplace.
assert.equal(stepsBack(["#/aide/q"], help), null);
assert.equal(stepsBack(["#/aide", "#/glossaire/blot"], help), null);
});
+203
View File
@@ -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);
});
@@ -0,0 +1,171 @@
// Étapes du scénario docs/scenarios/partager-une-position.feature (ADR 0008,
// ADR 0013) : le lien passe par le routeur de l'interface (adresse écrite
// puis relue), la position par le domaine et le vrai moteur WebAssembly, et
// les parties par le même enregistrement que l'application.
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
import assert from "node:assert/strict";
import { Before, BeforeAll, Given, Then, When } from "@cucumber/cucumber";
import type { Engine } from "../../src/domain/game/engine.js";
import type { GameJournal } from "../../src/domain/game/events.js";
import { Game } from "../../src/domain/game/game.js";
import {
linkOf,
readPosition,
type SharedPosition,
setupOf,
UnreadableLinkError,
} from "../../src/domain/game/position-link.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 { parseRoute, routeHash } from "../../src/ui/router.js";
import { loadNodeEngine } from "../support/node-engine.js";
import { playMoves, seededDice, step } from "../support/play.js";
let engine: Engine;
let library: GameLibrary;
let session: GameSession;
let game: Game;
let gameId: string | null;
let journalBefore: GameJournal;
let address: string;
let shared: SharedPosition | null;
let failure: unknown;
/** Une position de milieu de partie (gnubg), vue du joueur au trait. */
const MIDDLE_GAME = "jGfkASjg8wcBMA";
BeforeAll(async () => {
engine = await loadNodeEngine();
});
Before(() => {
library = createGameLibrary(createKeyValueBackend(memoryStorage()));
session = createGameSession(
library,
{
start: (setup) =>
Game.start(engine, { dice: seededDice(7), ply: 0, ...(setup ? { setup } : {}) }),
resume: (journal) => Game.resume(engine, journal, { ply: 0 }),
},
"0ply",
);
shared = null;
failure = null;
});
/** Ouvre une adresse comme l'application : le routeur la lit, le moteur
* lit la position. */
async function open(hash: string): Promise<void> {
const route = parseRoute(hash);
assert.equal(route.screen, "position", `adresse non reconnue : ${hash}`);
if (route.screen !== "position") {
return;
}
try {
shared = await readPosition(engine, { position: route.position, matchId: route.match });
} catch (error) {
failure = error;
}
}
Given("votre partie en cours compte quelques coups", async () => {
game = session.newGame();
// Des coups jusqu'à ce que ce soit à vous de lancer les dés.
await playMoves(game, engine, 4);
const yourRoll = () => game.state.turn === "player" && game.state.phase === "rolling";
while (!yourRoll() && game.state.phase !== "over") {
await step(game, engine);
}
assert.ok(yourRoll(), "partie finie trop tôt");
session.save(game.journal);
gameId = session.shownId;
journalBefore = game.journal;
});
When("vous copiez le lien de la position", async () => {
const link = await linkOf(engine, game.state);
address = routeHash({ screen: "position", position: link.position, match: link.matchId });
});
Then("le lien contient l'identifiant de la position et celui du match", () => {
const route = parseRoute(address);
assert.ok(route.screen === "position");
assert.equal(route.position, game.state.position);
assert.equal(route.match?.length, 12);
assert.match(address, /^#\/position\/[A-Za-z0-9%]{14,}\/[A-Za-z0-9%]{12,}$/);
});
When("vous ouvrez ce lien", async () => {
await open(address);
});
Then("vous voyez exactement la même position, avec le même videau", async () => {
assert.ok(shared !== null, `lien illisible : ${failure}`);
assert.deepEqual(shared.sides, await game.displayedBoard());
assert.deepEqual(shared.cube, game.state.cube);
assert.equal(shared.match, null);
});
When("vous ouvrez un lien vers une position où le videau est à 2, chez l'ordinateur", async () => {
const matchId = await engine.encodeMatch({ value: 2, owner: "opponent" });
address = routeHash({ screen: "position", position: MIDDLE_GAME, match: matchId });
await open(address);
});
Then("vous voyez cette position, avec la main", async () => {
assert.ok(shared !== null, `lien illisible : ${failure}`);
const board = await engine.board(MIDDLE_GAME, { value: 2, owner: "opponent" });
assert.deepEqual(shared.sides, { player: board.onRoll, computer: board.opponent });
assert.equal(shared.result, null);
});
Then("votre partie n'a pas changé", () => {
assert.equal(session.shownId, gameId);
assert.deepEqual(game.journal, journalBefore);
});
When("vous choisissez « Jouer à partir d'ici »", () => {
assert.ok(shared !== null);
game = session.newGame(setupOf(shared));
session.save(game.journal);
});
Then("une nouvelle partie commence à cette position, à vous de lancer les dés", () => {
assert.notEqual(session.shownId, gameId);
assert.equal(game.state.position, MIDDLE_GAME);
assert.equal(game.state.turn, "player");
assert.equal(game.state.phase, "rolling");
});
Then("le videau est à 2, chez l'ordinateur", () => {
assert.deepEqual(game.state.cube, { value: 2, owner: "computer" });
});
Then("votre partie précédente est toujours enregistrée", async () => {
assert.ok(gameId !== null);
const saved = await library.load(gameId);
assert.deepEqual(saved?.journal, journalBefore);
});
When("vous ouvrez un lien dont l'identifiant de position est abîmé", async () => {
// Coupé d'un caractère, puis un caractère hors du base64.
shared = null;
failure = null;
await open("#/position/4HPwATDgc%2FAB%2A/cAkAAAAAAAAA");
});
When("vous ouvrez un lien dont l'identifiant de match est abîmé", async () => {
shared = null;
failure = null;
await open("#/position/4HPwATDgc%2FABMA/cAk*");
});
Then("le lien est déclaré illisible", () => {
assert.equal(shared, null);
assert.ok(failure instanceof UnreadableLinkError, `erreur inattendue : ${failure}`);
});
+38 -16
View File
@@ -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");
+267
View File
@@ -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/);
});
+86
View File
@@ -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 });
}
}
+139
View File
@@ -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
View File
@@ -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
View File
@@ -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 |
+1 -2
View File
@@ -23,8 +23,7 @@ Demandes de Pierre du 2026-11-03, traitées avant d'ouvrir l'étape 1.
| Sous-étape | Contenu | État | Plan |
| --- | --- | --- | --- |
| 0e.4 | Une adresse par écran (aide, glossaire, menu, mes parties…) et par position, par de vrais liens (ADR 0013 amendé) | ⏳ | `plans/2026-11-03-position-dans-l-adresse.md` |
| 0e.5 | Page d'accueil, installation en un toucher (« Ajouter à l'écran d'accueil », proposée par l'app), tutoriel des règles très didactique avec plateaux illustrés, aide réécrite sur un ton plus humain ; textes à valider par Pierre | ⏳ | `plans/2026-11-03-accueil-tutoriel-aide.md` |
| 0e.5 | Page d'accueil, mise à jour de l'app en un toucher, installation en un toucher (« Ajouter à l'écran d'accueil », proposée par l'app), tutoriel des règles très didactique avec plateaux illustrés, aide réécrite sur un ton plus humain ; textes à valider par Pierre | ⏳ | `plans/2026-11-03-accueil-tutoriel-aide.md` |
## Étapes suivantes
+3
View File
@@ -52,10 +52,12 @@ exportée. `make test` vérifie le lien dans les deux sens : chaque fiche avec
| [Identifiant de match](identifiant-de-match.md) | Match ID |
| [Identifiant de position](identifiant-de-position.md) | Position ID |
| [Jet](jet.md) | Roll |
| [Lien de position](lien-de-position.md) | Position link |
| [Match](match.md) | Match |
| [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 +66,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 |
+6 -1
View File
@@ -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)
+2 -1
View File
@@ -1,9 +1,10 @@
---
term: Identifiant de match
english: Match ID
code: MatchId
---
# Identifiant de match
**En bref** : code de 12 caractères qui décrit le reste de l'état du jeu : score, videau, joueur au trait, dés ; c'est le format de GNU Backgammon.
Voir aussi : [Identifiant de position](identifiant-de-position.md)
Voir aussi : [Identifiant de position](identifiant-de-position.md), [Lien de position](lien-de-position.md)
+1 -1
View File
@@ -7,4 +7,4 @@ code: PositionId
**En bref** : code de 14 caractères, par exemple 4HPwATDgc/ABMA, qui décrit la place de tous les pions ; c'est le format de GNU Backgammon.
Voir aussi : [Identifiant de match](identifiant-de-match.md)
Voir aussi : [Identifiant de match](identifiant-de-match.md), [Lien de position](lien-de-position.md)
+15
View File
@@ -0,0 +1,15 @@
---
term: Lien de position
english: Position link
code: PositionLink
---
# Lien de position
**En bref** : une adresse qui ouvre directement une position du plateau, à partager, à garder ou à coller dans une question ; elle contient l'identifiant de position et l'identifiant de match.
Le lien montre la position du point de vue du joueur qui a la main : c'est
vous, en bas. « Jouer à partir d'ici » commence une nouvelle partie depuis
cette position, sans toucher à votre partie en cours. Le lien ne contient que
la position : aucune donnée personnelle.
Voir aussi : [Identifiant de position](identifiant-de-position.md), [Identifiant de match](identifiant-de-match.md)
+14
View File
@@ -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)
+13
View File
@@ -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.
+17 -2
View File
@@ -41,9 +41,24 @@ Traiter les points 2 et 3 du message de Pierre du 2026-11-03 et sa demande d'ins
- « Les mots du jeu » : les cartes du glossaire, avec une recherche ;
- « À propos » : licence, crédits, version ;
- le tutoriel est proposé en tête de l'aide.
5. **Scénarios Gherkin** : premier lancement, installation, suivre le tutoriel.
6. **`make test-browser`** :
5. **Mise à jour de l'application** (demande de Pierre du 2026-11-09). Quand
le service worker trouve une nouvelle version, l'app affiche un bandeau
discret « Nouvelle version disponible » avec « Mettre à jour », jamais
pendant un coup en cours. Toucher le bouton active la nouvelle version
(`skipWaiting`, puis rechargement au `controllerchange`), et la partie
reprend où elle en était. Le menu propose aussi « Rechercher une mise à
jour » (`registration.update()`), qui dit « Vous avez la dernière
version » sinon. L'onglet « À propos » affiche la version publiée (le
commit de `version.json`). Aujourd'hui, `web/sw.js` appelle
`skipWaiting()` dès l'installation et efface l'ancien cache : une page
ouverte garde son ancien code et peut ne plus trouver les anciens
fichiers. Le nouveau service worker doit attendre l'accord du joueur, sauf
à la toute première installation.
6. **Scénarios Gherkin** : premier lancement, installation, suivre le tutoriel.
7. **`make test-browser`** :
- premier lancement, tutoriel complet avec ses écrans « à vous », aide ;
- mise à jour : publier deux versions du site de test l'une après
l'autre, voir le bandeau, mettre à jour, retrouver la partie ;
- `beforeinstallprompt` simulé dans Chromium ;
- captures dans `build/browser-shots/accueil-tutoriel/` pour la recette design.
+46
View File
@@ -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.
+36
View File
@@ -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.
+32
View File
@@ -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.
@@ -1,50 +0,0 @@
# Une adresse pour chaque écran, et pour chaque position (0e.4)
- **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)
- **Exécutant** : sous-agent general-purpose, dans un worktree
## Objectif
Appliquer l'ADR 0013 et son amendement du 2026-11-03 : chaque écran (aide,
glossaire, menu, mes parties…) a son adresse et s'ouvre par un vrai lien ;
chaque position du plateau a un lien direct.
## Consignes
ADR 0007, 0008 et 0013. Aucune nouvelle dépendance.
0. **Routeur** : un petit module pur (sans dépendance) qui traduit le
fragment en écran et inversement, testé unitairement ; l'app dessine
l'écran demandé par l'adresse, sur `hashchange`. Tous les écrans existants
(aide et ses questions, glossaire et ses termes, à propos, menu, mes
parties de 1b) passent par lui, avec de vrais liens `<a href>` ; le bouton
retour du navigateur ramène à l'écran précédent. Les actions de jeu restent
des boutons.
1. Lecture de `#/position/<Position ID>/<Match ID>` ; validation par le
moteur ; message clair si illisible.
2. Vue de la position, non jouable, avec « Jouer à partir d'ici » (nouvelle
partie depuis la position, la partie en cours restant reprenable) et
« Retour à ma partie ».
3. Domaine : démarrer une partie depuis une position et un Match ID donnés
(événement GameStarted étendu de façon rétrocompatible, ou équivalent
justifié), sans règle réimplémentée.
4. « Copier le lien de la position » depuis le menu de la partie et depuis la
vue du coup de l'ordinateur ; le lien copié rouvre exactement la même
position (test aller-retour).
5. Scénarios Gherkin (« Ouvrir un lien vers une position », « Partager la
position »), fiches du glossaire (Position ID, Match ID si absentes).
6. `make lint`, `make test`, `make test-browser` : chaque adresse du tableau
de l'ADR ouverte directement dans un nouvel onglet montre le bon écran ;
« retour » du navigateur ; liens reconnus comme liens (rôle `link`) ;
position : ouvrir un lien, jouer à partir d'ici, revenir, lien invalide.
## Critères de réussite
Un lien copié depuis l'app, ouvert dans un nouvel onglet, montre la même
position ; les trois commandes passent.
## Résultat
À remplir à la clôture.
+30
View File
@@ -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.
+42
View File
@@ -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.
+2
View File
@@ -47,4 +47,6 @@ 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 |
| [partager-une-position.feature](partager-une-position.feature) | Ouvrir et partager le lien d'une position | Étape 0e.4 |
| [regler-l-application.feature](regler-l-application.feature) | Régler l'application (menu) | Étape 0e |
+36
View File
@@ -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
@@ -0,0 +1,43 @@
# language: fr
Fonctionnalité: Partager une position
Chaque position du plateau a son lien : une adresse qui la rouvre telle
quelle, à envoyer à un ami, à garder pour plus tard ou à coller dans une
question. Le lien contient les deux identifiants de GNU Backgammon, celui
de la position et celui du match, et rien d'autre.
Scénario: Partager la position
Pendant la partie, « Copier le lien de la position », dans le menu ou à
côté du dernier coup de l'ordinateur, copie l'adresse de la position à
l'écran. Ouvert ailleurs, le lien montre la même position.
Étant donné que votre partie en cours compte quelques coups
Quand vous copiez le lien de la position
Alors le lien contient l'identifiant de la position et celui du match
Quand vous ouvrez ce lien
Alors vous voyez exactement la même position, avec le même videau
Scénario: Ouvrir un lien vers une position
Un lien vers une position montre le plateau, sans le rendre jouable :
vous y êtes en bas, avec la main. « Jouer à partir d'ici » commence une
nouvelle partie contre l'ordinateur depuis cette position, et votre
partie en cours reste dans « Mes parties ». « Retour à ma partie » vous
ramène à votre partie, intacte.
Étant donné que votre partie en cours compte quelques coups
Quand vous ouvrez un lien vers une position où le videau est à 2, chez l'ordinateur
Alors vous voyez cette position, avec la main
Et votre partie n'a pas changé
Quand vous choisissez « Jouer à partir d'ici »
Alors une nouvelle partie commence à cette position, à vous de lancer les dés
Et le videau est à 2, chez l'ordinateur
Et votre partie précédente est toujours enregistrée
Scénario: Ouvrir un lien abîmé
Un lien coupé ou mal recopié ne casse rien : un message le dit, et
« Retour à ma partie » reste là.
Quand vous ouvrez un lien dont l'identifiant de position est abîmé
Alors le lien est déclaré illisible
Quand vous ouvrez un lien dont l'identifiant de match est abîmé
Alors le lien est déclaré illisible
+528 -16
View File
@@ -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);
@@ -76,6 +76,15 @@ await context.addInitScript(() => {
return null;
};
}
// Les liens copiés (« Copier le lien de la position ») : le
// presse-papiers du navigateur sans écran n'est pas lisible partout.
window.__copied = [];
if (window.Clipboard) {
Clipboard.prototype.writeText = function (text) {
window.__copied.push(String(text));
return Promise.resolve();
};
}
window.__vibrations = [];
Object.defineProperty(Navigator.prototype, "vibrate", {
configurable: true,
@@ -124,9 +133,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 +246,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})`);
@@ -232,10 +300,49 @@ async function checkStable(when) {
stableChecks++;
}
/** Les boutons visibles d'une page dont le texte passe à la ligne : un
* bouton tient sur une ligne et fait 48 px de haut (charte). Un bouton plus
* haut que 48 px n'est refusé que si son texte occupe plusieurs lignes ;
* un texte coupé par le bord (white-space: nowrap) est refusé aussi. */
async function wrappedButtons(target) {
return target.evaluate(() => {
const wrapped = [];
for (const el of document.querySelectorAll("button, a.button")) {
const r = el.getBoundingClientRect();
if (el.closest("[hidden]") || r.width === 0 || r.height === 0) {
continue;
}
const name = el.id ? `#${el.id}` : el.textContent.trim();
const tops = new Set();
const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT);
for (let node = walker.nextNode(); node !== null; node = walker.nextNode()) {
if (node.textContent.trim() === "") {
continue;
}
const range = document.createRange();
range.selectNodeContents(node);
for (const line of range.getClientRects()) {
if (line.width > 0 && ![...tops].some((top) => Math.abs(top - line.top) < 4)) {
tops.add(line.top);
}
}
}
if (r.height > 48.5 && tops.size > 1) {
wrapped.push(`${name} sur ${tops.size} lignes (${Math.round(r.height)} px)`);
} else if (el.scrollWidth > el.clientWidth + 1) {
wrapped.push(`${name} coupé (${el.scrollWidth} px pour ${el.clientWidth})`);
}
}
return wrapped;
});
}
/** Les critères de la recette vérifiables sur la vue courante. Renvoie des
* mesures pour le compte rendu. */
async function checkView(width, height, when) {
const where = `${width}x${height} (${when})`;
const wrapped = await wrappedButtons(page);
check(wrapped.length === 0, `${where} : boutons sur plusieurs lignes : ${wrapped.join(", ")}`);
const seen = await page.evaluate(() => {
const visible = (el) => {
if (el.closest("[hidden]")) {
@@ -253,7 +360,7 @@ async function checkView(width, height, when) {
el.className?.baseVal ?? el.className;
// Cibles tactiles : boutons et zones d'action du plateau.
const small = [];
for (const el of document.querySelectorAll("button, [role=button], [role=switch], [data-action]")) {
for (const el of document.querySelectorAll("button, [role=button], [role=switch], [data-action], a.icon-button, a.item, a.button")) {
if (!visible(el)) {
continue;
}
@@ -345,8 +452,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 +465,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 };
@@ -468,7 +576,7 @@ async function touchTarget(action, label, count = 1, index = 0) {
check((await zones.count()) === count,
`zone ${action} : ${await zones.count()} sur le plateau, ${count} attendues`);
for (const zone of await zones.all()) {
check((await zone.getAttribute("role")) === "button" &&
check((await zone.getAttribute("role")) === (action === "menu" ? "link" : "button") &&
(await zone.getAttribute("aria-label")) === label, `zone ${action} sans rôle accessible`);
const box = await zone.boundingBox();
check(box.width >= 44 && box.height >= 44,
@@ -528,10 +636,329 @@ async function checkReplay(traces, reducedMotion) {
await page.emulateMedia({ reducedMotion: "no-preference" });
}
// ----------------------------------------------------------- Adresses
/** Un nouvel onglet ouvert directement sur une adresse (fragment ou URL
* complète), ses erreurs comptées comme celles de la page principale. */
async function openTab(address, viewport = PORTRAIT) {
const tab = await context.newPage();
await tab.setViewportSize(viewport);
tab.on("pageerror", (error) => problems.push(`erreur de la page (${address}) : ${error.message}`));
tab.on("console", (message) => {
if (message.type() === "error") {
problems.push(`console (${address}) : ${message.text()}`);
}
});
tab.on("dialog", (dialog) => {
problems.push(`boîte native ouverte (${address}) : ${dialog.type()}`);
void dialog.dismiss();
});
await tab.goto(new URL(address, base).href);
return tab;
}
/** Ce que montre un onglet : écran, question, terme, feuille, message. */
async function screenOf(tab) {
return tab.evaluate(() => {
const shown = (id) => {
const el = document.getElementById(id);
return el !== null && !el.hasAttribute("hidden") && el.getClientRects().length > 0;
};
const text = (id) => document.getElementById(id)?.textContent ?? "";
return {
hash: location.hash,
busy: document.body.classList.contains("busy"),
game: shown("game"),
help: shown("help"),
play: shown("help-play"),
glossary: shown("help-glossary"),
about: shown("help-about"),
question: document.querySelector("#help-play .question.open")?.id ?? null,
term: document.querySelector("#help-glossary dt.current")?.id ?? null,
menu: shown("menu"),
games: shown("games"),
listed: document.querySelectorAll("#games .games-item").length,
expanded:
document
.querySelector('#games [data-action="branches"][aria-expanded="true"]')
?.closest(".games-item")?.dataset.id ?? null,
position: shown("position-view"),
positionBoard: shown("position-board"),
positionTitle: text("position-title"),
positionMessage: text("position-message"),
positionPlay: shown("position-play"),
info: text("info"),
};
});
}
/** Attend que l'onglet montre l'écran attendu (expected : test sur
* screenOf), au plus 30 s ; sinon, échoue avec ce qu'il montre. */
async function expectScreen(tab, what, expected) {
let state = null;
for (let i = 0; i < 300; i++) {
state = await screenOf(tab);
if (expected(state)) {
return state;
}
await sleep(100);
}
await tab.screenshot({ path: `${shots}/${browserName}-adresse-echec.png` }).catch(() => {});
throw new Error(`${what} : écran inattendu ${JSON.stringify(state)}`);
}
/** Le dernier lien copié par l'application dans cet onglet. */
async function lastCopied(tab) {
for (let i = 0; i < 50; i++) {
const copied = await tab.evaluate(() => window.__copied.at(-1) ?? null);
if (copied !== null) {
return copied;
}
await sleep(100);
}
throw new Error("aucun lien copié");
}
/** L'élément est-il exposé comme un lien (rôle link) ? */
async function isLink(tab, selector) {
const all = tab.locator(selector);
const links = all.and(tab.getByRole("link"));
return (await all.count()) > 0 && (await links.count()) === (await all.count());
}
async function checkAddresses() {
const dir = `${shots}/adresses`;
mkdirSync(dir, { recursive: true });
const playing = (s) => s.hash === "#/" && s.game && !s.help && !s.position && !s.menu && !s.games;
// Le lien de la position en cours, copié depuis le menu de la partie.
await openMenu();
await page.evaluate(() => {
window.__copied = [];
});
await page.click("#menu-copy-link");
const positionUrl = await lastCopied(page);
const positionHash = new URL(positionUrl).hash;
check(/^#\/position\/[^/]{14,}\/[^/]+$/.test(positionHash), `lien de position copié : ${positionUrl}`);
check(!(await page.isVisible("#menu")), "le menu reste ouvert après la copie du lien");
// Chaque adresse du tableau de l'ADR, dans un nouvel onglet. Les
// identifiants d'une question et d'une partie sont lus en chemin.
let question = null;
let gameId = null;
let gamesBefore = 0;
const notYet = (s) => s.hash === "#/" && s.game && s.info.includes("n'existe pas encore");
const addresses = [
["#/", playing],
["#/accueil", notYet],
["#/aide", (s) => s.help && s.play && !s.glossary && !s.about && s.question === null],
[() => `#/aide/${question}`, (s) => s.help && s.play && s.question === `aide-${question}`],
["#/glossaire", (s) => s.help && s.glossary && !s.play && s.term === null],
["#/glossaire/lien-de-position", (s) => s.help && s.glossary && s.term === "glossaire-lien-de-position"],
["#/a-propos", (s) => s.help && s.about && !s.play && !s.glossary],
["#/tutoriel", notYet],
["#/tutoriel/2", notYet],
["#/parties", (s) => s.games && s.listed >= 2 && !s.menu],
[() => `#/parties/${gameId}`, (s) => s.games && s.expanded === gameId],
["#/menu", (s) => s.menu && !s.games],
[positionHash, (s) => s.position && s.positionBoard && !s.game && s.positionPlay],
[`#/position/${positionHash.split("/")[2]}`, (s) => s.position && s.positionBoard],
["#/position/pas-une-position", (s) =>
s.position && !s.positionBoard && s.positionMessage.includes("ne décrit pas une position")],
["#/nulle-part", (s) => s.hash === "#/" && s.game && s.info.includes("ne mène à aucun écran")],
];
for (const [address, expected] of addresses) {
const hash = typeof address === "function" ? address() : address;
const tab = await openTab(hash);
await expectScreen(tab, `adresse ${hash}`, expected);
if (hash === "#/aide") {
question = await tab.evaluate(() =>
document.querySelector("#help-play .question")?.id.replace(/^aide-/, ""));
check(typeof question === "string" && question !== "", "aide : aucune question");
}
if (hash === "#/parties") {
await tab.waitForSelector("#games .games-item");
gameId = await tab.evaluate(() => document.querySelector("#games .games-item")?.dataset.id);
gamesBefore = (await screenOf(tab)).listed;
}
await tab.close();
}
log(`adresses : ${addresses.length} adresses ouvertes directement, chacune sur son écran`);
// Captures : l'aide ouverte par son adresse, une question, un terme, la
// vue d'une position, un lien illisible ; en portrait et en paysage.
const captures = [
["aide", "#/aide", (s) => s.help && s.play],
["question", `#/aide/${question}`, (s) => s.question === `aide-${question}`],
["terme", "#/glossaire/lien-de-position", (s) => s.term === "glossaire-lien-de-position"],
["position", positionHash, (s) => s.position && s.positionBoard],
["lien-invalide", "#/position/pas-une-position", (s) => s.position &&
s.positionMessage.includes("ne décrit pas une position")],
];
for (const [width, height] of [[412, 915], [915, 412]]) {
for (const [name, hash, expected] of captures) {
const tab = await openTab(hash, { width, height });
await expectScreen(tab, `capture ${name}`, expected);
await sleep(300);
await tab.screenshot({ path: `${dir}/${browserName}-${name}-${width}x${height}.png` });
const where = `${name} ${width}x${height}`;
const wrapped = await wrappedButtons(tab);
check(wrapped.length === 0, `${where} : boutons sur plusieurs lignes : ${wrapped.join(", ")}`);
if (name === "question" || name === "terme") {
// La barre haute de l'aide reste en vue, et ce qu'on a
// ouvert s'arrête juste sous elle.
const seen = await tab.evaluate(() => {
const bar = document.getElementById("help-bar").getBoundingClientRect();
const target = document.querySelector(
"#help-play .question.open, #help-glossary dt.current",
).getBoundingClientRect();
return { scrolled: window.scrollY, barTop: bar.top, barBottom: bar.bottom, top: target.top };
});
check(seen.scrolled > 0 && Math.abs(seen.barTop) <= 1,
`${where} : barre de l'aide hors de la vue (${JSON.stringify(seen)})`);
check(Math.abs(seen.top - (seen.barBottom + 12)) <= 2,
`${where} : élément à ${seen.top} px, barre jusqu'à ${seen.barBottom} px`);
}
if (name === "position") {
// Les trois actions, dans l'ordre, visibles sans défiler.
const seen = await tab.evaluate(() => ({
scrolled: window.scrollY,
order: [...document.querySelectorAll(".position-actions > *")].map((el) => el.id),
boxes: ["position-play", "position-copy", "position-back"].map((id) => {
const r = document.getElementById(id).getBoundingClientRect();
return [r.left, r.top, r.right, r.bottom];
}),
board: (() => {
const r = document.getElementById("position-board").getBoundingClientRect();
return [r.left, r.top, r.width, r.height];
})(),
cube: (() => {
const r = document.querySelector("#position-board .cube-face")?.getBoundingClientRect();
return r === undefined ? null : [r.left, r.top, r.right, r.bottom];
})(),
controls: document.querySelectorAll("#position-board [data-action]").length,
}));
check(seen.order.join() === "position-play,position-copy,position-back",
`${where} : actions dans l'ordre ${seen.order}`);
const inView = seen.boxes.every(([l, t, r, b]) => l >= 0 && t >= 0 && r <= width && b <= height);
if (width > height) {
check(seen.scrolled === 0 && inView,
`${where} : actions hors de la vue sans défiler : ${JSON.stringify(seen.boxes)}`);
check(seen.board[0] === 0 && seen.board[1] === 0 && Math.abs(seen.board[3] - height) <= 1,
`${where} : plateau pas sur toute la hauteur à gauche : ${seen.board}`);
// Le videau, dans la colonne gauche du cadre, entier à
// l'écran ; ni menu ni annuler : la position ne se joue pas.
const [cl, ct, cr, cb] = seen.cube ?? [-1, -1, -1, -1];
check(seen.cube !== null && cl >= 0 && ct >= 0 && cr <= seen.board[2] && cb <= height,
`${where} : videau coupé : ${JSON.stringify(seen.cube)}`);
check(seen.controls === 0, `${where} : ${seen.controls} commandes sur le plateau`);
} else {
// Empilées sur toute la largeur.
const [first] = seen.boxes;
check(seen.boxes.every(([l, , r], i) => Math.abs(l - first[0]) <= 1 &&
Math.abs(r - first[2]) <= 1 && (i === 0 || seen.boxes[i - 1][3] <= seen.boxes[i][1])),
`${where} : actions pas empilées : ${JSON.stringify(seen.boxes)}`);
}
}
await tab.close();
}
}
// Retour du navigateur et rôle des liens, dans un même onglet.
{
const tab = await openTab("#/");
await expectScreen(tab, "partie", playing);
for (const selector of ["#help-button", "#menu-button"]) {
check(await isLink(tab, selector), `${selector} n'est pas un lien`);
}
for (const selector of ["#roll, #confirm, #again, #double", "#fullscreen-button"]) {
check((await tab.locator(selector).and(tab.getByRole("link")).count()) === 0,
`${selector} : une action de jeu exposée comme un lien`);
}
await tab.click("#help-button");
await expectScreen(tab, "aide par son lien", (s) => s.hash === "#/aide" && s.help && s.play);
for (const selector of ["#help-back", "#help-tabs a", "#help-play .question h3 a"]) {
check(await isLink(tab, selector), `${selector} : pas des liens`);
}
await tab.click(`#aide-${question} h3 a`);
await expectScreen(tab, "question par son lien", (s) => s.hash === `#/aide/${question}` &&
s.question === `aide-${question}`);
await tab.goBack();
await expectScreen(tab, "retour : question refermée", (s) => s.hash === "#/aide" && s.help &&
s.question === null);
await tab.click('#help-tabs a[data-screen="glossary"]');
await expectScreen(tab, "onglet glossaire", (s) => s.hash === "#/glossaire" && s.glossary);
check(await isLink(tab, "#help-glossary dt a"), "les termes du glossaire ne sont pas des liens");
await tab.goBack();
await expectScreen(tab, "retour : onglet Jouer", (s) => s.hash === "#/aide" && s.play);
await tab.goBack();
await expectScreen(tab, "retour : la partie", playing);
// Le menu, puis « Mes parties » depuis le menu : le retour ferme la
// feuille et ramène à la partie, sans rouvrir le menu.
await tab.click("#menu-button");
await expectScreen(tab, "menu par son lien", (s) => s.hash === "#/menu" && s.menu);
for (const selector of ["#menu-games", "#menu-help"]) {
check(await isLink(tab, selector), `${selector} n'est pas un lien`);
}
check((await tab.locator("#menu-new-game").and(tab.getByRole("button")).count()) === 1,
"« Nouvelle partie » n'est plus un bouton");
await tab.goBack();
await expectScreen(tab, "retour : menu fermé", playing);
await tab.click("#menu-button");
await expectScreen(tab, "menu", (s) => s.menu);
await tab.click("#menu-games");
await expectScreen(tab, "mes parties depuis le menu", (s) => s.hash === "#/parties" && s.games &&
!s.menu);
await tab.goBack();
await expectScreen(tab, "retour : mes parties fermé", playing);
// Échap quitte l'aide comme « Retour ».
await tab.click("#help-button");
await expectScreen(tab, "aide", (s) => s.help);
await tab.keyboard.press("Escape");
await expectScreen(tab, "Échap : la partie", playing);
await tab.close();
}
log("adresses : retour du navigateur (question, onglet, menu, mes parties), Échap, rôle link des liens");
// Aller-retour d'un lien de position : le lien copié depuis la vue
// ouverte par ce lien est le même ; « Retour à ma partie » ; « Jouer à
// partir d'ici » commence une partie de plus, l'ancienne restant
// reprenable.
{
const tab = await openTab(positionUrl);
await expectScreen(tab, "lien de position", (s) => s.position && s.positionBoard && s.positionPlay);
await tab.click("#position-copy");
const again = await lastCopied(tab);
check(again === positionUrl, `aller-retour du lien : ${again} au lieu de ${positionUrl}`);
check(await isLink(tab, "#position-back"), "« Retour à ma partie » n'est pas un lien");
await tab.click("#position-back");
await expectScreen(tab, "retour à ma partie (nouvel onglet)", playing);
// Depuis la partie : le lien suivi, puis « Retour à ma partie ».
await tab.evaluate((hash) => {
location.hash = hash;
}, positionHash);
await expectScreen(tab, "lien suivi depuis la partie", (s) => s.position && s.positionBoard);
await tab.click("#position-back");
await expectScreen(tab, "retour à ma partie", playing);
await tab.goForward();
await expectScreen(tab, "avance : la position", (s) => s.position && s.positionPlay);
await tab.click("#position-play");
await expectScreen(tab, "jouer à partir d'ici", (s) => playing(s) && !s.busy);
await tab.goto(new URL("#/parties", base).href);
const after = await expectScreen(tab, "mes parties", (s) => s.games && s.listed > 0);
check(after.listed === gamesBefore + 1,
`jouer à partir d'ici : ${after.listed} parties, ${gamesBefore + 1} attendues`);
await tab.close();
}
log("adresses : lien de position copié, rouvert à l'identique, retour à la partie, partie jouée depuis la position");
}
// ------------------------------------------------------------- Menu
async function openMenu() {
await page.click("#menu-button");
// Le menu a son adresse (#/menu) : il s'ouvre au changement d'adresse.
await page.waitForSelector("#menu", { state: "visible", timeout: 5000 }).catch(() => {});
check(await page.isVisible("#menu"), "le menu ne s'ouvre pas");
}
@@ -894,10 +1321,19 @@ for (let actions = 0; ; actions++) {
} else {
await (await touchTarget("confirm", "Jouer ce coup", 2, confirms % 2)).click();
}
await waitForPlayer();
const played = await savedJournal();
check(countMoves(played, "player") === countMoves(journal, "player") + 1,
"le coup joué n'est pas dans le journal");
const next = await waitForPlayer();
// L'application enregistre sans attendre ; sur un disque lent,
// l'écriture d'IndexedDB peut suivre de loin : on lui laisse 10 s.
const expected = countMoves(journal, "player") + 1;
let played = await savedJournal();
for (let i = 0; i < 100 && countMoves(played, "player") < expected; i++) {
await sleep(100);
played = await savedJournal();
}
check(countMoves(played, "player") === expected,
`le coup joué n'est pas dans le journal : ${countMoves(played, "player")} coups ` +
`enregistrés, ${expected} attendus ; à l'écran : ${next}, ` +
`« ${await page.textContent("#info")} »`);
halfMoves = 0;
touched = false;
} else {
@@ -1014,9 +1450,11 @@ for (let actions = 0; ; actions++) {
// Le clavier ouvre aussi le menu (M) et Échap le ferme.
await page.evaluate(() => document.activeElement?.blur());
await page.keyboard.press("m");
await page.waitForSelector("#menu", { state: "visible", timeout: 5000 }).catch(() => {});
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 +1464,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,10 +1526,72 @@ 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");
await page.waitForSelector(".glossary dt");
await page.waitForSelector(".glossary dt", { state: "attached" });
const terms = await page.locator(".glossary dt").count();
const scenarios = await page.locator("#help-content h3").count();
check(terms >= 20 && scenarios >= 9, `aide incomplète : ${terms} termes, ${scenarios} scénarios`);
@@ -1156,7 +1658,7 @@ if (offline === "roll") {
await waitForPlayer();
}
await page.click("#help-button");
await page.waitForSelector(".glossary dt");
await page.waitForSelector(".glossary dt", { state: "attached" });
await page.click("#help-back");
log(
offlineMode
@@ -1188,6 +1690,16 @@ check(
);
log("CSP : aucune violation pendant la partie, script en ligne bloqué ; aucune boîte native");
step("adresses");
// Chaque écran a son adresse (ADR 0013, amendement du 2026-11-03) : ouverte
// directement dans un nouvel onglet, elle montre le bon écran ; le retour du
// navigateur referme d'abord la question ou la feuille ouverte ; les liens
// sont des liens (rôle link) ; un lien de position fait l'aller-retour.
if (offlineMode) {
await context.setOffline(false);
}
await checkAddresses();
log(`recette : ${recette.views} vues vérifiées (${stableChecks} contrôles de stabilité du ` +
`portrait, ${recette.rotations} rotations sans perte d'état), numéros des flèches à ` +
`${recette.minNumber.toFixed(1)} px au moins, réponse au toucher en ${responseMs.toFixed(1)} ms ` +
+87
View File
@@ -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
View File
@@ -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)"
+316 -5
View File
@@ -363,6 +363,8 @@ button {
border: 2px solid var(--accent);
border-radius: var(--radius-pill);
cursor: pointer;
/* Un bouton tient sur une ligne (charte). */
white-space: nowrap;
/* Pas de délai ni de zoom sur un double toucher. */
touch-action: manipulation;
-webkit-tap-highlight-color: transparent;
@@ -1165,14 +1167,14 @@ html.landscape .banner {
bottom: var(--space-2);
}
/* L'aide reste une page qu'on fait défiler. */
html.landscape.help-open,
html.landscape.help-open body {
/* L'aide et la vue d'une position restent des pages qu'on fait défiler. */
html.landscape.page-open,
html.landscape.page-open body {
height: auto;
overflow: visible;
}
html.landscape.help-open body {
html.landscape.page-open body {
padding: 0 var(--space-4);
background: var(--background);
}
@@ -1202,6 +1204,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 {
@@ -1265,7 +1308,7 @@ html.rotated .banner {
}
.about {
margin-top: var(--space-8);
margin-top: var(--space-2);
font-size: 15px;
line-height: 20px;
color: var(--muted);
@@ -1275,3 +1318,271 @@ html.rotated .banner {
user-select: text;
-webkit-user-select: text;
}
/* La barre haute de l'aide (« Retour » et onglets) reste en vue quand la
* page défile. Sa hauteur est fixe (12 + 48 + 8 + 44 + 1 px de filet) : une
* question ou un terme ouvert par son adresse s'arrête 12 px sous elle. */
.help {
--help-bar-height: 113px;
}
.help-bar {
position: sticky;
top: env(safe-area-inset-top);
z-index: 3;
height: var(--help-bar-height);
margin-inline: calc(-1 * var(--space-4));
padding: var(--space-3) var(--space-4) 0;
background: var(--background);
}
.help .question,
.glossary dt {
scroll-margin-top: calc(var(--help-bar-height) + 12px + env(safe-area-inset-top));
}
/* Les onglets de l'aide : des liens, l'onglet courant souligné en brique ;
* sur une ligne, qui défile si l'écran est trop étroit. */
.help-tabs {
display: flex;
gap: var(--space-1);
overflow-x: auto;
white-space: nowrap;
margin-top: var(--space-2);
border-bottom: 1px solid var(--border);
}
.help-tabs a {
display: inline-flex;
align-items: center;
min-height: 44px;
padding: 0 var(--space-3);
font-weight: 500;
color: var(--muted);
text-decoration: none;
border-bottom: 3px solid transparent;
}
.help-tabs a[aria-current="page"] {
font-weight: 700;
color: var(--text);
border-bottom-color: var(--accent);
}
.help-notice:empty {
display: none;
}
/* Une question de l'aide : son titre est un lien qui l'ouvre (son adresse),
* ou la referme quand elle est ouverte. */
.question {
border-bottom: 1px solid var(--border);
}
.help .question h3 {
margin: 0;
}
.question h3 a {
display: flex;
gap: var(--space-2);
align-items: center;
min-height: 52px;
padding: var(--space-2) 0;
color: var(--text);
text-decoration: none;
}
.question h3 a::after {
content: "";
flex: none;
width: 9px;
height: 9px;
margin: 0 6px 4px auto;
border-right: 2px solid var(--muted);
border-bottom: 2px solid var(--muted);
transform: rotate(45deg);
}
.question.open h3 a::after {
margin-bottom: -4px;
transform: rotate(-135deg);
}
.answer {
padding-bottom: var(--space-3);
}
/* Le terme ouvert par son adresse (#/glossaire/<terme>). */
.glossary dt a {
color: var(--text);
text-decoration: none;
}
.glossary dt.current,
.glossary dt.current + dd {
margin-inline: calc(-1 * var(--space-4));
padding-inline: var(--space-4);
background: var(--accent-soft);
/* Le surlignage montre où l'on arrive, puis s'efface après 2 s. */
animation: term-fade var(--duration-moderate) var(--easing-exit) 2s forwards;
}
@keyframes term-fade {
to { background-color: transparent; }
}
/* -------------------------------------------------- Liens et boutons */
/* Un lien qui mène à un écran, avec l'allure d'un bouton. */
a.button {
display: inline-flex;
gap: 6px;
align-items: center;
justify-content: center;
min-height: var(--button-height);
padding: 0 20px;
font-weight: 700;
color: var(--on-accent);
text-decoration: none;
background: var(--accent);
border: 2px solid var(--accent);
border-radius: var(--radius-pill);
white-space: nowrap;
touch-action: manipulation;
-webkit-tap-highlight-color: transparent;
}
a.button.secondary {
color: var(--text);
background: transparent;
border-color: var(--border-strong);
}
a.button:active {
transform: translateY(1px);
filter: brightness(0.94);
}
a.icon-button,
a.item,
a.link-button {
text-decoration: none;
touch-action: manipulation;
-webkit-tap-highlight-color: transparent;
}
a.link-button {
display: inline-flex;
align-items: center;
text-decoration: underline;
}
.infoline .copy-link {
color: var(--accent);
}
/* Zone de texte de la copie de repli (presse-papiers), hors de l'écran. */
.offscreen {
position: fixed;
top: 0;
left: -10000px;
opacity: 0;
}
/* ------------------------------------------------ Vue d'une position */
.position-view {
max-width: var(--page-width);
margin: 0 auto;
padding-bottom: var(--space-6);
}
.position-title {
margin: var(--space-6) 0 var(--space-2);
font-size: 24px;
line-height: 30px;
}
.position-message:empty {
display: none;
}
.position-board {
margin: var(--space-2) 0;
}
.position-detail {
margin: var(--space-2) 0 var(--space-4);
font-size: 15px;
line-height: 20px;
color: var(--muted);
}
/* Les actions empilées sur toute la largeur, la principale en premier. */
.position-actions {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.position-actions > * {
width: 100%;
}
#position-copy {
display: inline-flex;
gap: 6px;
align-items: center;
justify-content: center;
}
#position-copy .icon {
width: 20px;
height: 20px;
}
/* En paysage, comme l'écran de jeu : le plateau à gauche sur toute la
* hauteur (colonne gauche du cadre avec le videau seul), le titre, le contexte et les actions
* dans une colonne à droite. */
html.landscape .position-view.with-board {
position: fixed;
inset: 0;
display: grid;
grid-template-columns: minmax(0, 1fr) 288px;
grid-template-rows: auto auto auto auto 1fr;
max-width: none;
margin: 0;
padding: 0;
overflow-y: auto;
background: var(--background);
}
html.landscape .position-view.with-board > * {
grid-column: 2;
margin-inline: 0;
padding-inline: var(--space-4) calc(var(--space-4) + env(safe-area-inset-right));
}
html.landscape .position-view.with-board .position-title {
margin-top: var(--space-4);
}
html.landscape .position-view.with-board .position-board {
grid-row: 1 / -1;
grid-column: 1;
width: 100%;
height: 100%;
margin: 0;
padding: 0;
aspect-ratio: auto;
background: var(--frame);
}
html.landscape .position-view.with-board .position-actions {
padding-bottom: var(--space-4);
}
html.landscape .position-view.with-board .position-actions > * {
padding-inline: 16px;
}
+35 -6
View File
@@ -29,9 +29,11 @@
<img class="logo-light" src="icons/logo-horizontal-couleur.svg" alt="Ustagammon">
<img class="logo-dark" src="icons/logo-horizontal-sombre.svg" alt="Ustagammon">
</h1>
<button id="help-button" type="button" class="icon-button labelled"></button>
<!-- Ce qui mène à un autre écran est un lien vers son adresse (ADR 0013,
amendement) ; les actions sont des boutons. -->
<a id="help-button" href="#/aide" class="icon-button labelled"></a>
<button id="fullscreen-button" type="button" class="icon-button labelled"></button>
<button id="menu-button" type="button" class="icon-button labelled"></button>
<a id="menu-button" href="#/menu" class="icon-button labelled"></a>
</header>
<main id="game" class="game">
@@ -46,7 +48,7 @@
<div id="pip-bubble" class="bubble" role="dialog" aria-labelledby="pip-bubble-term" hidden>
<p id="pip-bubble-term" class="bubble-term"></p>
<p class="bubble-text"></p>
<button type="button" class="link-button"></button>
<a class="link-button" href="#/glossaire"></a>
</div>
</div>
<div id="strip-player" class="strip">
@@ -58,6 +60,7 @@
<div class="infoline">
<p id="info" class="info" aria-live="polite"></p>
<button id="replay" type="button" class="icon-button replay" hidden></button>
<button id="copy-link" type="button" class="icon-button copy-link" hidden></button>
</div>
<p id="prompt" class="prompt" aria-live="polite"></p>
<div class="spacer"></div>
@@ -79,11 +82,35 @@
<noscript><p>Ustagammon a besoin de JavaScript.</p></noscript>
</main>
<section id="help" class="help" hidden>
<button id="help-back" type="button" class="secondary"></button>
<!-- L'aide : un onglet par adresse (#/aide, #/glossaire, #/a-propos). -->
<section id="help" class="help page" hidden>
<!-- La barre haute de l'aide reste en vue quand la page défile. -->
<div id="help-bar" class="help-bar">
<a id="help-back" href="#/" class="button secondary"></a>
<nav id="help-tabs" class="help-tabs">
<a href="#/aide" data-screen="help"></a>
<a href="#/glossaire" data-screen="glossary"></a>
<a href="#/a-propos" data-screen="about"></a>
</nav>
</div>
<div id="help-content"></div>
</section>
<!-- Une position partagée (#/position/<Position ID>/<Match ID>), non
jouable. En portrait, de haut en bas ; en paysage, le plateau à gauche
sur toute la hauteur, le reste dans une colonne à droite. -->
<section id="position-view" class="position-view page" aria-labelledby="position-title" hidden>
<h2 id="position-title" class="position-title"></h2>
<p id="position-message" class="position-message" role="status"></p>
<svg id="position-board" class="board position-board" role="img" xmlns="http://www.w3.org/2000/svg"></svg>
<p id="position-detail" class="position-detail"></p>
<div class="position-actions">
<button id="position-play" type="button"></button>
<button id="position-copy" type="button" class="secondary"></button>
<a id="position-back" href="#/" class="button secondary"></a>
</div>
</section>
<!-- Menu : une feuille qui monte du bas. -->
<div id="menu-veil" class="veil sheet-veil" hidden>
<div id="menu" class="sheet" role="dialog" aria-modal="true" aria-labelledby="menu-title">
@@ -93,8 +120,10 @@
<button id="menu-close" type="button" class="icon-button"></button>
</div>
<button id="menu-new-game" type="button" class="item"></button>
<a id="menu-games" href="#/parties" class="item"></a>
<button id="menu-copy-link" type="button" class="item"></button>
<button id="menu-fullscreen" type="button" class="item"></button>
<button id="menu-help" type="button" class="item"></button>
<a id="menu-help" href="#/aide" class="item"></a>
<button id="menu-theme" type="button" class="item"><span class="item-label"></span><span class="item-value"></span></button>
<button id="menu-vibrations" type="button" class="item" role="switch"><span class="item-label"></span><span class="switch" aria-hidden="true"></span></button>
<button id="menu-screen-on" type="button" class="item" role="switch"><span class="item-label"></span><span class="switch" aria-hidden="true"></span></button>