Compare commits
22
Commits
@@ -30,7 +30,8 @@ CADDY_IMAGE := caddy:2.8.4-alpine
|
||||
# Navigateurs sans écran pour make test-browser (Playwright, Apache-2.0).
|
||||
PLAYWRIGHT_IMAGE := mcr.microsoft.com/playwright:v1.48.2-noble
|
||||
|
||||
export SHELLCHECK_IMAGE GHERKIN_IMAGE BUILD_C_IMAGE WASM_IMAGE CADDY_IMAGE WEB_IMAGE PLAYWRIGHT_IMAGE
|
||||
export SHELLCHECK_IMAGE GHERKIN_IMAGE BUILD_C_IMAGE WASM_IMAGE CADDY_IMAGE WEB_IMAGE PLAYWRIGHT_IMAGE \
|
||||
REFERENCE_IMAGE
|
||||
|
||||
# Lance une commande dans la chaîne C, sous l'utilisateur courant pour que
|
||||
# build/ lui appartienne.
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
// Les branches d'une partie : les lignes de jeu de son journal.
|
||||
//
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
//
|
||||
// Le journal est un arbre (events.ts) : chaque événement pointe vers son
|
||||
// parent, et « Rejouer d'ici » accroche un nouvel événement à un ancien. Une
|
||||
// branche est le chemin du premier événement jusqu'à une feuille de l'arbre.
|
||||
// Les identifiants des événements sont attribués dans l'ordre d'ajout : de
|
||||
// deux enfants d'un même événement, le plus ancien a le plus petit indice
|
||||
// dans le journal, et c'est lui qui continue la ligne d'origine.
|
||||
|
||||
import { currentLine, type GameEvent, type GameJournal, goBackTo } from "./events.js";
|
||||
import { replay } from "./state.js";
|
||||
|
||||
/**
|
||||
* Une branche de la partie : la suite d'événements du début jusqu'à head.
|
||||
*
|
||||
* Glossaire : docs/glossaire/branche.md
|
||||
*/
|
||||
export interface Branch {
|
||||
/** Le dernier événement de la branche (une feuille de l'arbre). */
|
||||
readonly head: string;
|
||||
/** L'événement à partir duquel elle a été rejouée (« Rejouer d'ici ») ;
|
||||
* null pour la ligne d'origine de la partie. */
|
||||
readonly forkedFrom: string | null;
|
||||
/** Les coups joués avant de rejouer (0 : depuis le début) ; 0 aussi pour
|
||||
* la ligne d'origine. */
|
||||
readonly forkMoveCount: number;
|
||||
/** Les coups joués sur toute la branche, depuis le début. */
|
||||
readonly moveCount: number;
|
||||
/** La partie est finie au bout de cette branche. */
|
||||
readonly over: boolean;
|
||||
/** L'événement courant de la partie est sur cette branche. */
|
||||
readonly containsHead: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Les branches de la partie, dans l'ordre de leur création (celui de leur
|
||||
* premier événement propre) : la ligne d'origine d'abord. Liste vide pour un
|
||||
* journal vide.
|
||||
*/
|
||||
export function branches(journal: GameJournal): Branch[] {
|
||||
const order = new Map(journal.events.map((event, index) => [event.id, index]));
|
||||
const children = new Map<string, GameEvent[]>();
|
||||
for (const event of journal.events) {
|
||||
if (event.parent !== null) {
|
||||
children.set(event.parent, [...(children.get(event.parent) ?? []), event]);
|
||||
}
|
||||
}
|
||||
const leaves = journal.events.filter((event) => !children.has(event.id));
|
||||
return leaves
|
||||
.map((leaf) => {
|
||||
const line = currentLine({ events: journal.events, head: leaf.id });
|
||||
// Le point de départ de la branche : le parent de l'événement le
|
||||
// plus récent du chemin qui n'est pas le premier enfant de son
|
||||
// parent (la ligne d'origine n'en a aucun).
|
||||
let forkIndex = -1;
|
||||
for (let i = line.length - 1; i > 0; i--) {
|
||||
const event = line[i] as GameEvent;
|
||||
const first = children.get(event.parent as string)?.[0];
|
||||
if (first !== undefined && first.id !== event.id) {
|
||||
forkIndex = i - 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
const moves = (events: readonly GameEvent[]) =>
|
||||
events.filter((event) => event.type === "MovePlayed").length;
|
||||
const fork = forkIndex >= 0 ? (line[forkIndex] as GameEvent) : null;
|
||||
const start = line[forkIndex + 1] as GameEvent;
|
||||
return {
|
||||
created: order.get(start.id) ?? 0,
|
||||
head: leaf.id,
|
||||
forkedFrom: fork?.id ?? null,
|
||||
forkMoveCount: fork === null ? 0 : moves(line.slice(0, forkIndex + 1)),
|
||||
moveCount: moves(line),
|
||||
over: replay(line).phase === "over",
|
||||
containsHead: line.some((event) => event.id === journal.head),
|
||||
};
|
||||
})
|
||||
.sort((a, b) => a.created - b.created)
|
||||
.map(({ created: _, ...branch }) => branch);
|
||||
}
|
||||
|
||||
/** Passe à une autre branche : la partie reprend au bout de celle-ci. */
|
||||
export function switchToBranch(journal: GameJournal, head: string): GameJournal {
|
||||
if (!branches(journal).some((branch) => branch.head === head)) {
|
||||
throw new Error(`branche introuvable : ${head}`);
|
||||
}
|
||||
return goBackTo(journal, head);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
// (state.ts). Les règles viennent toutes du moteur : coups légaux, coup de
|
||||
// l'ordinateur, videau, fin de partie et points.
|
||||
|
||||
import { type Branch, branches, switchToBranch } from "./branches.js";
|
||||
import { CENTERED_CUBE, type Cube } from "./cube.js";
|
||||
import type { BoardSide, Engine, EngineCube, Ply } from "./engine.js";
|
||||
import {
|
||||
@@ -415,6 +416,19 @@ export class Game {
|
||||
this.#journal = goBackTo(this.#journal, eventId);
|
||||
}
|
||||
|
||||
/** Les branches de la partie (voir branches.ts), la ligne d'origine
|
||||
* d'abord. */
|
||||
get branches(): Branch[] {
|
||||
return branches(this.#journal);
|
||||
}
|
||||
|
||||
/** Passe à une autre branche, désignée par son dernier événement : la
|
||||
* partie reprend au bout de celle-ci ; les autres restent intactes. */
|
||||
switchBranch(head: string): void {
|
||||
this.cancelHalfMoves();
|
||||
this.#journal = switchToBranch(this.#journal, head);
|
||||
}
|
||||
|
||||
/** Annule le dernier événement : la partie repart de son parent. */
|
||||
undo(): void {
|
||||
const last = this.events.at(-1);
|
||||
|
||||
@@ -0,0 +1,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);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
// Une partie enregistrée, telle que la liste « Mes parties » la présente.
|
||||
//
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
//
|
||||
// Le journal reste la source (ADR 0007, point 3) : le résumé n'en est qu'une
|
||||
// lecture, recalculée à chaque enregistrement, pour afficher la liste sans
|
||||
// relire tous les journaux.
|
||||
|
||||
import { branches } from "./branches.js";
|
||||
import type { Level } from "./engine.js";
|
||||
import { currentLine, type GameJournal } from "./events.js";
|
||||
import { type GameResult, replay } from "./state.js";
|
||||
|
||||
/**
|
||||
* Le résumé d'une partie enregistrée.
|
||||
*
|
||||
* Glossaire : docs/glossaire/partie-enregistree.md
|
||||
*/
|
||||
export interface SavedGameSummary {
|
||||
/** Identifiant de la partie, unique sur l'appareil. */
|
||||
readonly id: string;
|
||||
/** Début de la partie et dernière action, en millisecondes depuis 1970. */
|
||||
readonly startedAt: number;
|
||||
readonly updatedAt: number;
|
||||
/** Niveau de l'ordinateur, l'adversaire. */
|
||||
readonly level: Level;
|
||||
/** En cours ou finie, sur la branche courante. */
|
||||
readonly status: "playing" | "over";
|
||||
/** Le résultat sur la branche courante (null si elle n'est pas finie). */
|
||||
readonly result: GameResult | null;
|
||||
/** Coups joués sur la branche courante. */
|
||||
readonly moveCount: number;
|
||||
/** Nombre de branches (1 : jamais rejouée). */
|
||||
readonly branchCount: number;
|
||||
}
|
||||
|
||||
/** Ce qu'il faut savoir d'une partie pour l'enregistrer, en plus de son
|
||||
* journal. */
|
||||
export interface SavedGameDetails {
|
||||
readonly level: Level;
|
||||
}
|
||||
|
||||
/** Le résumé d'un journal ; startedAt et updatedAt viennent de l'appelant
|
||||
* (le domaine ne lit pas l'horloge). */
|
||||
export function summarizeGame(
|
||||
id: string,
|
||||
journal: GameJournal,
|
||||
details: SavedGameDetails,
|
||||
times: { readonly startedAt: number; readonly updatedAt: number },
|
||||
): SavedGameSummary {
|
||||
const line = currentLine(journal);
|
||||
const state = replay(line);
|
||||
return {
|
||||
id,
|
||||
startedAt: times.startedAt,
|
||||
updatedAt: times.updatedAt,
|
||||
level: details.level,
|
||||
status: state.phase === "over" ? "over" : "playing",
|
||||
result: state.result,
|
||||
moveCount: line.filter((event) => event.type === "MovePlayed").length,
|
||||
branchCount: branches(journal).length,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
// Export d'une partie au format SGF de GNU Backgammon.
|
||||
//
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
//
|
||||
// Le SGF (Smart Game Format, FF[4], GM[6] pour le backgammon) est le format
|
||||
// de fichier de GNU Backgammon : une suite de nœuds, un par coup ou décision
|
||||
// de videau, et des variantes entre parenthèses. Le journal étant un arbre,
|
||||
// l'export en est un simple parcours (ADR 0007, point 3) : chaque branche
|
||||
// devient une variante. GNU Backgammon ne lit que la première variante de
|
||||
// chaque embranchement ; la branche courante de la partie vient donc en
|
||||
// premier, pour que gnubg ouvre la partie telle que le joueur la voit.
|
||||
//
|
||||
// Conventions de gnubg (sgf.c, au commit figé par l'ADR 0005) :
|
||||
// - le joueur est « B » (le joueur 1 de gnubg, l'humain), l'ordinateur « W » ;
|
||||
// - un coup s'écrit [dés][départ arrivée]… ; une case c (1 à 24, vue de celui
|
||||
// qui joue) s'écrit 'x' - (c - 1) pour B et 'a' + (c - 1) pour W ; la barre
|
||||
// s'écrit 'y', la sortie 'z' ; un coup impossible n'a que ses dés ;
|
||||
// - [double], [take], [drop] pour le videau ;
|
||||
// - une position de départ autre que l'habituelle se pose par AE, AW, AB et
|
||||
// PL, le videau par CV et CP.
|
||||
// Aucune règle n'est jugée ici : les coups et positions viennent du journal,
|
||||
// et une position de départ particulière est décodée par le moteur.
|
||||
|
||||
import type { Engine } from "./engine.js";
|
||||
import { currentLine, type GameEvent, type GameJournal } from "./events.js";
|
||||
import { BAR, type HalfMove, OFF, START_POSITION } from "./position.js";
|
||||
import type { Side } from "./side.js";
|
||||
import { replay } from "./state.js";
|
||||
|
||||
export interface SgfOptions {
|
||||
/** Nom du joueur (PB). */
|
||||
readonly playerName: string;
|
||||
/** Nom de l'ordinateur (PW). */
|
||||
readonly computerName: string;
|
||||
/** Date de la partie, AAAA-MM-JJ (DT) ; absente si non fournie. */
|
||||
readonly date?: string;
|
||||
}
|
||||
|
||||
/** Un nœud SGF et les variantes qui le suivent. */
|
||||
interface SgfNode {
|
||||
readonly properties: string;
|
||||
readonly children: SgfNode[];
|
||||
}
|
||||
|
||||
/**
|
||||
* La partie au format SGF de GNU Backgammon, branches comprises (en
|
||||
* variantes, la branche courante d'abord). Le moteur ne sert qu'à décoder
|
||||
* une position de départ particulière. (Le format : docs/glossaire/sgf.md.)
|
||||
*/
|
||||
export async function exportSgf(
|
||||
engine: Engine,
|
||||
journal: GameJournal,
|
||||
options: SgfOptions,
|
||||
): Promise<string> {
|
||||
const start = journal.events[0];
|
||||
if (start?.type !== "GameStarted") {
|
||||
throw new Error("journal de partie vide ou mal formé");
|
||||
}
|
||||
const byId = new Map(journal.events.map((event) => [event.id, event]));
|
||||
const onHeadLine = new Set(currentLine(journal).map((event) => event.id));
|
||||
const children = new Map<string, GameEvent[]>();
|
||||
for (const event of journal.events) {
|
||||
if (event.parent !== null) {
|
||||
children.set(event.parent, [...(children.get(event.parent) ?? []), event]);
|
||||
}
|
||||
}
|
||||
// La branche courante d'abord, puis les autres dans l'ordre du journal.
|
||||
for (const list of children.values()) {
|
||||
list.sort((a, b) => Number(onHeadLine.has(b.id)) - Number(onHeadLine.has(a.id)));
|
||||
}
|
||||
|
||||
/** Les variantes qui suivent un événement. */
|
||||
const variations = (id: string): SgfNode[] =>
|
||||
(children.get(id) ?? []).flatMap((child) => {
|
||||
const properties = nodeProperties(child, byId, children.has(child.id));
|
||||
const below = variations(child.id);
|
||||
return properties === null ? below : [{ properties, children: below }];
|
||||
});
|
||||
|
||||
// La partie telle que la voit gnubg : la ligne courante.
|
||||
const result = replay(currentLine(journal)).result;
|
||||
const root = [
|
||||
"FF[4]GM[6]CA[UTF-8]AP[Ustagammon]",
|
||||
"MI[length:0][game:0][ws:0][bs:0]",
|
||||
`PW[${escapeText(options.computerName)}]PB[${escapeText(options.playerName)}]`,
|
||||
options.date === undefined ? "" : `DT[${escapeText(options.date)}]`,
|
||||
result === null ? "" : `RE[${letter(result.winner)}+${result.points}]`,
|
||||
].join("");
|
||||
// Les nœuds d'installation, à la suite l'un de l'autre, puis la partie.
|
||||
const setup = await setupProperties(engine, start);
|
||||
const tree = setup.reduceRight<SgfNode[]>(
|
||||
(below, properties) => [{ properties, children: below }],
|
||||
variations(start.id),
|
||||
);
|
||||
return `(;${root}${sequence(tree)})\n`;
|
||||
}
|
||||
|
||||
/** Écrit des nœuds : une suite tant qu'il n'y a qu'une variante, puis
|
||||
* chaque variante entre parenthèses. */
|
||||
function sequence(nodes: readonly SgfNode[]): string {
|
||||
if (nodes.length === 1) {
|
||||
const [node] = nodes as [SgfNode];
|
||||
return `\n;${node.properties}${sequence(node.children)}`;
|
||||
}
|
||||
return nodes.map((node) => `(\n;${node.properties}${sequence(node.children)})`).join("");
|
||||
}
|
||||
|
||||
/** Les propriétés du nœud d'un événement, ou null s'il n'en fait pas
|
||||
* (début, dés suivis d'un coup, fin de partie). */
|
||||
function nodeProperties(
|
||||
event: GameEvent,
|
||||
byId: ReadonlyMap<string, GameEvent>,
|
||||
hasChildren: boolean,
|
||||
): string | null {
|
||||
switch (event.type) {
|
||||
case "MovePlayed": {
|
||||
const rolled = event.parent === null ? undefined : byId.get(event.parent);
|
||||
if (rolled?.type !== "DiceRolled") {
|
||||
throw new Error(`coup sans jet de dés : ${event.id}`);
|
||||
}
|
||||
const moves = event.halfMoves.map((h) => halfMove(h, event.side)).join("");
|
||||
return `${letter(event.side)}[${rolled.roll[0]}${rolled.roll[1]}${moves}]`;
|
||||
}
|
||||
case "CubeOffered":
|
||||
return `${letter(event.side)}[double]`;
|
||||
case "CubeTaken":
|
||||
return `${letter(event.side)}[take]`;
|
||||
case "CubeDropped":
|
||||
return `${letter(event.side)}[drop]`;
|
||||
case "DiceRolled": {
|
||||
// Des dés pas encore joués, au bout d'une branche.
|
||||
if (hasChildren) {
|
||||
return null;
|
||||
}
|
||||
const [a, b] = event.roll;
|
||||
const side = event.side ?? (a > b ? "player" : a < b ? "computer" : null);
|
||||
return side === null ? null : `PL[${letter(side)}]DI[${a}${b}]`;
|
||||
}
|
||||
case "GameStarted":
|
||||
case "GameEnded":
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Les propriétés des nœuds qui posent une position de départ particulière
|
||||
* (aucun pour la position habituelle, videau au centre, jet d'ouverture à
|
||||
* faire). */
|
||||
async function setupProperties(
|
||||
engine: Engine,
|
||||
start: Extract<GameEvent, { type: "GameStarted" }>,
|
||||
): Promise<string[]> {
|
||||
const usual =
|
||||
start.position === START_POSITION &&
|
||||
start.firstTurn === null &&
|
||||
start.cube.value === 1 &&
|
||||
start.cube.owner === null;
|
||||
if (usual) {
|
||||
return [];
|
||||
}
|
||||
const onRoll: Side = start.firstTurn ?? "player";
|
||||
const owner =
|
||||
start.cube.owner === null
|
||||
? "centered"
|
||||
: start.cube.owner === onRoll
|
||||
? "onRoll"
|
||||
: "opponent";
|
||||
const board = await engine.board(start.position, { value: start.cube.value, owner });
|
||||
// Chaque camp avec sa propre numérotation : W en 'a' + (c - 1), B en
|
||||
// 'x' - (c - 1), la barre en 'y' (comme les coups).
|
||||
const stones = (side: Side, points: readonly number[], bar: number) => {
|
||||
const out: string[] = [];
|
||||
points.forEach((count, i) => {
|
||||
for (let k = 0; k < count; k++) {
|
||||
out.push(`[${pointLetter(i + 1, side)}]`);
|
||||
}
|
||||
});
|
||||
for (let k = 0; k < bar; k++) {
|
||||
out.push("[y]");
|
||||
}
|
||||
return out.join("");
|
||||
};
|
||||
const other: Side = onRoll === "player" ? "computer" : "player";
|
||||
const sides = { [onRoll]: board.onRoll, [other]: board.opponent } as Record<
|
||||
Side,
|
||||
typeof board.onRoll
|
||||
>;
|
||||
const white = stones("computer", sides.computer.points, sides.computer.bar);
|
||||
const black = stones("player", sides.player.points, sides.player.bar);
|
||||
const nodes = [
|
||||
`PL[${letter(onRoll)}]AE[a:y]${white === "" ? "" : `AW${white}`}${black === "" ? "" : `AB${black}`}`,
|
||||
];
|
||||
if (start.cube.value !== 1) {
|
||||
nodes.push(`CV[${start.cube.value}]`);
|
||||
}
|
||||
if (start.cube.owner !== null) {
|
||||
nodes.push(`CP[${start.cube.owner === "player" ? "b" : "w"}]`);
|
||||
}
|
||||
return nodes;
|
||||
}
|
||||
|
||||
function letter(side: Side): "B" | "W" {
|
||||
return side === "player" ? "B" : "W";
|
||||
}
|
||||
|
||||
function halfMove(h: HalfMove, side: Side): string {
|
||||
return pointLetter(h.from, side) + pointLetter(h.to, side);
|
||||
}
|
||||
|
||||
/** Une case vue du camp side : 1 à 24, BAR ou OFF. */
|
||||
function pointLetter(point: number, side: Side): string {
|
||||
if (point === BAR) {
|
||||
return "y";
|
||||
}
|
||||
if (point === OFF) {
|
||||
return "z";
|
||||
}
|
||||
const code =
|
||||
side === "player" ? "x".charCodeAt(0) - (point - 1) : "a".charCodeAt(0) + point - 1;
|
||||
return String.fromCharCode(code);
|
||||
}
|
||||
|
||||
/** Échappe un texte libre dans une valeur SGF. */
|
||||
function escapeText(text: string): string {
|
||||
return text.replace(/[\\\]]/g, (c) => `\\${c}`);
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
// Les parties enregistrées sur l'appareil : leur résumé, leur journal
|
||||
// d'événements, et laquelle est la partie en cours.
|
||||
//
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
//
|
||||
// Deux stockages derrière la même interface (GameLibrary) :
|
||||
// - IndexedDB, le stockage normal du navigateur (indexeddb-backend.ts) ;
|
||||
// - un stockage clé-valeur (localStorage), en repli quand IndexedDB est
|
||||
// indisponible, par exemple en navigation privée de certains navigateurs.
|
||||
// Les opérations sont exécutées l'une après l'autre, dans l'ordre des
|
||||
// appels : un enregistrement lancé sans être attendu est donc toujours fait
|
||||
// avant la lecture qui suit.
|
||||
|
||||
import type { GameJournal } from "../domain/game/events.js";
|
||||
import {
|
||||
type SavedGameDetails,
|
||||
type SavedGameSummary,
|
||||
summarizeGame,
|
||||
} from "../domain/game/saved-game.js";
|
||||
|
||||
/** Une partie enregistrée : son résumé et son journal. */
|
||||
export interface SavedGame {
|
||||
readonly summary: SavedGameSummary;
|
||||
readonly journal: GameJournal;
|
||||
}
|
||||
|
||||
export interface GameLibrary {
|
||||
/** Les parties, la dernière jouée d'abord. */
|
||||
list(): Promise<SavedGameSummary[]>;
|
||||
/** Une partie, ou null si elle n'existe pas ou est illisible. */
|
||||
load(id: string): Promise<SavedGame | null>;
|
||||
/** Enregistre la partie id (nouvelle ou non), qui devient la partie en
|
||||
* cours. Renvoie son résumé. */
|
||||
save(id: string, journal: GameJournal, details: SavedGameDetails): Promise<SavedGameSummary>;
|
||||
/** Supprime une partie (rien si elle n'existe pas). */
|
||||
remove(id: string): Promise<void>;
|
||||
/** La partie en cours : la dernière enregistrée ou choisie (setCurrent),
|
||||
* si elle existe encore ; sinon la dernière jouée ; null s'il n'y en a
|
||||
* aucune. */
|
||||
current(): Promise<SavedGame | null>;
|
||||
/** Fait d'une partie enregistrée la partie en cours, sans la modifier
|
||||
* (pour la reprendre). */
|
||||
setCurrent(id: string): Promise<void>;
|
||||
}
|
||||
|
||||
/** Ce que chaque stockage sait faire ; GameLibrary ajoute l'ordre des
|
||||
* opérations, les dates et la validation. */
|
||||
export interface LibraryBackend {
|
||||
summaries(): Promise<SavedGameSummary[]>;
|
||||
summary(id: string): Promise<SavedGameSummary | undefined>;
|
||||
journal(id: string): Promise<unknown>;
|
||||
/** Écrit le résumé et le journal ensemble. */
|
||||
write(summary: SavedGameSummary, journal: GameJournal): Promise<void>;
|
||||
remove(id: string): Promise<void>;
|
||||
currentId(): Promise<string | undefined>;
|
||||
setCurrentId(id: string): Promise<void>;
|
||||
}
|
||||
|
||||
export interface LibraryOptions {
|
||||
/** L'horloge, en millisecondes ; Date.now par défaut. */
|
||||
readonly now?: () => number;
|
||||
}
|
||||
|
||||
/** Un identifiant de partie : la date et du hasard, sans dépendre de
|
||||
* crypto.randomUUID, absent hors https. */
|
||||
export function newGameId(now: number = Date.now()): string {
|
||||
const random = Math.floor(Math.random() * 36 ** 6)
|
||||
.toString(36)
|
||||
.padStart(6, "0");
|
||||
return `${now.toString(36)}-${random}`;
|
||||
}
|
||||
|
||||
/** Un journal lisible : la forme attendue, sans juger de son contenu (le
|
||||
* domaine le vérifie en reprenant la partie). */
|
||||
export function isJournal(value: unknown): value is GameJournal {
|
||||
if (typeof value !== "object" || value === null) {
|
||||
return false;
|
||||
}
|
||||
const journal = value as { events?: unknown; head?: unknown };
|
||||
return (
|
||||
Array.isArray(journal.events) && (journal.head === null || typeof journal.head === "string")
|
||||
);
|
||||
}
|
||||
|
||||
export function createGameLibrary(
|
||||
backend: LibraryBackend,
|
||||
options: LibraryOptions = {},
|
||||
): GameLibrary {
|
||||
const now = options.now ?? Date.now;
|
||||
let queue: Promise<unknown> = Promise.resolve();
|
||||
/** Exécute op après toutes les opérations déjà demandées. */
|
||||
const enqueue = <T>(op: () => Promise<T>): Promise<T> => {
|
||||
const result = queue.then(op, op);
|
||||
queue = result.catch(() => {});
|
||||
return result;
|
||||
};
|
||||
|
||||
const load = async (id: string): Promise<SavedGame | null> => {
|
||||
const summary = await backend.summary(id);
|
||||
const journal = await backend.journal(id);
|
||||
return summary === undefined || !isJournal(journal) ? null : { summary, journal };
|
||||
};
|
||||
const list = async () => (await backend.summaries()).sort((a, b) => b.updatedAt - a.updatedAt);
|
||||
|
||||
return {
|
||||
list: () => enqueue(list),
|
||||
load: (id) => enqueue(() => load(id)),
|
||||
save: (id, journal, details) =>
|
||||
enqueue(async () => {
|
||||
const time = now();
|
||||
const startedAt = (await backend.summary(id))?.startedAt ?? time;
|
||||
const summary = summarizeGame(id, journal, details, {
|
||||
startedAt,
|
||||
updatedAt: Math.max(time, startedAt),
|
||||
});
|
||||
await backend.write(summary, journal);
|
||||
await backend.setCurrentId(id);
|
||||
return summary;
|
||||
}),
|
||||
remove: (id) => enqueue(() => backend.remove(id)),
|
||||
current: () =>
|
||||
enqueue(async () => {
|
||||
const id = await backend.currentId();
|
||||
const chosen = id === undefined ? null : await load(id);
|
||||
if (chosen !== null) {
|
||||
return chosen;
|
||||
}
|
||||
const latest = (await list())[0];
|
||||
return latest === undefined ? null : load(latest.id);
|
||||
}),
|
||||
setCurrent: (id) =>
|
||||
enqueue(async () => {
|
||||
if ((await backend.summary(id)) === undefined) {
|
||||
throw new Error(`partie introuvable : ${id}`);
|
||||
}
|
||||
await backend.setCurrentId(id);
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,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;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
// L'enregistrement de la partie en cours, pour la reprendre après fermeture
|
||||
// de la page : son journal d'événements, en JSON, dans un stockage clé-valeur
|
||||
// (localStorage dans le navigateur).
|
||||
//
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
|
||||
import type { GameJournal } from "../domain/game/events.js";
|
||||
|
||||
/** Ce qu'il faut d'un stockage clé-valeur (localStorage en a davantage). */
|
||||
export interface KeyValueStorage {
|
||||
getItem(key: string): string | null;
|
||||
setItem(key: string, value: string): void;
|
||||
}
|
||||
|
||||
export interface GameStore {
|
||||
/** Le journal enregistré, ou null s'il n'y en a pas de lisible. */
|
||||
load(): GameJournal | null;
|
||||
save(journal: GameJournal): void;
|
||||
}
|
||||
|
||||
const KEY = "ustagammon.current-game";
|
||||
|
||||
export function createGameStore(storage: KeyValueStorage): GameStore {
|
||||
return {
|
||||
load() {
|
||||
try {
|
||||
const journal = JSON.parse(storage.getItem(KEY) ?? "null");
|
||||
const valid =
|
||||
journal !== null &&
|
||||
Array.isArray(journal.events) &&
|
||||
(journal.head === null || typeof journal.head === "string");
|
||||
return valid ? journal : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
save(journal) {
|
||||
try {
|
||||
storage.setItem(KEY, JSON.stringify(journal));
|
||||
} catch {
|
||||
// Stockage plein ou interdit (navigation privée) : la partie
|
||||
// continue, elle ne pourra seulement pas être reprise.
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
// Les parties dans IndexedDB, le stockage de données du navigateur.
|
||||
//
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
//
|
||||
// Base « ustagammon », trois magasins d'objets :
|
||||
// - games : les résumés des parties (SavedGameSummary), clé id ;
|
||||
// - journals : leurs journaux, { id, journal }, clé id ;
|
||||
// - settings : { key, value }, dont la partie en cours (key « current »).
|
||||
// Le résumé et le journal d'une partie sont écrits dans la même transaction.
|
||||
// L'API IndexedDB est utilisée directement, sans bibliothèque.
|
||||
|
||||
import type { GameJournal } from "../domain/game/events.js";
|
||||
import type { SavedGameSummary } from "../domain/game/saved-game.js";
|
||||
import type { LibraryBackend } from "./game-library.js";
|
||||
|
||||
const DATABASE = "ustagammon";
|
||||
const VERSION = 1;
|
||||
const GAMES = "games";
|
||||
const JOURNALS = "journals";
|
||||
const SETTINGS = "settings";
|
||||
|
||||
/** Le résultat d'une requête IndexedDB, en promesse. */
|
||||
function done<T>(request: IDBRequest<T>): Promise<T> {
|
||||
return new Promise((resolve, reject) => {
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
|
||||
/** La fin d'une transaction (toutes ses écritures faites), en promesse. */
|
||||
function committed(transaction: IDBTransaction): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
transaction.oncomplete = () => resolve();
|
||||
transaction.onerror = () => reject(transaction.error);
|
||||
transaction.onabort = () => reject(transaction.error ?? new Error("transaction annulée"));
|
||||
});
|
||||
}
|
||||
|
||||
/** Ouvre la base, en la créant au besoin. Rejetée si IndexedDB est
|
||||
* indisponible ou refusé. */
|
||||
export async function openIndexedDbBackend(factory: IDBFactory): Promise<LibraryBackend> {
|
||||
const request = factory.open(DATABASE, VERSION);
|
||||
request.onupgradeneeded = () => {
|
||||
const db = request.result;
|
||||
db.createObjectStore(GAMES, { keyPath: "id" });
|
||||
db.createObjectStore(JOURNALS, { keyPath: "id" });
|
||||
db.createObjectStore(SETTINGS, { keyPath: "key" });
|
||||
};
|
||||
const db = await done(request);
|
||||
|
||||
const get = async <T>(store: string, key: string): Promise<T | undefined> =>
|
||||
done(db.transaction(store).objectStore(store).get(key));
|
||||
|
||||
return {
|
||||
summaries: async () => done(db.transaction(GAMES).objectStore(GAMES).getAll()),
|
||||
summary: (id) => get<SavedGameSummary>(GAMES, id),
|
||||
journal: async (id) => (await get<{ journal: unknown }>(JOURNALS, id))?.journal,
|
||||
write: async (summary: SavedGameSummary, journal: GameJournal) => {
|
||||
const transaction = db.transaction([GAMES, JOURNALS], "readwrite");
|
||||
transaction.objectStore(JOURNALS).put({ id: summary.id, journal });
|
||||
transaction.objectStore(GAMES).put(summary);
|
||||
await committed(transaction);
|
||||
},
|
||||
remove: async (id) => {
|
||||
const transaction = db.transaction([GAMES, JOURNALS], "readwrite");
|
||||
transaction.objectStore(GAMES).delete(id);
|
||||
transaction.objectStore(JOURNALS).delete(id);
|
||||
await committed(transaction);
|
||||
},
|
||||
currentId: async () => {
|
||||
const setting = await get<{ value: unknown }>(SETTINGS, "current");
|
||||
return typeof setting?.value === "string" ? setting.value : undefined;
|
||||
},
|
||||
setCurrentId: async (id) => {
|
||||
const transaction = db.transaction(SETTINGS, "readwrite");
|
||||
transaction.objectStore(SETTINGS).put({ key: "current", value: id });
|
||||
await committed(transaction);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
// Les parties dans un stockage clé-valeur (localStorage), en JSON : le
|
||||
// repli quand IndexedDB est indisponible, et la lecture de la partie
|
||||
// enregistrée par les versions précédentes, pour la migrer.
|
||||
//
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
|
||||
import type { GameJournal } from "../domain/game/events.js";
|
||||
import type { SavedGameSummary } from "../domain/game/saved-game.js";
|
||||
import { type GameLibrary, isJournal, type LibraryBackend, newGameId } from "./game-library.js";
|
||||
|
||||
/** Ce qu'il faut d'un stockage clé-valeur (localStorage en a davantage). */
|
||||
export interface KeyValueStorage {
|
||||
getItem(key: string): string | null;
|
||||
setItem(key: string, value: string): void;
|
||||
removeItem(key: string): void;
|
||||
}
|
||||
|
||||
const SUMMARIES = "ustagammon.games";
|
||||
const CURRENT = "ustagammon.current-game-id";
|
||||
const journalKey = (id: string) => `ustagammon.game.${id}`;
|
||||
|
||||
/** La partie unique des versions précédentes (étape 0), à migrer. */
|
||||
const LEGACY_GAME = "ustagammon.current-game";
|
||||
|
||||
/** Lit une clé en JSON ; undefined si elle est absente, illisible, ou si le
|
||||
* stockage est interdit. */
|
||||
function read(storage: KeyValueStorage, key: string): unknown {
|
||||
try {
|
||||
const text = storage.getItem(key);
|
||||
return text === null ? undefined : JSON.parse(text);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export function createKeyValueBackend(storage: KeyValueStorage): LibraryBackend {
|
||||
const summaries = (): SavedGameSummary[] => {
|
||||
const value = read(storage, SUMMARIES);
|
||||
return Array.isArray(value) ? value : [];
|
||||
};
|
||||
const writeSummaries = (list: readonly SavedGameSummary[]) =>
|
||||
storage.setItem(SUMMARIES, JSON.stringify(list));
|
||||
return {
|
||||
summaries: async () => summaries(),
|
||||
summary: async (id) => summaries().find((s) => s.id === id),
|
||||
journal: async (id) => read(storage, journalKey(id)),
|
||||
write: async (summary, journal) => {
|
||||
// Le journal d'abord : un résumé ne désigne jamais un journal
|
||||
// absent.
|
||||
storage.setItem(journalKey(summary.id), JSON.stringify(journal));
|
||||
writeSummaries([...summaries().filter((s) => s.id !== summary.id), summary]);
|
||||
},
|
||||
remove: async (id) => {
|
||||
writeSummaries(summaries().filter((s) => s.id !== id));
|
||||
storage.removeItem(journalKey(id));
|
||||
},
|
||||
currentId: async () => {
|
||||
const id = read(storage, CURRENT);
|
||||
return typeof id === "string" ? id : undefined;
|
||||
},
|
||||
setCurrentId: async (id) => storage.setItem(CURRENT, JSON.stringify(id)),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Reprend dans library la partie enregistrée par les versions précédentes
|
||||
* (une seule partie, sous une clé de localStorage), qui devient la partie
|
||||
* en cours ; puis efface l'ancienne clé. Sans effet s'il n'y en a pas. Un
|
||||
* journal illisible est effacé. Renvoie l'identifiant de la partie migrée.
|
||||
*/
|
||||
export async function migrateLegacyGame(
|
||||
storage: KeyValueStorage,
|
||||
library: GameLibrary,
|
||||
now: number = Date.now(),
|
||||
): Promise<string | null> {
|
||||
let present: boolean;
|
||||
try {
|
||||
present = storage.getItem(LEGACY_GAME) !== null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (!present) {
|
||||
return null;
|
||||
}
|
||||
const journal: unknown = read(storage, LEGACY_GAME);
|
||||
let id: string | null = null;
|
||||
if (isJournal(journal) && journal.events.length > 0) {
|
||||
id = newGameId(now);
|
||||
// Les parties de l'étape 0 se jouaient toutes au niveau standard.
|
||||
await library.save(id, journal as GameJournal, { level: "2ply" });
|
||||
}
|
||||
try {
|
||||
storage.removeItem(LEGACY_GAME);
|
||||
} catch {
|
||||
// Stockage interdit : la migration sera refaite, sans dommage.
|
||||
}
|
||||
return id;
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
// Ouverture des parties enregistrées au démarrage : IndexedDB si possible,
|
||||
// sinon localStorage, puis migration de la partie des versions précédentes.
|
||||
//
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
|
||||
import { createGameLibrary, type GameLibrary, type LibraryOptions } from "./game-library.js";
|
||||
import { openIndexedDbBackend } from "./indexeddb-backend.js";
|
||||
import {
|
||||
createKeyValueBackend,
|
||||
type KeyValueStorage,
|
||||
migrateLegacyGame,
|
||||
} from "./key-value-backend.js";
|
||||
|
||||
/** Les stockages du navigateur ; chacun peut manquer ou être refusé. */
|
||||
export interface BrowserStorage {
|
||||
readonly indexedDB?: IDBFactory;
|
||||
readonly localStorage?: KeyValueStorage;
|
||||
}
|
||||
|
||||
export interface OpenedLibrary {
|
||||
readonly library: GameLibrary;
|
||||
/** Le stockage retenu : IndexedDB, son repli localStorage, ou la seule
|
||||
* mémoire de la page (rien n'est gardé après sa fermeture). */
|
||||
readonly kind: "indexeddb" | "localStorage" | "memory";
|
||||
}
|
||||
|
||||
/** Au-delà, IndexedDB est tenu pour indisponible (une ouverture bloquée ne
|
||||
* doit pas empêcher de jouer). */
|
||||
const OPEN_TIMEOUT_MS = 5000;
|
||||
|
||||
export async function openGameLibrary(
|
||||
storage: BrowserStorage,
|
||||
options: LibraryOptions = {},
|
||||
): Promise<OpenedLibrary> {
|
||||
const opened = await open(storage, options);
|
||||
if (storage.localStorage !== undefined) {
|
||||
try {
|
||||
await migrateLegacyGame(storage.localStorage, opened.library);
|
||||
} catch {
|
||||
// Migration impossible (stockage plein ou refusé) : l'ancienne
|
||||
// partie reste où elle est, on la retentera au prochain démarrage.
|
||||
}
|
||||
}
|
||||
return opened;
|
||||
}
|
||||
|
||||
async function open(storage: BrowserStorage, options: LibraryOptions): Promise<OpenedLibrary> {
|
||||
if (storage.indexedDB !== undefined) {
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
try {
|
||||
const backend = await Promise.race([
|
||||
openIndexedDbBackend(storage.indexedDB),
|
||||
new Promise<never>((_, reject) => {
|
||||
timer = setTimeout(() => reject(new Error("IndexedDB muet")), OPEN_TIMEOUT_MS);
|
||||
}),
|
||||
]);
|
||||
return { library: createGameLibrary(backend, options), kind: "indexeddb" };
|
||||
} catch {
|
||||
// Indisponible (navigation privée de certains navigateurs) : repli.
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
if (storage.localStorage !== undefined && usable(storage.localStorage)) {
|
||||
return {
|
||||
library: createGameLibrary(createKeyValueBackend(storage.localStorage), options),
|
||||
kind: "localStorage",
|
||||
};
|
||||
}
|
||||
return {
|
||||
library: createGameLibrary(createKeyValueBackend(memoryStorage()), options),
|
||||
kind: "memory",
|
||||
};
|
||||
}
|
||||
|
||||
/** localStorage peut exister et refuser toute écriture. */
|
||||
function usable(storage: KeyValueStorage): boolean {
|
||||
try {
|
||||
storage.setItem("ustagammon.probe", "1");
|
||||
storage.removeItem("ustagammon.probe");
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Un stockage clé-valeur en mémoire. */
|
||||
export function memoryStorage(): KeyValueStorage {
|
||||
const items = new Map<string, string>();
|
||||
return {
|
||||
getItem: (key) => items.get(key) ?? null,
|
||||
setItem: (key, value) => void items.set(key, value),
|
||||
removeItem: (key) => void items.delete(key),
|
||||
};
|
||||
}
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
import type { Cube } from "../domain/game/cube.js";
|
||||
import type { SideBoards } from "../domain/game/game.js";
|
||||
import type { KeyValueStorage } from "./game-store.js";
|
||||
import type { KeyValueStorage } from "./key-value-backend.js";
|
||||
|
||||
const SETTINGS_KEY = "ustagammon.settings";
|
||||
const BOARD_KEY = "ustagammon.last-board";
|
||||
@@ -41,7 +41,9 @@ function isSide(value: unknown): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
export function createSettingsStore(storage: KeyValueStorage): SettingsStore {
|
||||
export function createSettingsStore(
|
||||
storage: Pick<KeyValueStorage, "getItem" | "setItem">,
|
||||
): SettingsStore {
|
||||
const write = (key: string, value: string) => {
|
||||
try {
|
||||
storage.setItem(key, value);
|
||||
|
||||
+477
-66
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
// Les textes de « Mes parties » (ADR 0007, point 5), à part de texts.ts le
|
||||
// temps que l'écran rejoigne le menu de l'application.
|
||||
//
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
|
||||
import type { ResultKind } from "../domain/game/engine.js";
|
||||
import type { Side } from "../domain/game/side.js";
|
||||
|
||||
const RESULT_KIND: Record<ResultKind, string> = {
|
||||
single: "partie simple",
|
||||
gammon: "gammon",
|
||||
backgammon: "backgammon",
|
||||
};
|
||||
|
||||
function moves(n: number): string {
|
||||
return n > 1 ? `${n} coups` : `${n} coup`;
|
||||
}
|
||||
|
||||
function points(n: number): string {
|
||||
return n > 1 ? `${n} points` : `${n} point`;
|
||||
}
|
||||
|
||||
export const GAMES_TEXTS = {
|
||||
button: "Mes parties",
|
||||
title: "Mes parties",
|
||||
empty: "Aucune partie enregistrée.",
|
||||
close: "Fermer",
|
||||
resume: "Reprendre",
|
||||
showBranches: "Branches",
|
||||
resumeBranch: "Reprendre cette branche",
|
||||
exportSgf: "Exporter (SGF)",
|
||||
remove: "Supprimer",
|
||||
confirmRemove: "Supprimer pour de bon (branches comprises)",
|
||||
shown: "partie affichée",
|
||||
busy: "Attendez la fin du coup en cours.",
|
||||
failed: (message: string) => `Erreur : ${message}`,
|
||||
|
||||
/** « 3 nov. 2026, 14:05 ». */
|
||||
date: (ms: number) =>
|
||||
new Date(ms).toLocaleString("fr-FR", { dateStyle: "medium", timeStyle: "short" }),
|
||||
|
||||
status: (
|
||||
result: { winner: Side; kind: ResultKind; points: number } | null,
|
||||
moveCount: number,
|
||||
) => {
|
||||
if (result === null) {
|
||||
return `En cours, ${moves(moveCount)}`;
|
||||
}
|
||||
const who = result.winner === "player" ? "Gagnée" : "Perdue";
|
||||
return `${who} (${RESULT_KIND[result.kind]}, ${points(result.points)}), ${moves(moveCount)}`;
|
||||
},
|
||||
branchCount: (n: number) => (n > 1 ? `${n} branches` : ""),
|
||||
|
||||
/** Une branche : d'où elle part et où elle en est. */
|
||||
branch: (forkMoveCount: number | null, moveCount: number, over: boolean) => {
|
||||
const start =
|
||||
forkMoveCount === null
|
||||
? "Partie d'origine"
|
||||
: forkMoveCount === 0
|
||||
? "Rejouée depuis le début"
|
||||
: `Rejouée après le coup ${forkMoveCount}`;
|
||||
return `${start} : ${moves(moveCount)}${over ? ", finie" : ""}`;
|
||||
},
|
||||
currentBranch: " (branche courante)",
|
||||
};
|
||||
@@ -0,0 +1,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
@@ -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);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ export const ICON_PATHS = {
|
||||
chevron: "M9 6l6 6-6 6",
|
||||
copy: "M9 9h11v11H9zM5 15H4V4h11v1",
|
||||
newGame: "M12 5v14M5 12h14",
|
||||
games: "M9 6h11M9 12h11M9 18h11M4 6h.01M4 12h.01M4 18h.01",
|
||||
vibrate: "M9 5h6v14H9zM5 8v8M19 8v8M2 10v4M22 10v4",
|
||||
theme: "M12 21a9 9 0 1 0 0-18zM12 3a9 9 0 0 1 0 18",
|
||||
screenOn: "M7 3h10v18H7zM11 18h2",
|
||||
|
||||
@@ -0,0 +1,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);
|
||||
});
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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}`);
|
||||
});
|
||||
@@ -13,7 +13,10 @@ import type { GameJournal } from "../../src/domain/game/events.js";
|
||||
import { Game } from "../../src/domain/game/game.js";
|
||||
import { parseHalfMoves, START_POSITION } from "../../src/domain/game/position.js";
|
||||
import { fixedDice } from "../../src/domain/game/roll.js";
|
||||
import { createGameStore } from "../../src/storage/game-store.js";
|
||||
import { createGameLibrary, type GameLibrary } from "../../src/storage/game-library.js";
|
||||
import { createGameSession, type GameSession } from "../../src/storage/game-session.js";
|
||||
import { createKeyValueBackend } from "../../src/storage/key-value-backend.js";
|
||||
import { memoryStorage } from "../../src/storage/open-library.js";
|
||||
import { BANNER_MS } from "../../src/ui/app.js";
|
||||
import { createHaptics } from "../../src/ui/haptics.js";
|
||||
import { nextTheme, parseSettings, type Settings } from "../../src/ui/settings.js";
|
||||
@@ -34,30 +37,41 @@ BeforeAll(async () => {
|
||||
|
||||
// -------------------------------------------------- Nouvelle partie annulable
|
||||
|
||||
// Comme dans l'application : chaque partie est enregistrée (localStorage,
|
||||
// ici un stockage en mémoire), et l'ancienne est reprise depuis son journal.
|
||||
const data = new Map<string, string>();
|
||||
const store = createGameStore({
|
||||
getItem: (key) => data.get(key) ?? null,
|
||||
setItem: (key, value) => void data.set(key, value),
|
||||
});
|
||||
// Comme dans l'application : chaque partie est enregistrée par la session
|
||||
// (ici sur un stockage en mémoire), et l'ancienne est relue dans le
|
||||
// stockage par son identifiant.
|
||||
let library: GameLibrary;
|
||||
let session: GameSession;
|
||||
let oldId: string | null;
|
||||
|
||||
Given("une partie est en cours depuis quelques coups", async () => {
|
||||
game = Game.start(engine, { dice: fixedDice([3, 1, 6, 4, 5, 2]) });
|
||||
library = createGameLibrary(createKeyValueBackend(memoryStorage()));
|
||||
let dice = fixedDice([3, 1, 6, 4, 5, 2]);
|
||||
session = createGameSession(
|
||||
library,
|
||||
{
|
||||
start: () => Game.start(engine, { dice }),
|
||||
resume: (journal) => Game.resume(engine, journal, { dice }),
|
||||
},
|
||||
"2ply",
|
||||
);
|
||||
game = session.newGame();
|
||||
dice = () => 1 + Math.floor(Math.random() * 6);
|
||||
for (const halfMove of parseHalfMoves("8/5 6/5")) {
|
||||
assert.ok(await game.playHalfMove(halfMove));
|
||||
}
|
||||
await game.confirmMove();
|
||||
await game.playComputerTurn();
|
||||
store.save(game.journal);
|
||||
session.save(game.journal);
|
||||
oldId = session.shownId;
|
||||
assert.equal(game.state.turn, "player");
|
||||
});
|
||||
|
||||
When("vous commencez une nouvelle partie depuis le menu", async () => {
|
||||
oldJournal = game.journal;
|
||||
oldBoard = await game.displayedBoard();
|
||||
game = Game.start(engine);
|
||||
store.save(game.journal);
|
||||
game = session.newGame();
|
||||
session.save(game.journal);
|
||||
});
|
||||
|
||||
Then("une nouvelle partie commence aussitôt", () => {
|
||||
@@ -69,13 +83,21 @@ Then("l'ancienne partie peut être reprise pendant {int} secondes", (seconds: nu
|
||||
assert.equal(BANNER_MS, seconds * 1000);
|
||||
});
|
||||
|
||||
When("vous reprenez l'ancienne partie", () => {
|
||||
game = Game.resume(engine, oldJournal);
|
||||
store.save(game.journal);
|
||||
When("vous reprenez l'ancienne partie", async () => {
|
||||
const old = await session.resumePrevious();
|
||||
assert.ok(old !== null);
|
||||
game = old;
|
||||
session.save(game.journal);
|
||||
});
|
||||
|
||||
Then("vous retrouvez exactement la partie quittée", async () => {
|
||||
assert.deepEqual(store.load(), oldJournal);
|
||||
assert.equal(session.shownId, oldId);
|
||||
assert.deepEqual((await library.current())?.journal, oldJournal);
|
||||
// La nouvelle partie, abandonnée, est supprimée.
|
||||
assert.deepEqual(
|
||||
(await library.list()).map((s) => s.id),
|
||||
[oldId],
|
||||
);
|
||||
assert.deepEqual(game.journal, oldJournal);
|
||||
assert.deepEqual(await game.displayedBoard(), oldBoard);
|
||||
assert.equal(game.state.turn, "player");
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
// Tests du stockage des parties (src/storage/), sans navigateur : la
|
||||
// bibliothèque de parties sur un stockage clé-valeur en mémoire (le repli
|
||||
// de localStorage), la migration de la partie des versions précédentes et
|
||||
// le choix du stockage. IndexedDB, absent de Node, est essayé dans un vrai
|
||||
// navigateur (tests/browser/play.mjs).
|
||||
//
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
import { branches } from "../src/domain/game/branches.js";
|
||||
import type { Engine } from "../src/domain/game/engine.js";
|
||||
import {
|
||||
appendEvent,
|
||||
EMPTY_JOURNAL,
|
||||
type GameJournal,
|
||||
goBackTo,
|
||||
} from "../src/domain/game/events.js";
|
||||
import { Game } from "../src/domain/game/game.js";
|
||||
import { START_POSITION } from "../src/domain/game/position.js";
|
||||
import { fixedDice } from "../src/domain/game/roll.js";
|
||||
import { createGameLibrary, isJournal, newGameId } from "../src/storage/game-library.js";
|
||||
import { createGameSession } from "../src/storage/game-session.js";
|
||||
import {
|
||||
createKeyValueBackend,
|
||||
type KeyValueStorage,
|
||||
migrateLegacyGame,
|
||||
} from "../src/storage/key-value-backend.js";
|
||||
import { memoryStorage, openGameLibrary } from "../src/storage/open-library.js";
|
||||
|
||||
/** Une partie commencée, et éventuellement quelques événements de plus. */
|
||||
function journal(extraRolls = 0): GameJournal {
|
||||
let j = appendEvent(EMPTY_JOURNAL, {
|
||||
type: "GameStarted",
|
||||
position: START_POSITION,
|
||||
cube: { value: 1, owner: null },
|
||||
firstTurn: null,
|
||||
});
|
||||
for (let i = 0; i < extraRolls; i++) {
|
||||
j = appendEvent(j, { type: "DiceRolled", side: null, roll: [3, 3] });
|
||||
}
|
||||
return j;
|
||||
}
|
||||
|
||||
/** Une horloge qui avance d'une seconde à chaque lecture. */
|
||||
function clock(start = 1_000_000): () => number {
|
||||
let t = start;
|
||||
return () => {
|
||||
t += 1000;
|
||||
return t;
|
||||
};
|
||||
}
|
||||
|
||||
function library(storage: KeyValueStorage = memoryStorage()) {
|
||||
return createGameLibrary(createKeyValueBackend(storage), { now: clock() });
|
||||
}
|
||||
|
||||
test("plusieurs parties : liste, chargement, dernière jouée d'abord", async () => {
|
||||
const games = library();
|
||||
assert.deepEqual(await games.list(), []);
|
||||
assert.equal(await games.current(), null);
|
||||
|
||||
const a = await games.save("a", journal(), { level: "2ply" });
|
||||
await games.save("b", journal(1), { level: "beginner" });
|
||||
assert.deepEqual(
|
||||
(await games.list()).map((s) => s.id),
|
||||
["b", "a"],
|
||||
);
|
||||
assert.equal(a.status, "playing");
|
||||
assert.equal(a.result, null);
|
||||
assert.equal(a.moveCount, 0);
|
||||
assert.equal(a.branchCount, 1);
|
||||
|
||||
// Rejouer « a » la remet en tête ; sa date de début ne change pas.
|
||||
const again = await games.save("a", journal(2), { level: "2ply" });
|
||||
assert.equal(again.startedAt, a.startedAt);
|
||||
assert.ok(again.updatedAt > a.updatedAt);
|
||||
assert.deepEqual(
|
||||
(await games.list()).map((s) => s.id),
|
||||
["a", "b"],
|
||||
);
|
||||
assert.deepEqual((await games.load("a"))?.journal, journal(2));
|
||||
assert.equal((await games.load("b"))?.summary.level, "beginner");
|
||||
assert.equal(await games.load("inconnue"), null);
|
||||
});
|
||||
|
||||
test("partie en cours : la dernière enregistrée, ou celle qu'on reprend", async () => {
|
||||
const games = library();
|
||||
await games.save("ancienne", journal(), { level: "2ply" });
|
||||
await games.save("nouvelle", journal(1), { level: "2ply" });
|
||||
assert.equal((await games.current())?.summary.id, "nouvelle");
|
||||
|
||||
// « Reprendre l'ancienne » : elle redevient la partie en cours, sans
|
||||
// être modifiée ; la nouvelle peut être supprimée.
|
||||
const before = await games.load("ancienne");
|
||||
await games.setCurrent("ancienne");
|
||||
await games.remove("nouvelle");
|
||||
assert.deepEqual(await games.current(), before);
|
||||
assert.deepEqual(
|
||||
(await games.list()).map((s) => s.id),
|
||||
["ancienne"],
|
||||
);
|
||||
await assert.rejects(games.setCurrent("nouvelle"), /introuvable/);
|
||||
|
||||
// La partie en cours supprimée : la dernière jouée la remplace.
|
||||
await games.save("autre", journal(), { level: "2ply" });
|
||||
await games.setCurrent("ancienne");
|
||||
await games.remove("ancienne");
|
||||
assert.equal((await games.current())?.summary.id, "autre");
|
||||
});
|
||||
|
||||
test("les opérations sont faites dans l'ordre des appels", async () => {
|
||||
const games = library();
|
||||
// Lancées sans attendre, comme l'enregistrement après chaque action.
|
||||
void games.save("a", journal(), { level: "2ply" });
|
||||
void games.save("a", journal(3), { level: "2ply" });
|
||||
void games.remove("a");
|
||||
void games.save("b", journal(), { level: "2ply" });
|
||||
assert.deepEqual(
|
||||
(await games.list()).map((s) => s.id),
|
||||
["b"],
|
||||
);
|
||||
});
|
||||
|
||||
test("les branches et le résultat sont résumés", async () => {
|
||||
const games = library();
|
||||
let j = journal(2);
|
||||
j = goBackTo(j, "2");
|
||||
j = appendEvent(j, { type: "DiceRolled", side: null, roll: [5, 2] });
|
||||
j = appendEvent(j, {
|
||||
type: "MovePlayed",
|
||||
side: "player",
|
||||
halfMoves: [],
|
||||
position: START_POSITION,
|
||||
});
|
||||
j = appendEvent(j, { type: "GameEnded", winner: "player", kind: "gammon", points: 2 });
|
||||
const summary = await games.save("x", j, { level: "2ply" });
|
||||
assert.equal(summary.branchCount, 2);
|
||||
assert.equal(summary.moveCount, 1);
|
||||
assert.equal(summary.status, "over");
|
||||
assert.deepEqual(summary.result, { winner: "player", kind: "gammon", points: 2 });
|
||||
});
|
||||
|
||||
test("un journal illisible n'est pas chargé ; un stockage plein ne casse rien", async () => {
|
||||
const storage = memoryStorage();
|
||||
const games = library(storage);
|
||||
await games.save("a", journal(), { level: "2ply" });
|
||||
storage.setItem("ustagammon.game.a", "{pas du JSON");
|
||||
assert.equal(await games.load("a"), null);
|
||||
assert.equal(isJournal({ events: [], head: 3 }), false);
|
||||
assert.equal(isJournal(null), false);
|
||||
|
||||
const full = library({
|
||||
getItem: () => null,
|
||||
setItem: () => {
|
||||
throw new Error("plein");
|
||||
},
|
||||
removeItem: () => {},
|
||||
});
|
||||
await assert.rejects(full.save("a", journal(), { level: "2ply" }), /plein/);
|
||||
// La bibliothèque reste utilisable après un échec.
|
||||
assert.deepEqual(await full.list(), []);
|
||||
});
|
||||
|
||||
test("la partie des versions précédentes est migrée, une seule fois", async () => {
|
||||
const storage = memoryStorage();
|
||||
const old = journal(1);
|
||||
storage.setItem("ustagammon.current-game", JSON.stringify(old));
|
||||
const games = library(storage);
|
||||
const id = await migrateLegacyGame(storage, games, 42);
|
||||
assert.ok(id !== null);
|
||||
assert.equal(storage.getItem("ustagammon.current-game"), null);
|
||||
const current = await games.current();
|
||||
assert.equal(current?.summary.id, id);
|
||||
assert.deepEqual(current?.journal, old);
|
||||
assert.equal(await migrateLegacyGame(storage, games), null);
|
||||
assert.equal((await games.list()).length, 1);
|
||||
|
||||
// Un ancien enregistrement illisible est seulement effacé.
|
||||
storage.setItem("ustagammon.current-game", "{pas du JSON");
|
||||
assert.equal(await migrateLegacyGame(storage, games), null);
|
||||
assert.equal(storage.getItem("ustagammon.current-game"), null);
|
||||
assert.equal((await games.list()).length, 1);
|
||||
});
|
||||
|
||||
test("sans IndexedDB : localStorage, ou à défaut la mémoire", async () => {
|
||||
const storage = memoryStorage();
|
||||
storage.setItem("ustagammon.current-game", JSON.stringify(journal()));
|
||||
const opened = await openGameLibrary({ localStorage: storage });
|
||||
assert.equal(opened.kind, "localStorage");
|
||||
assert.equal((await opened.library.list()).length, 1);
|
||||
|
||||
const refused: KeyValueStorage = {
|
||||
getItem: () => {
|
||||
throw new Error("interdit");
|
||||
},
|
||||
setItem: () => {
|
||||
throw new Error("interdit");
|
||||
},
|
||||
removeItem: () => {
|
||||
throw new Error("interdit");
|
||||
},
|
||||
};
|
||||
const memory = await openGameLibrary({ localStorage: refused });
|
||||
assert.equal(memory.kind, "memory");
|
||||
await memory.library.save("a", journal(), { level: "2ply" });
|
||||
assert.equal((await memory.library.current())?.summary.id, "a");
|
||||
|
||||
// IndexedDB qui échoue à l'ouverture : repli.
|
||||
const broken = {
|
||||
open: () => {
|
||||
throw new Error("indisponible");
|
||||
},
|
||||
} as unknown as IDBFactory;
|
||||
assert.equal((await openGameLibrary({ indexedDB: broken })).kind, "memory");
|
||||
});
|
||||
|
||||
test("les identifiants de partie sont distincts", () => {
|
||||
const ids = new Set(Array.from({ length: 100 }, () => newGameId(1)));
|
||||
assert.ok(ids.size > 95);
|
||||
assert.match(newGameId(), /^[0-9a-z]+-[0-9a-z]{6}$/);
|
||||
});
|
||||
|
||||
test("session : « Reprendre l'ancienne » par l'identifiant, et reprise d'une branche", async () => {
|
||||
const games = library();
|
||||
// Game.start et Game.resume n'interrogent pas le moteur.
|
||||
const engine = {} as Engine;
|
||||
const session = createGameSession(
|
||||
games,
|
||||
{
|
||||
start: () => Game.start(engine, { dice: fixedDice([5, 2]) }),
|
||||
resume: (j) => Game.resume(engine, j),
|
||||
},
|
||||
"2ply",
|
||||
);
|
||||
assert.equal(await session.restore(), null);
|
||||
assert.equal(await session.resumePrevious(), null);
|
||||
|
||||
const first = session.newGame();
|
||||
const firstId = session.shownId as string;
|
||||
session.save(first.journal);
|
||||
const second = session.newGame();
|
||||
session.save(second.journal);
|
||||
assert.notEqual(session.shownId, firstId);
|
||||
|
||||
// L'ancienne revient, relue dans le stockage ; la nouvelle disparaît.
|
||||
const back = await session.resumePrevious();
|
||||
assert.deepEqual(back?.journal, first.journal);
|
||||
assert.equal(session.shownId, firstId);
|
||||
assert.deepEqual(
|
||||
(await games.list()).map((s) => s.id),
|
||||
[firstId],
|
||||
);
|
||||
assert.equal(await session.resumePrevious(), null);
|
||||
|
||||
// Une branche reprise devient la ligne courante enregistrée.
|
||||
let j = goBackTo(first.journal, "1");
|
||||
j = appendEvent(j, { type: "DiceRolled", side: null, roll: [6, 1] });
|
||||
await games.save(firstId, j, { level: "2ply" });
|
||||
const original = branches(j).find((b) => b.forkedFrom === null);
|
||||
assert.ok(original !== undefined);
|
||||
const opened = await session.open(firstId, original.head);
|
||||
assert.equal(opened.journal.head, original.head);
|
||||
assert.equal((await games.current())?.journal.head, original.head);
|
||||
await assert.rejects(session.open("inconnue"), /introuvable/);
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
// Jouer une partie du domaine sans interface, pour les tests, les
|
||||
// scénarios et les parties d'exemple : les deux camps, avec des dés
|
||||
// reproductibles.
|
||||
//
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
|
||||
import type { Engine } from "../../src/domain/game/engine.js";
|
||||
import type { Game } from "../../src/domain/game/game.js";
|
||||
import type { DiceSource } from "../../src/domain/game/roll.js";
|
||||
|
||||
/** Des dés pseudo-aléatoires reproductibles (générateur congruentiel). */
|
||||
export function seededDice(seed: number): DiceSource {
|
||||
let state = seed;
|
||||
return () => {
|
||||
state = (state * 1103515245 + 12345) % 2147483648;
|
||||
return 1 + (Math.floor(state / 65536) % 6);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Une action de la partie, quel que soit le camp qui a la main. Le joueur
|
||||
* joue le choice-ième coup légal (le dernier s'il y en a moins), prend les
|
||||
* doubles si take, et double quand il le peut si double.
|
||||
*/
|
||||
export async function step(
|
||||
game: Game,
|
||||
engine: Engine,
|
||||
strategy: { choice?: number; take?: boolean; double?: boolean } = {},
|
||||
): Promise<void> {
|
||||
const state = game.state;
|
||||
if (state.turn === "computer" && state.phase === "doubled") {
|
||||
if (strategy.take ?? true) {
|
||||
game.take();
|
||||
} else {
|
||||
game.drop();
|
||||
}
|
||||
} else if (state.turn === "computer") {
|
||||
await game.playComputerTurn();
|
||||
} else if (state.phase === "rolling") {
|
||||
if (strategy.double === true && (await game.canDouble())) {
|
||||
await game.double();
|
||||
} else {
|
||||
await game.roll();
|
||||
}
|
||||
} else if (state.phase === "moving" && state.roll !== null) {
|
||||
const moves = await engine.legalMoves(state.position, state.roll);
|
||||
const move = moves[Math.min(strategy.choice ?? 0, moves.length - 1)];
|
||||
if (move === undefined) {
|
||||
await game.skipTurn();
|
||||
return;
|
||||
}
|
||||
for (const halfMove of move.halfMoves) {
|
||||
if (!(await game.playHalfMove(halfMove))) {
|
||||
throw new Error("coup légal refusé");
|
||||
}
|
||||
}
|
||||
await game.confirmMove();
|
||||
} else {
|
||||
throw new Error(`phase inattendue : ${state.phase}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** Joue jusqu'à count coups (des deux camps), ou jusqu'à la fin. */
|
||||
export async function playMoves(
|
||||
game: Game,
|
||||
engine: Engine,
|
||||
count: number,
|
||||
choice = 0,
|
||||
): Promise<void> {
|
||||
let played = 0;
|
||||
while (played < count && game.state.phase !== "over") {
|
||||
const before = game.events.length;
|
||||
await step(game, engine, { choice });
|
||||
played += game.events.slice(before).filter((event) => event.type === "MovePlayed").length;
|
||||
}
|
||||
}
|
||||
|
||||
export async function playToTheEnd(game: Game, engine: Engine, choice = 0): Promise<void> {
|
||||
for (let i = 0; game.state.phase !== "over"; i++) {
|
||||
if (i > 2000) {
|
||||
throw new Error("la partie ne finit pas");
|
||||
}
|
||||
await step(game, engine, { choice });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
// Parties d'exemple exportées en SGF, pour les faire relire par le GNU
|
||||
// Backgammon de référence (tests/test_sgf_reference.sh).
|
||||
//
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
//
|
||||
// Usage : node build/web/tests/tools/sgf-samples.js <dossier>
|
||||
//
|
||||
// Joue sur le vrai moteur WebAssembly, avec des dés reproductibles, des
|
||||
// parties variées : complètes, avec branches, avec videau, depuis une
|
||||
// position particulière. Pour chaque branche de chaque partie, écrit
|
||||
// <nom>.sgf (la partie exportée, cette branche en premier : c'est celle que
|
||||
// gnubg lit) et <nom>.json : les positions successives du journal sur cette
|
||||
// branche, celles que gnubg doit retrouver (tests/sgf/check.py).
|
||||
|
||||
import { mkdirSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { Game } from "../../src/domain/game/game.js";
|
||||
import { exportSgf } from "../../src/domain/game/sgf.js";
|
||||
import { loadNodeEngine } from "../support/node-engine.js";
|
||||
import { playMoves, playToTheEnd, seededDice, step } from "../support/play.js";
|
||||
|
||||
/** Le dernier événement « coup joué » de la ligne courante. */
|
||||
function lastMoveId(game: Game, skip = 0): string {
|
||||
const moves = game.events.filter((event) => event.type === "MovePlayed");
|
||||
const move = moves.at(-1 - skip);
|
||||
if (move === undefined) {
|
||||
throw new Error("aucun coup joué");
|
||||
}
|
||||
return move.id;
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const out = process.argv[2];
|
||||
if (out === undefined) {
|
||||
throw new Error("usage : sgf-samples.js <dossier>");
|
||||
}
|
||||
mkdirSync(out, { recursive: true });
|
||||
const engine = await loadNodeEngine();
|
||||
const games: [string, Game][] = [];
|
||||
|
||||
// Une partie complète, puis une autre où le joueur joue autrement.
|
||||
for (const [seed, choice] of [
|
||||
[3, 0],
|
||||
[11, 2],
|
||||
] as const) {
|
||||
const game = Game.start(engine, { dice: seededDice(seed), ply: 0 });
|
||||
await playToTheEnd(game, engine, choice);
|
||||
games.push([`complete-${seed}`, game]);
|
||||
}
|
||||
|
||||
// Des branches : rejouée d'un coup de l'ordinateur (nouveaux dés), d'un
|
||||
// jet du joueur (même jet, autre coup), et une branche d'une branche.
|
||||
{
|
||||
const game = Game.start(engine, { dice: seededDice(5), ply: 0 });
|
||||
await playMoves(game, engine, 14);
|
||||
const original = game.events.at(-1)?.id as string;
|
||||
game.goBackTo(lastMoveId(game, 6));
|
||||
await playMoves(game, engine, 8);
|
||||
const rolled = game.events.findLast((e) => e.type === "DiceRolled" && e.side === "player");
|
||||
if (rolled === undefined) {
|
||||
throw new Error("aucun jet du joueur");
|
||||
}
|
||||
game.goBackTo(rolled.id);
|
||||
await playMoves(game, engine, 5, 1);
|
||||
game.switchBranch(original);
|
||||
await playToTheEnd(game, engine);
|
||||
games.push(["branches", game]);
|
||||
}
|
||||
|
||||
// Le joueur double d'entrée ; l'ordinateur prend ou passe selon le moteur.
|
||||
{
|
||||
const game = Game.start(engine, { dice: seededDice(9), ply: 0 });
|
||||
for (let i = 0; i < 400 && game.state.phase !== "over"; i++) {
|
||||
await step(game, engine, { double: true });
|
||||
}
|
||||
games.push(["player-doubles", game]);
|
||||
}
|
||||
|
||||
// Depuis une position particulière : l'ordinateur, au trait, double
|
||||
// (position de référence « cube-double-take ») ; le joueur prend dans
|
||||
// une branche, passe dans une autre.
|
||||
{
|
||||
const game = Game.start(engine, {
|
||||
dice: seededDice(13),
|
||||
ply: 0,
|
||||
setup: { position: "6O5uAAC4uzsAAA", firstTurn: "computer" },
|
||||
});
|
||||
await step(game, engine);
|
||||
const offered = game.events.at(-1)?.id as string;
|
||||
await playMoves(game, engine, 6);
|
||||
game.goBackTo(offered);
|
||||
await step(game, engine, { take: false });
|
||||
games.push(["setup-cube", game]);
|
||||
}
|
||||
|
||||
// Depuis une position particulière, videau déjà doublé chez le joueur,
|
||||
// qui a la main ; partie laissée en cours, dés lancés et pas joués.
|
||||
{
|
||||
const game = Game.start(engine, {
|
||||
dice: seededDice(17),
|
||||
ply: 0,
|
||||
setup: {
|
||||
position: "t+0OAADg/wcAQA",
|
||||
firstTurn: "player",
|
||||
cube: { value: 2, owner: "player" },
|
||||
},
|
||||
});
|
||||
await playMoves(game, engine, 4);
|
||||
await game.roll();
|
||||
games.push(["setup-unfinished", game]);
|
||||
}
|
||||
|
||||
let files = 0;
|
||||
for (const [name, game] of games) {
|
||||
const original = game.journal;
|
||||
for (const [i, branch] of game.branches.entries()) {
|
||||
game.switchBranch(branch.head);
|
||||
const sgf = await exportSgf(engine, game.journal, {
|
||||
playerName: "Vous",
|
||||
computerName: "Ordinateur",
|
||||
date: "2026-11-03",
|
||||
});
|
||||
// Après chaque coup, la position vue de celui qui a alors la main,
|
||||
// et le résultat.
|
||||
const expected = {
|
||||
after: game.events.flatMap((e) => (e.type === "MovePlayed" ? [e.position] : [])),
|
||||
result: game.state.result,
|
||||
};
|
||||
writeFileSync(join(out, `${name}-${i}.sgf`), sgf);
|
||||
writeFileSync(join(out, `${name}-${i}.json`), `${JSON.stringify(expected)}\n`);
|
||||
files++;
|
||||
}
|
||||
game.goBackTo(original.head as string);
|
||||
}
|
||||
console.log(`${files} parties exportées dans ${out}`);
|
||||
}
|
||||
|
||||
await main();
|
||||
+2
-34
@@ -1,6 +1,6 @@
|
||||
// Tests de ce qui, dans l'interface et le stockage, se vérifie sans
|
||||
// navigateur : le plateau à dessiner, les mises en évidence, la lecture de
|
||||
// l'aide, l'enregistrement de la partie.
|
||||
// l'aide. Le stockage des parties a ses tests (storage.test.ts).
|
||||
//
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
@@ -9,9 +9,7 @@ import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { test } from "node:test";
|
||||
import type { BoardSide } from "../src/domain/game/engine.js";
|
||||
import { appendEvent, EMPTY_JOURNAL } from "../src/domain/game/events.js";
|
||||
import { parseHalfMoves, START_POSITION } from "../src/domain/game/position.js";
|
||||
import { createGameStore } from "../src/storage/game-store.js";
|
||||
import { parseHalfMoves } from "../src/domain/game/position.js";
|
||||
import {
|
||||
boardModel,
|
||||
moveTargets,
|
||||
@@ -176,36 +174,6 @@ test("l'aide lit chaque fiche de l'index du glossaire", () => {
|
||||
assert.equal(parseGlossaryEntry("pas une fiche"), null);
|
||||
});
|
||||
|
||||
test("la partie enregistrée se relit ; un enregistrement illisible est ignoré", () => {
|
||||
const data = new Map<string, string>();
|
||||
const storage = {
|
||||
getItem: (key: string) => data.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => void data.set(key, value),
|
||||
};
|
||||
const store = createGameStore(storage);
|
||||
assert.equal(store.load(), null);
|
||||
const journal = appendEvent(EMPTY_JOURNAL, {
|
||||
type: "GameStarted",
|
||||
position: START_POSITION,
|
||||
cube: { value: 1, owner: null },
|
||||
firstTurn: null,
|
||||
});
|
||||
store.save(journal);
|
||||
assert.deepEqual(store.load(), journal);
|
||||
for (const key of data.keys()) {
|
||||
data.set(key, "{pas du JSON");
|
||||
}
|
||||
assert.equal(store.load(), null);
|
||||
// Un stockage plein ou interdit ne casse pas la partie.
|
||||
const full = createGameStore({
|
||||
getItem: () => null,
|
||||
setItem: () => {
|
||||
throw new Error("plein");
|
||||
},
|
||||
});
|
||||
assert.doesNotThrow(() => full.save(journal));
|
||||
});
|
||||
|
||||
test("les textes de fin de partie emploient les mots du glossaire", () => {
|
||||
assert.equal(TEXTS.endTitle("computer", "gammon", false), "L'ordinateur gagne un gammon");
|
||||
assert.equal(TEXTS.endDetail("computer", 8, 4), "8 points pour lui, videau à 4.");
|
||||
|
||||
@@ -94,3 +94,15 @@ les justifie pas.
|
||||
dépendances seront fixés avec le socle, à la fin de l'étape 0.
|
||||
- Les sous-agents reçoivent ces règles dans leurs consignes ; `CLAUDE.md` y
|
||||
renvoie.
|
||||
|
||||
## Amendements
|
||||
|
||||
### 2026-11-03 — Le stockage des parties est un format durable
|
||||
|
||||
Avec les parties enregistrées (étape 1b), le schéma IndexedDB (base
|
||||
« ustagammon », version 1 : magasins `games`, `journals`, `settings`) et les
|
||||
clés `localStorage` de l'application (`ustagammon.settings`,
|
||||
`ustagammon.last-board`, et le repli des parties) rejoignent le journal
|
||||
d'événements parmi les formats durables : toute modification passe par une
|
||||
montée de version avec migration testée, jamais par une réécriture qui
|
||||
perdrait les parties des joueurs.
|
||||
|
||||
+1
-1
@@ -18,7 +18,7 @@ Repris du cadrage (modules, écrans clés, IA générative en V1) et des ADR 001
|
||||
| Sous-étape | Contenu | Dépend de |
|
||||
| --- | --- | --- |
|
||||
| 1a. Moteur pour l'analyse (fait) | Niveaux de jeu (plis et bruit de GNU Backgammon : débutant à expert), classement des coups d'un jet avec équités, analyse d'un coup joué et d'une décision de videau, chance d'un jet ; exposés par `engine.h`, `engine-cli` et le Worker, avec positions de référence comparées à gnubg de bureau | — |
|
||||
| 1b. Parties enregistrées | Stockage IndexedDB derrière l'interface de stockage existante : plusieurs parties, reprise automatique, branches (« Rejouer d'ici » crée une branche, l'originale reste intacte et rechargeable), export SGF avec variantes | — |
|
||||
| 1b. Parties enregistrées (fait) | Stockage IndexedDB derrière l'interface de stockage existante : plusieurs parties, reprise automatique, branches (« Rejouer d'ici » crée une branche, l'originale reste intacte et rechargeable), export SGF avec variantes | — |
|
||||
| 1c. Match et réglages | Partie libre ou match en N points, règle Crawford, règle Jacoby, choix du niveau de l'ordinateur, conseil du maître activable ou non ; écran de nouvelle partie | 1a, 1b |
|
||||
| 1d. Conseil du maître | Après chaque coup du joueur, jamais avant (ADR 0011) : appréciation du moteur (bon coup, imprécision, erreur, gaffe) avec mot, icône et couleur, phrase du maître fondée sur les chiffres du moteur, coup recommandé montré sur demande ; aussi pour les décisions de videau ; calcul en arrière-plan sans retarder le jeu | 1a |
|
||||
| 1e. Fin de partie et analyse | Bilan (ADR 0011) : résultat, coups par appréciation, taux d'erreur comparé à la chance, « le coup à revoir » ; écran d'analyse : graphe d'équité (toucher un point ouvre la position), liste des erreurs filtrable, visualiseur pas à pas coup joué contre meilleur coup, « Rejouer d'ici » | 1a, 1b, 1d |
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
@@ -0,0 +1,14 @@
|
||||
---
|
||||
term: Partie enregistrée
|
||||
english: Saved game
|
||||
code: SavedGameSummary
|
||||
---
|
||||
# Partie enregistrée
|
||||
|
||||
**En bref** : une partie gardée sur votre téléphone, finie ou en cours, avec toutes ses branches ; vous la retrouvez dans « Mes parties ».
|
||||
|
||||
Chaque partie est enregistrée après chacune de vos actions : fermer
|
||||
l'application ne perd rien. Rien ne quitte le téléphone, sauf si vous
|
||||
exportez une partie.
|
||||
|
||||
Voir aussi : [Partie](partie.md), [Branche](branche.md), [SGF](sgf.md)
|
||||
@@ -0,0 +1,13 @@
|
||||
---
|
||||
term: SGF
|
||||
english: SGF
|
||||
---
|
||||
# SGF
|
||||
|
||||
**En bref** : format de fichier des parties de GNU Backgammon ; une partie exportée en SGF s'ouvre dans GNU Backgammon pour l'analyser.
|
||||
|
||||
SGF (Smart Game Format) est un format commun à plusieurs jeux. Les branches
|
||||
d'une partie y deviennent des variantes ; GNU Backgammon ouvre la branche
|
||||
courante.
|
||||
|
||||
Voir aussi : [Partie enregistrée](partie-enregistree.md), [Moteur](moteur.md)
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
# Fin de partie et analyse (1e)
|
||||
|
||||
- **Date** : 2026-11-03
|
||||
- **Étape** : 1e (`docs/etape-1.md`)
|
||||
- **Statut** : prévu (après 1d)
|
||||
- **Exécutant** : sous-agent general-purpose, dans un worktree
|
||||
|
||||
## Objectif
|
||||
|
||||
Que le joueur comprenne sa partie : le bilan de fin de partie (ADR 0011), puis un écran d'analyse avec graphe d'équité, liste des erreurs, visualiseur et « Rejouer d'ici ».
|
||||
|
||||
## Consignes
|
||||
|
||||
Respecter les ADR 0007, 0008, 0011 et 0013. Chaque écran a son adresse : `#/parties/<id>/bilan` et `#/parties/<id>/analyse/<coup>`, à ajouter au tableau de l'ADR 0013 par un amendement. Pour la charte, suivre les maquettes « Fin de partie » du canevas. Aucune nouvelle dépendance : le graphe est dessiné en SVG à la main.
|
||||
|
||||
1. **Analyse de la partie.** Elle réutilise les analyses rangées par 1d (coups et videau) et complète celles qui manquent en arrière-plan, de façon progressive et annulable. Elle donne :
|
||||
- le taux d'erreur à la manière de gnubg ;
|
||||
- la chance aux dés (`luck`) pour chaque camp ;
|
||||
- le décompte des coups par appréciation.
|
||||
2. **Bilan**, dans le panneau de fin de partie existant :
|
||||
- le résultat ;
|
||||
- les coups par appréciation ;
|
||||
- le taux d'erreur comparé à la chance, en phrases simples ;
|
||||
- « le coup à revoir » : la plus grosse erreur, avec sa position, le coup joué et le meilleur coup ;
|
||||
- un emplacement pour le bouton « Demander à une IA » (1f) ;
|
||||
- « Voir l'analyse ».
|
||||
3. **Écran d'analyse** :
|
||||
- un graphe d'équité où toucher un point ouvre la position ;
|
||||
- la liste des erreurs, filtrable par appréciation ;
|
||||
- un visualiseur pas à pas qui montre sur le plateau le coup joué contre le meilleur coup, avec des pions animés ;
|
||||
- « Rejouer d'ici », qui crée une branche (domaine de 1b) sans toucher l'original.
|
||||
4. **Tests** :
|
||||
- scénarios Gherkin : bilan, coup à revoir, rejouer d'ici, chacun avec des dés imposés ;
|
||||
- fiches du glossaire : taux d'erreur, graphe d'équité si absentes ;
|
||||
- `make lint`, `make test`, `make test-browser`, avec des captures pour la recette design.
|
||||
|
||||
## Critères de réussite
|
||||
|
||||
- Après une partie, le bilan nomme le coup à revoir, conforme à l'analyse du moteur.
|
||||
- L'analyse s'ouvre par son adresse.
|
||||
- « Rejouer d'ici » crée une branche.
|
||||
- Les trois commandes passent.
|
||||
|
||||
## Résultat
|
||||
|
||||
À remplir à la clôture.
|
||||
@@ -0,0 +1,36 @@
|
||||
# Cinq premières leçons et accueil complet (1g)
|
||||
|
||||
- **Date** : 2026-11-03
|
||||
- **Étape** : 1g (`docs/etape-1.md`) ; le tutoriel des règles est traité en 0e.5
|
||||
- **Statut** : prévu (après 0e.5 et 1d)
|
||||
- **Exécutant** : sous-agent general-purpose, dans un worktree
|
||||
|
||||
## Objectif
|
||||
|
||||
Mettre en place les cinq premières leçons numérotées de l'ADR 0011. Chaque leçon est faite d'écrans courts : une explication, une position animée, puis « à vous de jouer ». La progression est stockée localement, et l'accueil propose « Continuer la leçon ».
|
||||
|
||||
## Consignes
|
||||
|
||||
1. **Contenus.** Ils vivent dans une source unique sous `docs/lecons/`, sous licence CC BY-SA 4.0, au même format que le tutoriel de 0e.5 (réutiliser son moteur d'écrans). Cinq leçons du parcours 2 du cadrage, au niveau débutant :
|
||||
1. Les ouvertures : bien jouer ses premiers dés.
|
||||
2. Construire ses cases et faire des points.
|
||||
3. Frapper ou ne pas frapper : sécurité et risque.
|
||||
4. La course et le compte de points.
|
||||
5. Sortir ses pions sans se faire frapper.
|
||||
|
||||
Chaque leçon compte deux à quatre exercices « à vous ». Leurs positions sont données par identifiants gnubg. La réponse attendue est le meilleur coup du moteur à 2 plis, vérifié par un test. Le texte n'affirme rien que le moteur ne confirme. Les contenus sont livrés en brouillon, marqués « à relire par un joueur fort » (cadrage, risque « contenus »), et Pierre les valide.
|
||||
2. **Progression.** Elle est enregistrée dans le stockage de 1b, dans le magasin `settings` ou dans un nouveau magasin avec montée de version (ADR 0007 amendé). Chaque leçon a son état : à commencer, « 2 exercices sur 4 », terminée.
|
||||
3. **Adresses** : `#/lecons` et `#/lecons/<n>/<écran>`, à ajouter au tableau de l'ADR 0013.
|
||||
4. **Accueil.** Il reçoit la carte « prochaine leçon » prévue par la charte.
|
||||
5. **Tests.**
|
||||
- Pour chaque exercice, la position est valide et la réponse attendue est bien le meilleur coup du moteur.
|
||||
- Scénarios Gherkin.
|
||||
- `make lint`, `make test`, `make test-browser`, avec captures.
|
||||
|
||||
## Critères de réussite
|
||||
|
||||
Les cinq leçons se suivent jusqu'au bout. Chaque exercice accepte le coup du moteur et commente les autres avec bienveillance. Les trois commandes passent.
|
||||
|
||||
## Résultat
|
||||
|
||||
À remplir à la clôture.
|
||||
@@ -1,57 +0,0 @@
|
||||
# Parties enregistrées, branches et export SGF (1b)
|
||||
|
||||
- **Date** : 2026-11-03
|
||||
- **Étape** : 1b (`docs/etape-1.md`)
|
||||
- **Statut** : prévu (lancé après la fusion de 0e.1)
|
||||
- **Exécutant** : sous-agent general-purpose, dans un worktree
|
||||
|
||||
## Objectif
|
||||
|
||||
Garder toutes les parties du joueur sur le téléphone, avec leurs branches, et
|
||||
les exporter au format SGF de GNU Backgammon.
|
||||
|
||||
## Consignes
|
||||
|
||||
ADR 0007 (domaine pur, stockage derrière une interface), ADR 0008 (scénarios
|
||||
et glossaire). Aucune nouvelle dépendance : l'API IndexedDB du navigateur
|
||||
directement.
|
||||
|
||||
1. **Stockage IndexedDB** derrière l'interface de `app/src/storage/` : une
|
||||
liste de parties (identifiant, date de début et de dernière action,
|
||||
adversaire et niveau, score, état en cours ou finie, résultat) et le
|
||||
journal d'événements de chacune. Migration transparente de la partie
|
||||
en cours enregistrée aujourd'hui dans `localStorage`. Si IndexedDB est
|
||||
indisponible (navigation privée), repli sur le comportement actuel.
|
||||
2. **Branches** : le journal est déjà un arbre (événements avec parent,
|
||||
`goBackTo`). Exposer dans le domaine la liste des branches d'une partie
|
||||
(feuilles de l'arbre, avec le coup d'origine) et le passage de l'une à
|
||||
l'autre. « Rejouer d'ici » depuis un coup passé crée une branche ; la
|
||||
branche d'origine reste intacte et rechargeable. Pas d'écran d'analyse ici
|
||||
(1e) : un accès minimal suffit pour le test (menu « Mes parties » :
|
||||
reprendre une partie, voir ses branches).
|
||||
3. **Nouvelle partie annulable** : l'arbitrage UX (plan
|
||||
`2026-11-03-revision-ux.md`, point 3) prévoit « Reprendre l'ancienne »
|
||||
pendant 10 s. Fournir l'API de stockage qui le permet (reprendre une partie
|
||||
par son identifiant) ; l'interface viendra avec la révision UX.
|
||||
4. **Export SGF** : une partie (et ses branches en variantes) au format SGF
|
||||
de GNU Backgammon, téléchargeable depuis « Mes parties ». Vérification par
|
||||
le gnubg de bureau de référence (Docker existant) : le fichier exporté se
|
||||
charge sans erreur et ses positions successives sont les mêmes que celles
|
||||
du journal, sur des parties générées par les tests.
|
||||
5. Scénarios Gherkin : reprendre une partie parmi plusieurs, rejouer d'ici
|
||||
sans perdre l'original, exporter une partie. Fiches du glossaire pour les
|
||||
nouveaux termes (branche, SGF…).
|
||||
|
||||
`make lint`, `make test` et `make test-browser` passent (ajouter au test
|
||||
navigateur : deux parties enregistrées, reprise de la première après
|
||||
rechargement). Commits fréquents ; pas de push ni de MR.
|
||||
|
||||
## Critères de réussite
|
||||
|
||||
- Après rechargement, la liste des parties et chaque branche sont intactes.
|
||||
- Un SGF exporté se charge dans gnubg de bureau avec les mêmes positions.
|
||||
- Les trois commandes de test passent.
|
||||
|
||||
## Résultat
|
||||
|
||||
À remplir à la clôture.
|
||||
@@ -0,0 +1,32 @@
|
||||
# Plateau soigné : gestes et finitions (1i)
|
||||
|
||||
- **Date** : 2026-11-03
|
||||
- **Étape** : 1i (`docs/etape-1.md`)
|
||||
- **Statut** : prévu
|
||||
- **Exécutant** : sous-agent general-purpose, dans un worktree
|
||||
|
||||
## Objectif
|
||||
|
||||
Terminer le plateau soigné du cadrage, une fois la révision UX en place :
|
||||
- les gestes naturels (principe d'expérience 2) ;
|
||||
- les écarts restants avec `docs/identite/charte/board.js`.
|
||||
|
||||
## Consignes
|
||||
|
||||
1. **Glisser-déposer.** On peut glisser un pion en plus du toucher pion puis destination. Le pion suit le doigt, et le lâcher hors d'une case permise le ramène à sa place. Les cibles restent celles du moteur.
|
||||
2. **Double toucher.** Un double toucher sur un pion joue le seul coup évident (cadrage). Un « coup évident » est défini ainsi : un seul coup légal complet commence par ce pion, ou le moteur ne propose qu'un coup. Sinon, rien ne se passe.
|
||||
3. **Écarts restants avec board.js.** Les relever par comparaison visuelle (filet des pions, numéros sur le cadre, videau…) et les corriger.
|
||||
4. **Clavier.** Faire ce que la révision UX a laissé :
|
||||
- le choix des pions aux flèches ;
|
||||
- l'affichage des raccourcis en `kbd` quand un clavier est détecté (interaction.md).
|
||||
5. **Tests.**
|
||||
- Scénarios Gherkin si un nouveau comportement est visible.
|
||||
- `make lint`, `make test`, `make test-browser` (glisser-déposer simulé, double toucher), avec captures pour la recette design.
|
||||
|
||||
## Critères de réussite
|
||||
|
||||
Un coup se joue entièrement au glisser-déposer. Le double toucher joue le coup évident. Les trois commandes passent.
|
||||
|
||||
## Résultat
|
||||
|
||||
À remplir à la clôture.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,30 @@
|
||||
# Progression (1h)
|
||||
|
||||
- **Date** : 2026-11-03
|
||||
- **Étape** : 1h (`docs/etape-1.md`)
|
||||
- **Statut** : prévu (après 1e)
|
||||
- **Exécutant** : sous-agent general-purpose, dans un worktree
|
||||
|
||||
## Objectif
|
||||
|
||||
Mettre en place la progression du cadrage pour la V1 : l'historique des parties et la courbe du taux d'erreur.
|
||||
|
||||
## Consignes
|
||||
|
||||
1. **Écran `#/progression`.** Il est conforme à la charte et s'ajoute au tableau de l'ADR 0013. Il montre :
|
||||
- la courbe du taux d'erreur par partie terminée, en SVG à la main, sans dépendance ;
|
||||
- la liste des parties, reliée à leur bilan et à leur analyse (1e) ;
|
||||
- un résumé en une phrase.
|
||||
2. **Accueil.** Il reçoit la « ligne de progression » de la charte.
|
||||
3. **Données.** Elles proviennent des analyses enregistrées : rien n'est recalculé si l'analyse existe. Une partie non analysée est analysée en arrière-plan.
|
||||
4. **Tests.**
|
||||
- Scénarios Gherkin.
|
||||
- `make lint`, `make test`, `make test-browser` avec trois parties jouées, et captures.
|
||||
|
||||
## Critères de réussite
|
||||
|
||||
Après trois parties, la courbe a trois points égaux aux taux d'erreur des bilans. Les trois commandes passent.
|
||||
|
||||
## Résultat
|
||||
|
||||
À remplir à la clôture.
|
||||
@@ -0,0 +1,42 @@
|
||||
# Prompts à copier (1f)
|
||||
|
||||
- **Date** : 2026-11-03
|
||||
- **Étape** : 1f (`docs/etape-1.md`)
|
||||
- **Statut** : prévu (après 1e)
|
||||
- **Exécutant** : sous-agent general-purpose, dans un worktree
|
||||
|
||||
## Objectif
|
||||
|
||||
Mettre en place la section « IA générative en V1 » du cadrage. Un bouton « Demander à une IA » copie un prompt rempli avec le contexte exact. Le moteur décide, l'IA explique. L'app n'appelle aucune IA.
|
||||
|
||||
## Consignes
|
||||
|
||||
1. **Gabarits.** Les cinq gabarits du cadrage sont versionnés dans une source unique sous `docs/prompts/`, sous licence CC BY-SA 4.0 :
|
||||
- « pourquoi ce coup est meilleur » ;
|
||||
- « expliquer cette décision de videau » ;
|
||||
- « bilan de ma partie » ;
|
||||
- « expliquer ce concept » ;
|
||||
- « créer des exercices », à garder pour la progression.
|
||||
|
||||
Chaque gabarit contient :
|
||||
- le rôle et le niveau du joueur ;
|
||||
- le Position ID et le Match ID ;
|
||||
- un diagramme texte de la position, produit à partir du plateau décodé par le moteur ;
|
||||
- les chiffres du moteur, avec le niveau d'évaluation ;
|
||||
- la consigne de ne pas contredire ces chiffres ;
|
||||
- la langue.
|
||||
|
||||
Ajouter aussi le lien `#/position/...` de l'ADR 0013, avec l'adresse publique du site.
|
||||
2. **Remplissage.** Une fonction pure remplit les gabarits. Elle est testée sur des analyses réelles du moteur : aucun champ vide ni `{inconnu}`, et les chiffres sont identiques à ceux du moteur.
|
||||
3. **Boutons.** Ils sont placés sur le conseil du maître (feuille d'explication), le bilan, l'analyse et la fiche d'un terme du glossaire. La copie passe par `navigator.clipboard`, avec un repli de sélection du texte. Un bandeau « Prompt copié » l'accompagne, et le texte reste visible pour qui veut le relire.
|
||||
4. **Tests.**
|
||||
- Scénarios Gherkin : copier le prompt d'une erreur, et celui d'un bilan.
|
||||
- `make lint`, `make test` et `make test-browser`, avec lecture du presse-papiers dans Chromium et captures.
|
||||
|
||||
## Critères de réussite
|
||||
|
||||
Le prompt copié après une erreur contient la position, les identifiants gnubg et le top 3 du moteur avec les mêmes chiffres que l'app. Les trois commandes passent.
|
||||
|
||||
## Résultat
|
||||
|
||||
À remplir à la clôture.
|
||||
@@ -47,4 +47,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 |
|
||||
|
||||
@@ -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
@@ -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 ` +
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
# Relit dans GNU Backgammon de référence les parties exportées en SGF par
|
||||
# Ustagammon (app/src/domain/game/sgf.ts) et vérifie qu'il y retrouve les
|
||||
# positions du journal.
|
||||
#
|
||||
# Ce script s'exécute DANS gnubg (scripting Python intégré), dans l'image
|
||||
# Docker ustagammon/gnubg-reference : voir tests/test_web.sh. Le dossier
|
||||
# SGF_DIR contient, pour chaque partie, <nom>.sgf et <nom>.json (écrits par
|
||||
# app/tests/tools/sgf-samples.ts) :
|
||||
# after : après chaque coup du journal, la position vue de celui qui a
|
||||
# alors la main ;
|
||||
# result : le résultat de la partie ({winner, points}) ou null.
|
||||
#
|
||||
# Pour chaque partie, gnubg charge le fichier (load game), puis avance
|
||||
# enregistrement par enregistrement (next) : après chaque coup, sa position
|
||||
# doit être celle du journal. Une position de départ mal posée, un coup mal
|
||||
# écrit ou refusé changent donc la suite des positions. Le résultat (RE)
|
||||
# doit aussi être celui du journal. Code de sortie : 0 si tout concorde.
|
||||
|
||||
import glob
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
import gnubg
|
||||
|
||||
FOLDER = os.environ.get("SGF_DIR", "/sgf")
|
||||
|
||||
|
||||
def check(path):
|
||||
"""Les écarts entre gnubg et le journal pour une partie (liste vide si
|
||||
tout concorde)."""
|
||||
with open(path[:-4] + ".json") as f:
|
||||
expected = json.load(f)
|
||||
gnubg.command("load game " + path)
|
||||
games = gnubg.match(analysis=0, boards=0, statistics=0, verbose=0)["games"]
|
||||
if len(games) != 1:
|
||||
return ["gnubg n'a pas chargé la partie"]
|
||||
game = games[0]
|
||||
records = list(game["game"])
|
||||
|
||||
# Le premier « next » applique le premier enregistrement.
|
||||
gnubg.command("first move")
|
||||
after = []
|
||||
for record in records:
|
||||
gnubg.command("next")
|
||||
if record["action"] == "move":
|
||||
after.append(gnubg.positionid())
|
||||
|
||||
errors = []
|
||||
if len(after) != len(expected["after"]):
|
||||
errors.append("%d coups lus, %d attendus" % (len(after), len(expected["after"])))
|
||||
for i, (got, want) in enumerate(zip(after, expected["after"])):
|
||||
if got != want:
|
||||
errors.append("après le coup %d : gnubg %s, journal %s" % (i + 1, got, want))
|
||||
break
|
||||
|
||||
# Dans gnubg, X est le joueur 0 (W, l'ordinateur) et O le joueur 1 (B).
|
||||
info = game["info"]
|
||||
result = expected["result"]
|
||||
if result is None:
|
||||
if info.get("winner") is not None:
|
||||
errors.append("gnubg voit une partie finie : %s" % info)
|
||||
else:
|
||||
winner = "O" if result["winner"] == "player" else "X"
|
||||
if (info.get("winner"), info.get("points-won")) != (winner, result["points"]):
|
||||
errors.append("résultat : gnubg %s, journal %s" % (info, result))
|
||||
return errors
|
||||
|
||||
|
||||
failures = 0
|
||||
paths = sorted(glob.glob(os.path.join(FOLDER, "*.sgf")))
|
||||
for path in paths:
|
||||
errors = check(path)
|
||||
name = os.path.basename(path)
|
||||
if errors:
|
||||
failures += 1
|
||||
print("ÉCHEC %s : %s" % (name, " ; ".join(errors)))
|
||||
else:
|
||||
print("OK %s" % name)
|
||||
|
||||
if not paths:
|
||||
print("aucun SGF dans %s" % FOLDER)
|
||||
failures += 1
|
||||
sys.stdout.flush()
|
||||
# Un script --python de gnubg ne transmet pas son code de sortie : la
|
||||
# dernière ligne le donne au script shell.
|
||||
print("SGF-RESULTAT %s" % ("OK" if failures == 0 else "ECHEC"))
|
||||
+22
-1
@@ -5,7 +5,10 @@
|
||||
# - scénarios de docs/scenarios/ exécutés par Cucumber sur le domaine branché
|
||||
# au vrai moteur (ADR 0008) ;
|
||||
# - règles d'architecture (dependency-cruiser, ADR 0007), et preuve qu'une
|
||||
# violation volontaire de chaque règle est bien détectée.
|
||||
# violation volontaire de chaque règle est bien détectée ;
|
||||
# - export SGF : des parties jouées sur le vrai moteur, exportées, relues par
|
||||
# le GNU Backgammon de référence, qui doit y retrouver les positions du
|
||||
# journal (tests/sgf/check.py).
|
||||
#
|
||||
# Lance `make engine-wasm` et `make web` (sans effet si tout est à jour).
|
||||
|
||||
@@ -39,6 +42,24 @@ web . node --test --test-reporter=dot 'build/web/tests/**/*.test.js' \
|
||||
web . cucumber-js --format summary --import 'build/web/tests/steps/*.js' docs/scenarios/*.feature \
|
||||
|| report "scénarios en échec (voir ci-dessus)"
|
||||
|
||||
# Export SGF relu par gnubg de bureau. Le script Python de gnubg ne
|
||||
# transmet pas de code de sortie : sa dernière ligne donne le verdict.
|
||||
samples=build/sgf-samples
|
||||
rm -rf "$samples"
|
||||
if ! docker image inspect "${REFERENCE_IMAGE:?lancer via make test}" >/dev/null 2>&1; then
|
||||
report "gnubg de référence absent : lancer d'abord make setup"
|
||||
elif ! web . node build/web/tests/tools/sgf-samples.js "$samples" >/dev/null; then
|
||||
report "parties d'exemple SGF non produites (voir ci-dessus)"
|
||||
else
|
||||
output="$(docker run --rm --user "$(id -u):$(id -g)" -e HOME=/tmp \
|
||||
-v "$PWD/$samples:/sgf:ro" -v "$PWD/tests/sgf:/check:ro" "$REFERENCE_IMAGE" \
|
||||
gnubg --tty --quiet --no-rc --python=/check/check.py 2>&1)" || true
|
||||
if ! grep -q '^SGF-RESULTAT OK$' <<<"$output"; then
|
||||
grep -E '^(ÉCHEC|aucun|Traceback| File|[A-Za-z]*Error)' <<<"$output" || true
|
||||
report "export SGF : gnubg ne retrouve pas les positions du journal"
|
||||
fi
|
||||
fi
|
||||
|
||||
web app depcruise --config .dependency-cruiser.cjs src tests \
|
||||
|| report "règles d'architecture non respectées (voir ci-dessus)"
|
||||
|
||||
|
||||
+316
-5
@@ -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
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user