Compare commits

...
Author SHA1 Message Date
Pierre MARTINandClaude Opus 5.5 86c0ebfc36 Recette design : tutoriel en paysage, sens du jeu, bandeau de mise à jour
- Sens du jeu (étape 2) : le tracé passe sous les pions et sous les
  numéros des flèches ; liseré ivoire de 8 px, points de 4 px à l'écran.
- Tutoriel en paysage : plateau à gauche sur toute la hauteur (géométrie
  du jeu en paysage, colonne gauche du cadre), colonne à droite avec la
  barre de progression, le titre, le texte, la consigne et les boutons ;
  seul le texte peut défiler. Tient en 915x412 et 844x320.
- Recette automatique étendue au tutoriel : numéros d'au moins 12 px,
  page sans défilement et boutons en vue en paysage, captures en 844x320.
- Boutons du videau « Prendre » et « Passer », lien « Quitter ».
- Bandeau « Nouvelle version prête. » en bas, au-dessus de la barre
  d'actions, seulement entre deux tours (betweenTurns, testé).
- Test des adresses : le lien Aide est lu au moment du toucher, la carte
  du terme ouvert est mesurée sous la barre de l'aide.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 00:10:15 +01:00
Pierre MARTINandClaude Opus 5.5 7c10c688c3 Tutoriel : étape 7 réécrite, « Prendre », « Passer » et « Quitter »
Texte de l'étape 7 remplacé par la version de la recette design (gammon,
backgammon et règle de Jacoby en un paragraphe), légende du plateau
gardée. Le texte du tutoriel est validé par Pierre (9 novembre 2026).
Scénario : les réponses au double sont « Prendre » et « Passer », le lien
du haut est « Quitter ».

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 00:10:15 +01:00
Pierre MARTINandClaude Opus 5.5 d1e234bb11 Fusion de main (MR !43) dans accueil-tutoriel-aide
Conflits résolus en gardant les deux apports : barre de l'aide collée
(scroll-margin sur la carte du terme) et position partagée en paysage.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 23:37:15 +01:00
Pierre MARTINandClaude Opus 5.5 65a4638475 Fusion de la MR !43 : une adresse pour chaque écran, et un lien pour chaque position (0e.4)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 19:03:28 +01:00
pierre 8e5be8e339 Fusion de la MR !46 : tutoriel, Jacoby 2026-11-09 17:38:55 +00:00
Pierre MARTINandClaude Opus 5.5 e724368f09 Charte : tutoriel, étape 7 avec la règle de Jacoby, lien « Quitter »
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 18:38:53 +01:00
Pierre MARTINandClaude Opus 5.5 2352a5317f Fusion de la MR !45 : « Grosse erreur » plutôt que « gaffe »
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 18:37:41 +01:00
Pierre MARTINandClaude Opus 5.5 0a88e835d2 Vocabulaire : « Grosse erreur » plutôt que « gaffe » (choix de Pierre)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 18:37:29 +01:00
Pierre MARTINandClaude Opus 5.5 71bff4def1 Accueil, tutoriel, aide : plan clos ; adresses ajoutées à l'ADR 0013
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 15:49:16 +01:00
Pierre MARTINandClaude Opus 5.5 8fd1ba3f4f Lint : formatage, ShellCheck sur le nettoyage du second site
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 15:36:19 +01:00
Pierre MARTINandClaude Opus 5.5 5fdca812d9 Tests navigateur : accueil, tutoriel, aide, installation, mise à jour ; plan : résultat
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 15:35:00 +01:00
Pierre MARTINandClaude Opus 5.5 441bca1bd7 Accueil, tutoriel, aide en questions, installation et mise à jour de l'application
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 15:17:49 +01:00
Pierre MARTINandClaude Opus 5.5 3f5b00133d Tutoriel : texte source, lecture, vérification par le moteur, adresses
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 14:55:32 +01:00
Pierre MARTIN f1c589eff1 Merge remote-tracking branch 'origin/adresses-ecrans' into accueil-tutoriel-aide 2026-11-09 14:45:10 +01:00
pierre dd5954a113 Fusion de la MR !42 : écrans 1e à 1i 2026-11-09 13:01:54 +00:00
Pierre MARTINandClaude Opus 5.5 4cdef612fd Charte : écrans de l'étape 1, lots 1e à 1i (bilan, analyse, IA, leçons, progression, gestes)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 14:01:51 +01:00
pierre 2eaa8ef304 Fusion de la MR !41 : écrans 1d 2026-11-09 12:53:29 +00:00
Pierre MARTINandClaude Opus 5.5 71a30cb2b6 Charte : écrans de l'étape 1, lot 1d (ligne et feuille du maître, phrases)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 13:53:27 +01:00
pierre 76fd72bbb5 Fusion de la MR !40 : écrans 1c 2026-11-09 12:47:21 +00:00
Pierre MARTINandClaude Opus 5.5 0e816564f9 Charte : écrans de l'étape 1, lot 1c (nouvelle partie, score de match)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 13:47:19 +01:00
55 changed files with 5508 additions and 224 deletions
+4 -3
View File
@@ -127,8 +127,9 @@ SITE_ICONS := icone.svg icone-192.png icone-512.png icone-maskable-192.png icone
# docs/identite/logo/ (une seule source, ADR 0010) ;
# - l'application compilée (build/web/src/) sous app/ ;
# - le moteur sous wasm/ ;
# - les copies du glossaire et des scénarios sous docs/, lues par l'aide de
# l'application (ADR 0008 : jamais commitées ailleurs) ;
# - les copies du glossaire, des scénarios et du tutoriel sous docs/, lues par
# l'aide et le tutoriel de l'application (ADR 0008 : jamais commitées
# ailleurs) ;
# - version.json (commit publié) ;
# - sw.js, complété par la liste de tous ces fichiers (scripts/service-worker.sh).
site: engine-wasm web ## Assemble le site publié dans build/site/
@@ -140,7 +141,7 @@ site: engine-wasm web ## Assemble le site publié dans build/site/
cp -r $(BUILD_DIR)/web/src $(BUILD_DIR)/site/app
cp $(BUILD_DIR)/wasm/engine.mjs $(BUILD_DIR)/wasm/engine.wasm $(BUILD_DIR)/wasm/engine.data \
$(BUILD_DIR)/wasm/positions.json $(BUILD_DIR)/site/wasm/
cp -r docs/glossaire docs/scenarios $(BUILD_DIR)/site/docs/
cp -r docs/glossaire docs/scenarios docs/tutoriel $(BUILD_DIR)/site/docs/
printf '{"commit": "%s", "dirty": %s}\n' "$$(git rev-parse HEAD)" \
"$$(git diff --quiet HEAD && echo false || echo true)" > $(BUILD_DIR)/site/version.json
scripts/service-worker.sh $(BUILD_DIR)/site
+241
View File
@@ -0,0 +1,241 @@
// Le tutoriel des règles, côté domaine : les positions illustrées et les
// écrans « à vous », vérifiés par le moteur.
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
//
// Le texte du tutoriel vit dans docs/tutoriel/ (ADR 0008 : une seule
// source) ; il donne pour chaque écran une position par son identifiant
// gnubg (ADR 0013), un jet, et les coups attendus en notation de gnubg.
// Rien n'est jugé ici : le moteur dit quels coups sont légaux, où mène
// chaque demi-coup, et s'il faut prendre un double. Le domaine ne fait que
// comparer ce qu'a joué le joueur aux coups attendus que le moteur a
// reconnus.
import { CENTERED_CUBE, type Cube } from "../game/cube.js";
import type { BoardResult, Engine, EngineCube, Move } from "../game/engine.js";
import { Game, type MoveSteps, type SideBoards, STANDARD_PLY } from "../game/game.js";
import type { HalfMove, PositionId } from "../game/position.js";
import { fixedDice, type Roll } from "../game/roll.js";
import type { Side } from "../game/side.js";
/** Un plateau du tutoriel, décodé par le moteur, et le résultat si la
* partie y est finie (vu du camp « player »). */
export interface TutorialBoard {
readonly sides: SideBoards;
readonly result: { readonly winner: Side; readonly kind: BoardResult["kind"] } | null;
}
/** Le videau vu du camp au trait. */
function engineCube(cube: Cube, onRoll: Side): EngineCube {
return {
value: cube.value,
owner: cube.owner === null ? "centered" : cube.owner === onRoll ? "onRoll" : "opponent",
};
}
/** Le plateau d'une position vue de onRoll, décodé par le moteur (erreur si
* l'identifiant est illisible). */
export async function tutorialBoard(
engine: Engine,
position: PositionId,
onRoll: Side = "player",
cube: Cube = CENTERED_CUBE,
): Promise<TutorialBoard> {
const board = await engine.board(position, engineCube(cube, onRoll));
const sides =
onRoll === "player"
? { player: board.onRoll, computer: board.opponent }
: { player: board.opponent, computer: board.onRoll };
const result =
board.result === null
? null
: {
winner:
(board.result.winner === "onRoll") === (onRoll === "player")
? ("player" as const)
: ("computer" as const),
kind: board.result.kind,
};
return { sides, result };
}
/** Les coups du jet que le moteur déclare légaux et dont la notation est
* l'une des notations données. Erreur si l'une d'elles n'est pas un coup
* légal : le texte du tutoriel ne correspond plus au moteur. */
export async function expectedMoves(
engine: Engine,
position: PositionId,
roll: Roll,
notations: readonly string[],
): Promise<Move[]> {
const legal = await engine.legalMoves(position, roll);
return notations.map((notation) => {
const move = legal.find((m) => m.notation === notation);
if (move === undefined) {
throw new Error(
`${notation} n'est pas un coup légal de ${roll.join("-")} dans ${position}`,
);
}
return move;
});
}
/** Le coup illustré d'un écran d'explication, demi-coup par demi-coup :
* les plateaux successifs, donnés par le moteur, et le résultat si le coup
* finit la partie. */
export async function illustratedMove(
engine: Engine,
position: PositionId,
roll: Roll,
notation: string,
): Promise<{ steps: MoveSteps; after: TutorialBoard }> {
const [move] = await expectedMoves(engine, position, roll, [notation]);
if (move === undefined) {
throw new Error(`coup illustré introuvable : ${notation}`);
}
const positions = [position];
for (let i = 1; i <= move.halfMoves.length; i++) {
const check = await engine.checkHalfMoves(position, roll, move.halfMoves.slice(0, i));
if (check.position === null) {
throw new Error(`demi-coup refusé par le moteur : ${notation}`);
}
positions.push(check.position);
}
const boards = await Promise.all(positions.map((p) => tutorialBoard(engine, p)));
const hits = move.halfMoves.map(
(_, i) => (boards[i + 1]?.sides.computer.bar ?? 0) > (boards[i]?.sides.computer.bar ?? 0),
);
const after = boards.at(-1) ?? boards[0];
if (after === undefined) {
throw new Error("aucun plateau");
}
return {
steps: {
side: "player",
halfMoves: move.halfMoves,
boards: boards.map((b) => b.sides),
hits,
},
after,
};
}
/**
* Les demi-coups saisis peuvent-ils encore mener à l'un des coups attendus ?
* Oui s'ils font tous partie (comme multiensemble) des demi-coups de l'un
* d'eux, tels que le moteur les donne. Un coup complet est jugé sur sa
* position (verdict de MovePractice).
*/
export function canStillLeadTo(played: readonly HalfMove[], expected: readonly Move[]): boolean {
return expected.some((move) => {
const left = [...move.halfMoves];
return played.every((h) => {
const i = left.findIndex((m) => m.from === h.from && m.to === h.to);
if (i < 0) {
return false;
}
left.splice(i, 1);
return true;
});
});
}
/** Ce que devient un écran « à vous » après un demi-coup. */
export type PracticeVerdict = "continue" | "right" | "wrong";
/**
* Un écran « à vous » où le joueur joue un coup : une partie d'un seul jet,
* commencée à la position de l'écran, jamais enregistrée. Le joueur saisit
* ses demi-coups comme en partie (le moteur dit lesquels sont permis) ; le
* coup est juste s'il mène à la position de l'un des coups attendus.
*/
export class MovePractice {
readonly #game: Game;
readonly #expected: readonly Move[];
readonly roll: Roll;
private constructor(game: Game, expected: readonly Move[], roll: Roll) {
this.#game = game;
this.#expected = expected;
this.roll = roll;
}
/** Prépare l'écran : la position, le jet, et les coups attendus,
* reconnus par le moteur (erreur sinon). */
static async start(
engine: Engine,
position: PositionId,
roll: Roll,
notations: readonly string[],
): Promise<MovePractice> {
const expected = await expectedMoves(engine, position, roll, notations);
const game = Game.start(engine, {
setup: { position, firstTurn: "player" },
dice: fixedDice(roll),
ply: 0,
});
await game.roll();
return new MovePractice(game, expected, roll);
}
/** Le plateau affiché, demi-coups saisis compris. */
board(): Promise<SideBoards> {
return this.#game.displayedBoard();
}
/** Les demi-coups permis maintenant (le moteur le dit). */
nextHalfMoves(): Promise<HalfMove[]> {
return this.#game.nextHalfMoves();
}
get pendingHalfMoves(): readonly HalfMove[] {
return this.#game.pendingHalfMoves;
}
/** Les dés du jet, et ceux déjà utilisés. */
get diceToPlay(): { value: number; used: boolean }[] {
return this.#game.diceToPlay;
}
/**
* Joue un demi-coup. « wrong » si le moteur le refuse, ou s'il ne peut
* plus mener à un coup attendu, ou si le coup complet n'est pas l'un
* d'eux : le demi-coup reste affiché, à l'appelant de revenir en
* arrière (reset). « right » quand le coup complet est l'un des coups
* attendus.
*/
async play(halfMove: HalfMove): Promise<PracticeVerdict> {
if (!(await this.#game.playHalfMove(halfMove))) {
return "wrong";
}
if (this.#game.moveComplete) {
const position = this.#game.displayedPosition;
return this.#expected.some((m) => m.position === position) ? "right" : "wrong";
}
return canStillLeadTo(this.#game.pendingHalfMoves, this.#expected) ? "continue" : "wrong";
}
/** Le pion revient : tous les demi-coups saisis sont oubliés. */
reset(): void {
this.#game.cancelHalfMoves();
}
}
/**
* Un écran « à vous » de videau : l'ordinateur, au trait dans position (vue
* de lui), double. Le moteur dit, au niveau standard de l'ordinateur, si le
* joueur doit prendre (vrai) ou passer (faux). Erreur si l'ordinateur ne
* peut pas doubler dans cette position.
*/
export async function shouldTake(engine: Engine, position: PositionId): Promise<boolean> {
const decision = await engine.cubeDecision(
position,
{ value: 1, owner: "centered" },
STANDARD_PLY,
);
if (!decision.available) {
throw new Error(`l'ordinateur ne peut pas doubler dans ${position}`);
}
return decision.take;
}
+26 -12
View File
@@ -8,12 +8,19 @@
import { Game } from "./domain/game/game.js";
import { linkOf, readPosition } from "./domain/game/position-link.js";
import { exportSgf } from "./domain/game/sgf.js";
import {
illustratedMove,
MovePractice,
shouldTake,
tutorialBoard,
} from "./domain/learning/tutorial.js";
import { createWasmEngine } from "./engine/wasm-engine.js";
import { startEngineWorker } from "./engine/worker-run.js";
import { createGameSession } from "./storage/game-session.js";
import { memoryStorage, openGameLibrary } from "./storage/open-library.js";
import { createSettingsStore } from "./storage/settings-store.js";
import { startApp } from "./ui/app.js";
import { startUpdates } from "./ui/updates.js";
// Ce fichier est servi sous app/ ; le Worker et le service worker sont à la
// racine du site.
@@ -96,20 +103,27 @@ startApp({
saveBoard: (board) => settings.saveBoard(board),
loadSettings: () => settings.loadSettings(),
saveSettings: (json) => settings.saveSettings(json),
// Le tutoriel : positions, coups illustrés et gestes jugés par le
// domaine, sur le moteur.
tutorial: {
board: (position, onRoll) => tutorialBoard(engine, position, onRoll),
illustrate: (position, roll, move) => illustratedMove(engine, position, roll, move),
practice: (position, roll, expected) =>
MovePractice.start(engine, position, roll, expected),
shouldTake: (position) => shouldTake(engine, position),
},
// Hors ligne : le service worker met tout le site en cache. Le navigateur
// le refuse hors https (sauf localhost) : l'application marche alors sans.
// Il est enregistré une fois le moteur chargé : si le Worker et le service
// worker téléchargent les données du moteur (8,7 Mo) en même temps,
// Chromium fait échouer l'installation (« Cache.add() encountered a
// network error »), et l'application n'est pas disponible hors ligne.
// Cela évite aussi de télécharger deux fois ces données à la première
// visite.
startUpdates: (onAvailable) =>
startUpdates(new URL("../sw.js", import.meta.url), worker.ready, onAvailable),
});
// Hors ligne : le service worker met tout le site en cache. Le navigateur le
// refuse hors https (sauf localhost) : l'application marche alors sans.
// Il est enregistré une fois le moteur chargé : si le Worker et le service
// worker téléchargent les données du moteur (8,7 Mo) en même temps, Chromium
// fait échouer l'installation (« Cache.add() encountered a network error »),
// et l'application n'est pas disponible hors ligne. Cela évite aussi de
// télécharger deux fois ces données à la première visite.
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 {
+328 -18
View File
@@ -52,11 +52,19 @@ 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 { createHelpView, loadGlossaryEntry } from "./help.js";
import { type HelpMoment, helpHash } from "./help-context.js";
import { dismissInstall, homeModel, installMenuShown, launchHash, welcomed } from "./home-model.js";
import { HOME_TEXTS } from "./home-texts.js";
import { createHomeView } from "./home-view.js";
import { icon } from "./icons.js";
import { installPlatform, startInstaller } from "./install.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 { isFirstLaunch, longPrompts, nextTheme, parseSettings, type Settings } from "./settings.js";
import { type PromptText, TEXTS } from "./texts.js";
import { seenAfter } from "./tutorial-nav.js";
import { createTutorialView, type TutorialAccess } from "./tutorial-view.js";
import { betweenTurns, type Updates, updateBannerShown } from "./updates.js";
/** Glissement d'un pion de l'ordinateur, et pause entre deux pions
* (interaction.md : on suit le coup sans l'attendre). */
@@ -95,6 +103,11 @@ export interface AppOptions {
/** Les réglages, en JSON (null : jamais enregistrés). */
readonly loadSettings: () => string | null;
readonly saveSettings: (json: string) => void;
/** Le tutoriel : plateaux, coups illustrés, gestes jugés par le moteur. */
readonly tutorial: TutorialAccess;
/** Les mises à jour de l'application (service worker) : onAvailable est
* appelé quand une nouvelle version attend. */
readonly startUpdates: (onAvailable: () => void) => Updates;
}
/** Ce que « Mes parties » demande au stockage. */
@@ -222,6 +235,21 @@ export function startApp(options: AppOptions): void {
banner: byId("banner"),
bannerText: byId("banner-text"),
resumeOld: byId<HTMLButtonElement>("resume-old"),
home: byId("home"),
tutorial: byId("tutorial"),
menuHome: byId<HTMLAnchorElement>("menu-home"),
menuTutorial: byId<HTMLAnchorElement>("menu-tutorial"),
menuInstall: byId<HTMLButtonElement>("menu-install"),
menuUpdate: byId<HTMLButtonElement>("menu-update"),
installVeil: byId("install-veil"),
installTitle: byId("install-title"),
installClose: byId<HTMLButtonElement>("install-close"),
installLead: byId("install-lead"),
installSteps: byId("install-steps"),
installDone: byId<HTMLButtonElement>("install-done"),
updateBanner: byId("update-banner"),
updateText: byId("update-text"),
updateApply: byId<HTMLButtonElement>("update-apply"),
};
// ------------------------------------------------------ Réglages
@@ -304,6 +332,28 @@ export function startApp(options: AppOptions): void {
child(ui.menuScreenOn, ".item-label").textContent = TEXTS.keepScreenOn;
ui.bannerText.textContent = TEXTS.newGameStarted;
ui.resumeOld.textContent = TEXTS.resumeOld;
label(ui.menuHome, HOME_TEXTS.homeLink, "home");
label(ui.menuTutorial, HOME_TEXTS.tutorialMenu, "rules");
label(ui.menuInstall, HOME_TEXTS.installMenu, "install");
label(ui.menuUpdate, HOME_TEXTS.updateCheck, "replay");
ui.installTitle.textContent = HOME_TEXTS.installTitle;
ui.installClose.append(icon("close"));
ui.installClose.setAttribute("aria-label", TEXTS.close);
ui.installLead.textContent = HOME_TEXTS.installLead;
ui.installDone.textContent = HOME_TEXTS.installUnderstood;
ui.updateApply.textContent = HOME_TEXTS.updateApply;
for (const [i, text] of HOME_TEXTS.installSteps[
installPlatform(navigator.userAgent)
].entries()) {
const item = document.createElement("li");
const number = document.createElement("span");
number.className = "step-number";
number.textContent = String(i + 1);
const words = document.createElement("span");
words.textContent = text;
item.append(number, words);
ui.installSteps.append(item);
}
const glossaryLink = child<HTMLAnchorElement>(ui.bubble, ".link-button");
glossaryLink.textContent = TEXTS.seeGlossary;
glossaryLink.href = routeHash({
@@ -352,6 +402,23 @@ export function startApp(options: AppOptions): void {
// La position partagée affichée (#/position/...), une fois lue.
let shownPosition: SharedPosition | null = null;
let positionRequest = 0;
// L'installation (invite du navigateur, application installée) et les
// mises à jour (service worker).
const installer = startInstaller((installedNow) => {
if (installedNow) {
saveSettings({ installed: true });
} else {
render();
}
});
let updates: Updates | null = null;
// Un message éphémère dans le bandeau de mise à jour (« Vous avez la
// dernière version. »), sans bouton.
let updateMessage: string | null = null;
let updateMessageTimer: ReturnType<typeof setTimeout> | null = null;
// Les parties jouées et gagnées, lues dans « Mes parties » pour
// l'accueil.
let homeProgress: { played: number; won: number } | null = null;
const landscapeQuery = window.matchMedia("(orientation: landscape) and (max-height: 40rem)");
const portraitQuery = window.matchMedia("(orientation: portrait)");
@@ -515,6 +582,7 @@ export function startApp(options: AppOptions): void {
ui.cancel.disabled = busy || game.pendingHalfMoves.length === 0;
renderPanel(game, landscape, doubled);
renderMenu();
renderChrome();
ui.resumeOld.disabled = busy;
// Pour le style et pour les tests dans un navigateur : l'écran
// attend le moteur.
@@ -563,6 +631,7 @@ export function startApp(options: AppOptions): void {
ui.info.textContent = notice;
}
renderMenu();
renderChrome();
}
/** Les commandes de la colonne de gauche, en paysage. */
@@ -641,6 +710,52 @@ export function startApp(options: AppOptions): void {
element.hidden = !visible;
}
/** Ce qui entoure les écrans : l'aide du moment derrière le bouton
* « Aide », la ligne « Installer » du menu, le bandeau de mise à jour,
* et l'accueil s'il est affiché. */
function renderChrome(): void {
const helpLink = helpHash(helpMoment());
ui.helpButton.href = helpLink;
ui.menuHelp.href = helpLink;
ui.menuInstall.hidden = !installMenuShown(settings, installer.installed);
// Le bandeau de mise à jour attend la fin du tour, même hors de
// l'écran de jeu : la partie enregistrée reprend alors sans perte.
const quiet =
!busy &&
(game === null || betweenTurns(game.state.phase, game.pendingHalfMoves.length));
if (updateMessage !== null) {
ui.updateBanner.hidden = false;
ui.updateText.textContent = updateMessage;
ui.updateApply.hidden = true;
} else {
const shown = updateBannerShown(updates?.available ?? false, quiet, !ui.banner.hidden);
ui.updateBanner.hidden = !shown;
ui.updateText.textContent = HOME_TEXTS.updateAvailable;
ui.updateApply.hidden = false;
}
if (navigation.route.screen === "home") {
renderHome();
}
}
/** Le moment de la partie, pour ouvrir l'aide sur la bonne question. */
function helpMoment(): HelpMoment | null {
if (game === null) {
return null;
}
const state = game.state;
if (state.phase === "over") {
return "over";
}
if (state.turn === "computer" && state.phase === "doubled") {
return "doubled";
}
if (state.turn === "player" && state.phase === "moving") {
return stuck ? "stuck" : "moving";
}
return null;
}
// --------------------------------------------- Vibrations, écran allumé
/** Une seule vibration par fin de partie. */
@@ -801,10 +916,18 @@ export function startApp(options: AppOptions): void {
notice = null;
ui.bubble.hidden = true;
render();
const before = game;
const wasOver = game.state.phase === "over";
try {
await action(game);
save();
await computerTurns();
// Une partie vient de se terminer (pas une partie finie
// reprise) : elle compte pour l'invitation à installer.
if (game === before && !wasOver && game.state.phase === "over") {
saveSettings({ gamesFinished: settings.gamesFinished + 1 });
homeProgress = null;
}
} catch (error) {
info = TEXTS.actionFailed(error instanceof Error ? error.message : String(error));
} finally {
@@ -888,7 +1011,8 @@ export function startApp(options: AppOptions): void {
// 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";
const isSheet = (route: Route) =>
route.screen === "menu" || route.screen === "games" || route.screen === "install";
// « Mes parties » : reprendre une partie (ou une branche) en change à
// l'écran, sans recharger la page.
@@ -1018,7 +1142,7 @@ export function startApp(options: AppOptions): void {
},
help: () => {
closeTransient();
navigation.go("#/aide");
navigation.go(helpHash(helpMoment()));
},
fullscreen: () => {
closeOverlays();
@@ -1037,6 +1161,8 @@ export function startApp(options: AppOptions): void {
navigation.leave(isHelpScreen);
} else if (route.screen === "position") {
navigation.leave((r) => r.screen === "position");
} else if (route.screen === "tutorial" || route.screen === "tutorialEnd") {
navigation.leave((r) => r.screen === "tutorial" || r.screen === "tutorialEnd");
}
},
};
@@ -1075,6 +1201,7 @@ export function startApp(options: AppOptions): void {
function leaveSheet(): void {
const menuWasOpen = !ui.menuVeil.hidden;
ui.menuVeil.hidden = true;
ui.installVeil.hidden = true;
gamesView?.hide();
if (menuWasOpen) {
(menuReturnFocus ?? ui.menuButton).focus();
@@ -1125,6 +1252,8 @@ export function startApp(options: AppOptions): void {
// téléphone ne rouvre pas le menu.
replacingLink(ui.menuGames, navigation);
replacingLink(ui.menuHelp, navigation);
replacingLink(ui.menuHome, navigation);
replacingLink(ui.menuTutorial, navigation);
ui.menuClose.onclick = () => closeOverlays();
ui.menuNewGame.onclick = () => newGame();
ui.menuCopyLink.onclick = () => {
@@ -1159,19 +1288,56 @@ export function startApp(options: AppOptions): void {
// ----------------------------------------------------------- Adresses
let menuReturnFocus: HTMLElement | null = null;
// L'écran sous la feuille d'installation : le dernier écran affiché.
let underSheet: Route = { screen: "game" };
/** 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";
// La feuille d'installation s'ouvre par-dessus l'écran d'où l'on
// vient (l'accueil, le plus souvent), qui reste dessous.
if (route.screen !== "install") {
underSheet = route;
}
const below = underSheet;
const help = isHelpScreen(below);
const position = below.screen === "position";
const home = below.screen === "home";
const tutorial = below.screen === "tutorial" || below.screen === "tutorialEnd";
const page = help || position || home || tutorial;
const wasPage = ui.game.hidden;
ui.game.hidden = help || position;
const wasScreen = ui.root.dataset.screen;
ui.game.hidden = page;
ui.help.hidden = !help;
ui.position.hidden = !position;
ui.root.classList.toggle("page-open", help || position);
if (wasPage && !ui.game.hidden) {
ui.home.hidden = !home;
ui.tutorial.hidden = !tutorial;
ui.root.classList.toggle("page-open", page);
// Le tutoriel et le premier lancement ont leur propre en-tête.
ui.root.classList.toggle("bare", tutorial || (home && isFirstLaunch(settings)));
ui.root.dataset.screen = below.screen;
if ((wasPage && !ui.game.hidden) || (page && wasScreen !== below.screen)) {
window.scrollTo(0, 0);
}
if (tutorial) {
if (route.screen !== "install") {
void tutorialView.show(below);
}
} else {
tutorialView.hide();
}
if (home) {
homeProgress = null;
}
// La marche à suivre pour installer : une feuille, comme le menu.
if (route.screen === "install" && ui.installVeil.hidden) {
closeTransient();
ui.menuVeil.hidden = true;
ui.installVeil.hidden = false;
ui.installDone.focus();
} else if (route.screen !== "install") {
ui.installVeil.hidden = true;
}
// Feuilles : le menu et « Mes parties ».
const menuOpen = route.screen === "menu";
@@ -1204,26 +1370,161 @@ export function startApp(options: AppOptions): void {
gamesView?.hide();
}
if (help) {
void helpView.show(route);
const sheet = route.screen === "install";
if (isHelpScreen(below) && !sheet) {
void helpView.show(below);
}
if (position) {
void showPosition(route);
if (below.screen === "position") {
if (!sheet) {
void showPosition(below);
}
} 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;
// Une adresse inconnue : la partie, avec un message, à l'adresse de
// la partie.
if (route.screen === "unknown") {
notice = TEXTS.unknownAddress;
navigation.replace("#/");
return;
}
render();
}
// ----------------------------------------------- Accueil et tutoriel
const homeView = createHomeView({
section: ui.home,
newGame: () => {
if (busy || game === null) {
return;
}
navigation.go("#/");
newGame();
},
install: () => void install(false),
dismissInstall: () => saveSettings(dismissInstall(settings)),
welcomed: () => saveSettings(welcomed()),
});
let progressLoading = false;
function renderHome(): void {
const playing = game === null ? null : game.state.phase !== "over";
homeView.render({
model: homeModel(settings, playing, installer.installed),
sides: sides ?? options.cachedBoard()?.sides ?? null,
tutorialSeen: settings.tutorialSeen,
progress: homeProgress,
busy: busy || game === null,
});
if (homeProgress === null && !progressLoading) {
progressLoading = true;
// La liste vient du stockage, ouvert avec le moteur.
options.engineReady
.then(() => options.games.list())
.then((list) => {
const over = list.filter((summary) => summary.status === "over");
homeProgress = {
played: over.length,
won: over.filter((summary) => summary.result?.winner === "player").length,
};
if (navigation.route.screen === "home") {
renderHome();
}
})
.catch(() => undefined)
.finally(() => {
progressLoading = false;
});
}
}
const tutorialView = createTutorialView({
section: ui.tutorial,
access: options.tutorial,
engineReady: options.engineReady,
seen: () => settings.tutorialSeen,
visit: (place) => {
const seen = seenAfter(settings.tutorialSeen, place);
if (seen !== settings.tutorialSeen || !settings.welcomed) {
saveSettings({ tutorialSeen: seen, ...welcomed() });
}
},
placed: () => haptic("checkerPlaced"),
reducedMotion,
landscape: () => layout() === "landscape",
replace: (hash) => navigation.replace(hash),
});
// ------------------------------------------------------- Installation
/** « Installer » : l'invite du navigateur s'il en a donné une, sinon la
* marche à suivre (#/installer). Depuis le menu, l'adresse du menu est
* remplacée : le retour du téléphone ne le rouvre pas. */
async function install(fromMenu: boolean): Promise<void> {
if (installer.canPrompt) {
if (fromMenu) {
closeOverlays();
}
await installer.offer();
render();
return;
}
if (fromMenu) {
ui.menuVeil.hidden = true;
navigation.replace("#/installer");
} else {
navigation.go("#/installer");
}
}
ui.menuInstall.onclick = () => void install(true);
const leaveInstall = () => {
ui.installVeil.hidden = true;
navigation.leave((route) => route.screen === "install");
};
ui.installClose.onclick = leaveInstall;
ui.installDone.onclick = leaveInstall;
ui.installVeil.addEventListener("click", (event) => {
if (event.target === ui.installVeil) {
leaveInstall();
}
});
// ------------------------------------------------------- Mise à jour
updates = options.startUpdates(() => render());
ui.updateApply.onclick = () => {
ui.updateApply.disabled = true;
updates?.apply();
};
function showUpdateMessage(message: string | null, lasting: boolean): void {
if (updateMessageTimer !== null) {
clearTimeout(updateMessageTimer);
updateMessageTimer = null;
}
updateMessage = message;
if (message !== null && !lasting) {
updateMessageTimer = setTimeout(() => showUpdateMessage(null, true), 4000);
}
render();
}
ui.menuUpdate.onclick = () => {
closeOverlays();
showUpdateMessage(HOME_TEXTS.updateChecking, true);
void (updates?.check() ?? Promise.resolve("unavailable" as const)).then((found) => {
showUpdateMessage(
found === "found"
? null
: found === "latest"
? HOME_TEXTS.updateLatest
: HOME_TEXTS.updateUnavailable,
found === "found",
);
});
};
// -------------------------------------------- Vue d'une position
/** #/position/... : lit la position par le moteur, puis la montre. */
@@ -1418,6 +1719,9 @@ export function startApp(options: AppOptions): void {
if (shownPosition !== null) {
renderPosition({ kind: "shown", shared: shownPosition });
}
if (!ui.tutorial.hidden) {
tutorialView.relayout();
}
});
};
window.addEventListener("resize", relayout);
@@ -1560,8 +1864,14 @@ export function startApp(options: AppOptions): void {
// ---------------------------------------------------------- Démarrage
// Réponse immédiate : le plateau est dessiné avant que le moteur arrive,
// ou l'écran que désigne l'adresse.
applyRoute(navigation.route);
// ou l'écran que désigne l'adresse. Lancée sans adresse d'écran (icône,
// adresse du site), l'application s'ouvre sur l'accueil.
const launch = launchHash(location.hash);
if (launch !== null) {
navigation.replace(launch);
} else {
applyRoute(navigation.route);
}
options.engineReady.then(
async () => {
game = options.savedGame();
+1
View File
@@ -166,6 +166,7 @@ export function renderBoard(
// Les pions, dessinés à part puis posés par-dessus les flèches du coup
// de l'ordinateur : une flèche ne cache jamais un pion.
const pieces = document.createElementNS(SVG_NS, "g");
pieces.setAttribute("class", "pieces");
for (const point of board.points) {
drawPoint(svg, pieces, g, hits, board, point.number, decor);
}
+32
View File
@@ -0,0 +1,32 @@
// La question de l'aide qui correspond au moment de la partie : le bouton
// « Aide » de l'écran de jeu l'ouvre directement (charte : « fermée par
// défaut sauf celle qui correspond à l'écran d'où l'on vient »).
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
//
// Les questions sont les titres des scénarios de docs/scenarios/ (ADR 0008) ;
// leur adresse en est tirée par slug. Un test (app/tests/ui.test.ts) vérifie
// que chaque titre ci-dessous est bien celui d'un scénario : changer un
// titre dans un .feature demande de le changer ici.
import { routeHash, slug } from "./router.js";
/** Le moment de la partie, vu de l'aide. */
export type HelpMoment = "moving" | "stuck" | "doubled" | "over";
export const HELP_QUESTIONS: Readonly<Record<HelpMoment, string>> = {
moving: "Comment jouer un coup ?",
stuck: "Je ne peux rien jouer avec mes dés",
doubled: "L'ordinateur double : que se passe-t-il si je prends ?",
over: "Comment se termine une partie ?",
};
/** L'adresse de l'aide à ouvrir à ce moment (l'onglet « Jouer » sans
* question particulière : null). */
export function helpHash(moment: HelpMoment | null): string {
return routeHash({
screen: "help",
question: moment === null ? null : slug(HELP_QUESTIONS[moment]),
});
}
+54 -6
View File
@@ -14,10 +14,16 @@ export interface HelpFeature {
}
export interface HelpScenario {
/** Le titre, formulé comme la question du joueur. */
readonly title: string;
/** La réponse : le paragraphe sous le titre. */
readonly description: readonly string[];
/** Les étapes, mot-clé compris : « Quand vous prenez ». */
/** Les étapes, mot-clé compris : « Quand vous prenez ». Elles ne
* s'affichent pas dans l'aide : elles prouvent la réponse. */
readonly steps: readonly string[];
/** La position de l'exemple, donnée par l'étiquette
* @position:<Position ID> du scénario (null sans étiquette). */
readonly position: string | null;
}
const STEP = /^(Étant donnée?s?|Etant donnée?s?|Soit|Quand|Lorsque|Alors|Donc|Et|Mais)\s/;
@@ -27,13 +33,20 @@ const FEATURE = /^Fonctionnalité\s*:\s*(.*)$/;
/**
* Lit un fichier Gherkin en français (fichiers .feature). Les lignes de texte
* libre consécutives forment un paragraphe ; les commentaires (#) sont
* ignorés.
* ignorés ; une étiquette @position:<Position ID> donne la position de
* l'exemple du scénario qui la suit.
*/
export function parseFeature(text: string): HelpFeature {
let title = "";
const description: string[] = [];
const scenarios: { title: string; description: string[]; steps: string[] }[] = [];
const scenarios: {
title: string;
description: string[];
steps: string[];
position: string | null;
}[] = [];
let paragraph: string[] = [];
let tags: string[] = [];
// Le paragraphe en cours rejoint la description de la fonctionnalité ou
// du dernier scénario.
@@ -50,12 +63,23 @@ export function parseFeature(text: string): HelpFeature {
const scenario = SCENARIO.exec(line);
if (line === "" || line.startsWith("#")) {
endParagraph();
} else if (line.startsWith("@")) {
endParagraph();
tags.push(...line.split(/\s+/));
} else if (feature) {
endParagraph();
title = feature[1] ?? "";
tags = [];
} else if (scenario) {
endParagraph();
scenarios.push({ title: scenario[2] ?? "", description: [], steps: [] });
const position = tags.find((tag) => tag.startsWith("@position:"));
scenarios.push({
title: scenario[2] ?? "",
description: [],
steps: [],
position: position === undefined ? null : position.slice("@position:".length),
});
tags = [];
} else if (STEP.test(line) && scenarios.length > 0) {
endParagraph();
scenarios.at(-1)?.steps.push(line);
@@ -74,18 +98,42 @@ export function parseGlossaryIndex(readme: string): string[] {
return [...section.matchAll(/\]\(([a-z0-9-]+\.md)\)/g)].map((match) => match[1] ?? "");
}
/** Une fiche du glossaire : le terme et sa phrase « En bref ». */
/** Une fiche du glossaire : le terme, son nom anglais (celui du code), sa
* phrase « En bref », et ses renvois « Voir aussi ». */
export interface GlossaryEntry {
readonly term: string;
readonly english: string;
readonly brief: string;
/** Les fiches citées par « Voir aussi » : le mot, et la fiche sans .md. */
readonly seeAlso: readonly { readonly term: string; readonly name: string }[];
}
/** Lit une fiche du glossaire ; null si elle n'a pas la forme attendue. */
export function parseGlossaryEntry(text: string): GlossaryEntry | null {
const term = /^term: (.+)$/m.exec(text)?.[1]?.trim();
const english = /^english: (.+)$/m.exec(text)?.[1]?.trim() ?? "";
const brief = /^\*\*En bref\*\* : (.+)$/m.exec(text)?.[1]?.trim();
if (term === undefined || brief === undefined) {
return null;
}
return { term, brief: brief.replaceAll("**", "") };
const seeLine = /^Voir aussi : (.+)$/m.exec(text)?.[1] ?? "";
const seeAlso = [...seeLine.matchAll(/\[([^\]]+)\]\(([a-z0-9-]+)\.md\)/g)].map((m) => ({
term: m[1] ?? "",
name: m[2] ?? "",
}));
return { term, english, brief: brief.replaceAll("**", ""), seeAlso };
}
/** Un texte sans accents ni majuscules, pour la recherche dans le
* glossaire. */
export function searchable(text: string): string {
return text.normalize("NFD").replace(/[̀-ͯ]/g, "").toLowerCase();
}
/** La fiche correspond-elle à la recherche (le terme, son nom anglais ou sa
* phrase « En bref » contiennent chacun des mots cherchés) ? */
export function matchesSearch(entry: GlossaryEntry, query: string): boolean {
const words = searchable(query).split(/\s+/).filter(Boolean);
const haystack = searchable(`${entry.term} ${entry.english} ${entry.brief}`);
return words.every((word) => haystack.includes(word));
}
+112 -21
View File
@@ -8,25 +8,33 @@
//
// 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).
// est ouvert (show). Le titre d'un scénario est la question, sa
// description la réponse ; ses étapes, la preuve que l'application fait ce
// que dit la réponse, ne s'affichent pas
// (docs/identite/charte/accueil-tutoriel-aide.md, « Aide »).
import {
type GlossaryEntry,
type HelpFeature,
type HelpScenario,
matchesSearch,
parseFeature,
parseGlossaryEntry,
parseGlossaryIndex,
} from "./help-text.js";
import { HOME_TEXTS } from "./home-texts.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 = [
export const FEATURES = [
"docs/scenarios/jouer-une-partie.feature",
"docs/scenarios/apprendre-les-regles.feature",
"docs/scenarios/mes-parties.feature",
"docs/scenarios/partager-une-position.feature",
"docs/scenarios/regler-l-application.feature",
"docs/scenarios/installer-l-application.feature",
"docs/scenarios/decouvrir-l-application.feature",
];
const GLOSSARY = "docs/glossaire/";
@@ -59,6 +67,18 @@ export async function loadGlossaryEntry(file: string): Promise<GlossaryEntry | n
return parseGlossaryEntry(await fetchText(GLOSSARY + file));
}
/** Le commit publié (version.json, écrit par make site), en abrégé. */
async function publishedVersion(): Promise<string | null> {
try {
const version = JSON.parse(await fetchText("version.json")) as { commit?: unknown };
return typeof version.commit === "string" && version.commit !== ""
? version.commit.slice(0, 12)
: null;
} catch {
return null;
}
}
function element<K extends keyof HTMLElementTagNameMap>(
parent: HTMLElement,
tag: K,
@@ -101,7 +121,7 @@ export function createHelpView(container: HTMLElement, tabs: HTMLElement): HelpV
notice.className = "help-notice";
notice.setAttribute("role", "status");
const questions: Question[] = [];
const terms = new Map<string, HTMLElement>();
const terms = new Map<string, { dt: HTMLElement; card: HTMLElement; entry: GlossaryCard }>();
let loading: Promise<boolean> | null = null;
// La question ouverte au dernier affichage : refermée, elle reste en vue.
let lastOpen: Question | null = null;
@@ -126,10 +146,14 @@ export function createHelpView(container: HTMLElement, tabs: HTMLElement): HelpV
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);
if (scenario.position !== null) {
const board = element(answer, "a", HOME_TEXTS.seeOnBoard);
board.className = "see-board";
board.href = routeHash({
screen: "position",
position: scenario.position,
match: null,
});
}
questions.push({ id, section, link, answer });
}
@@ -137,7 +161,7 @@ export function createHelpView(container: HTMLElement, tabs: HTMLElement): HelpV
function renderFeature(feature: HelpFeature): void {
element(play, "h2", feature.title);
for (const paragraph of feature.description) {
element(play, "p", paragraph);
element(play, "p", paragraph).className = "feature-intro";
}
for (const scenario of feature.scenarios) {
renderScenario(play, scenario);
@@ -146,38 +170,103 @@ export function createHelpView(container: HTMLElement, tabs: HTMLElement): HelpV
function renderGlossary(entries: readonly GlossaryCard[]): void {
element(glossary, "h2", TEXTS.glossaryTitle);
const label = element(glossary, "label");
label.className = "glossary-search";
element(label, "span", HOME_TEXTS.glossarySearch).className = "search-label";
const search = element(label, "input");
search.id = "glossary-search";
search.type = "search";
search.autocomplete = "off";
search.spellcheck = false;
const none = element(glossary, "p", HOME_TEXTS.glossaryNone);
none.className = "glossary-none muted";
none.hidden = true;
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);
const sorted = [...entries].sort((a, b) => a.term.localeCompare(b.term, "fr"));
for (const entry of sorted) {
const card = element(list, "div");
card.className = "term-card";
const dt = element(card, "dt");
dt.id = `glossaire-${entry.name}`;
const link = element(dt, "a", entry.term);
link.href = routeHash({ screen: "glossary", term: entry.name });
element(list, "dd", entry.brief);
terms.set(entry.name, term);
if (entry.english !== "") {
const english = element(dt, "span", entry.english);
english.className = "english";
english.lang = "en";
}
const dd = element(card, "dd");
element(dd, "p", entry.brief);
if (entry.seeAlso.length > 0) {
const see = element(dd, "p");
see.className = "see-also";
see.append(`${HOME_TEXTS.glossarySeeAlso} `);
entry.seeAlso.forEach((other, i) => {
if (i > 0) {
see.append(", ");
}
const a = element(see, "a", other.term);
a.href = routeHash({ screen: "glossary", term: other.name });
});
}
terms.set(entry.name, { dt, card, entry });
}
search.addEventListener("input", () => {
let shown = 0;
for (const { card, entry } of terms.values()) {
const match = matchesSearch(entry, search.value);
card.hidden = !match;
shown += match ? 1 : 0;
}
none.hidden = shown > 0;
});
}
function renderAbout(): void {
function renderAbout(version: string | null): void {
element(about, "h2", TEXTS.aboutTitle);
const text = element(about, "p", TEXTS.about);
text.className = "about";
for (const paragraph of HOME_TEXTS.aboutParagraphs) {
element(about, "p", paragraph).className = "about";
}
const line = element(
about,
"p",
version === null ? HOME_TEXTS.aboutVersionUnknown : HOME_TEXTS.aboutVersion(version),
);
line.id = "about-version";
line.className = "about";
}
function renderTutorialCard(): void {
const card = element(container, "a");
card.id = "help-tutorial";
card.className = "card help-tutorial";
card.href = "#/tutoriel/1";
const text = element(card, "span");
text.className = "help-tutorial-text";
element(text, "strong", HOME_TEXTS.helpTutorialTitle);
element(text, "span", HOME_TEXTS.helpTutorialText).className = "muted";
element(card, "span", HOME_TEXTS.helpTutorialLink).className = "pill";
}
async function load(): Promise<boolean> {
container.replaceChildren(notice);
notice.textContent = TEXTS.helpLoading;
try {
const [features, entries] = await Promise.all([
const [features, entries, version] = await Promise.all([
Promise.all(FEATURES.map(async (url) => parseFeature(await fetchText(url)))),
loadGlossary(),
publishedVersion(),
]);
const intro = element(play, "p", HOME_TEXTS.helpIntro);
intro.className = "help-intro muted";
for (const feature of features) {
renderFeature(feature);
}
renderGlossary(entries);
renderAbout();
renderAbout(version);
notice.textContent = "";
renderTutorialCard();
container.append(play, glossary, about);
container.dataset.loaded = "true";
return true;
@@ -215,10 +304,12 @@ export function createHelpView(container: HTMLElement, tabs: HTMLElement): HelpV
q.section.classList.toggle("open", open);
}
const term = route.screen === "glossary" ? route.term : null;
for (const [name, dt] of terms) {
for (const [name, { dt, card }] of terms) {
dt.classList.toggle("current", name === term);
card.classList.toggle("current", name === term);
if (name === term) {
dt.setAttribute("aria-current", "true");
card.hidden = false;
} else {
dt.removeAttribute("aria-current");
}
@@ -233,7 +324,7 @@ export function createHelpView(container: HTMLElement, tabs: HTMLElement): HelpV
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));
const target = opened?.section ?? (term === null ? undefined : terms.get(term)?.card);
if (target !== undefined) {
target.scrollIntoView({ block: "start" });
} else if (closed !== null) {
+64
View File
@@ -0,0 +1,64 @@
// Ce que montre l'accueil, et où s'ouvre l'application : fonctions pures,
// testées sous Node (docs/identite/charte/accueil-tutoriel-aide.md).
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
import { installCardShown, isFirstLaunch, type Settings } from "./settings.js";
/** Les deux choix du premier lancement : « Je découvre » mène au
* tutoriel, « Je connais les règles » à la partie. */
export const WELCOME_LINKS = { discover: "#/tutoriel/1", play: "#/" } as const;
/** Les réglages après l'un des deux choix du premier lancement. */
export function welcomed(): Partial<Settings> {
return { welcomed: true };
}
/** L'adresse où ouvrir l'application lancée sans adresse d'écran (icône de
* l'écran d'accueil, adresse du site) : l'accueil. Une adresse donnée est
* gardée (null). */
export function launchHash(hash: string): string | null {
return hash === "" || hash === "#" ? "#/accueil" : null;
}
/** L'accueil, au premier lancement ou ensuite. */
export interface HomeModel {
/** Premier lancement : le choix « Je découvre » / « Je connais les
* règles », et rien d'autre. */
readonly welcome: boolean;
/** La carte de la partie : « Reprendre la partie » si une partie est en
* cours, sinon « Nouvelle partie » en action principale. */
readonly resume: boolean;
/** L'invitation à installer. */
readonly installCard: boolean;
}
/** Ce que montre l'accueil. playing : une partie est en cours (null : pas
* encore connu, le moteur se charge). */
export function homeModel(
settings: Settings,
playing: boolean | null,
installed: boolean,
): HomeModel {
const welcome = isFirstLaunch(settings);
return {
welcome,
resume: playing !== false,
installCard: !welcome && installCardShown(settings, installed),
};
}
/** La ligne « Installer l'application » du menu : tant que l'application
* n'est pas installée. */
export function installMenuShown(settings: Settings, installed: boolean): boolean {
return !installed && !settings.installed;
}
/** Les réglages après « Plus tard » sur l'invitation à installer. */
export function dismissInstall(settings: Settings): Partial<Settings> {
return {
installDismissals: settings.installDismissals + 1,
installDismissedAt: settings.gamesFinished,
};
}
+125
View File
@@ -0,0 +1,125 @@
// Les textes de l'accueil, du tutoriel, de l'installation et de la mise à
// jour (ADR 0007, point 5 ; docs/identite/charte/accueil-tutoriel-aide.md).
// Première version, à valider par Pierre. Le texte des étapes du tutoriel
// n'est pas ici : il vit dans docs/tutoriel/regles.md.
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
import type { ResultKind } from "../domain/game/engine.js";
import type { InstallPlatform } from "./install.js";
function plural(n: number, one: string, many: string): string {
return `${n} ${n > 1 ? many : one}`;
}
export const HOME_TEXTS = {
// Premier lancement
welcomeTitle: "Bienvenue à la table",
welcomeText:
"Ustagammon vous apprend le backgammon pas à pas, et vous fait jouer contre l'ordinateur, même sans réseau.",
discover: "Je découvre",
discoverDetail: "Les règles en huit petites étapes, plateau à l'appui",
knowRules: "Je connais les règles",
knowRulesDetail: "Une partie tout de suite",
tutorialInMenu: "Le tutoriel reste dans le menu, à tout moment.",
// Accueil
homeLink: "Accueil",
homeTitle: "À vous de jouer",
homeLead: "Reprenez votre partie contre l'ordinateur, ou revoyez les règles.",
gameOverline: "Partie en cours",
noGameOverline: "Pas de partie en cours",
againstComputer: "Contre l'ordinateur",
homePips: (player: number, computer: number) =>
`Compte de points : vous ${player}, l'ordinateur ${computer}`,
homeLoading: "Chargement…",
resumeGame: "Reprendre la partie",
rulesTitle: "Les règles du jeu",
rulesDetail: "Le tutoriel, en huit étapes, plateau à l'appui",
rulesStart: "Commencer",
rulesContinue: (step: number) => `Reprendre à l'étape ${step}`,
rulesAgain: "Revoir",
progress: (played: number, won: number) =>
`${plural(played, "partie jouée", "parties jouées")} · ${plural(won, "gagnée", "gagnées")}`,
footer: "Né à l'Atelier du Huit, à Cugnaux. Logiciel libre, sans publicité ni mise.",
// Installation
installTitle: "Installer Ustagammon",
installCardText: "Sur l'écran d'accueil, en plein écran, sans réseau.",
install: "Installer",
installLater: "Plus tard",
installMenu: "Installer l'application",
installLead:
"Une icône sur votre écran d'accueil, le jeu en plein écran, et tout marche sans réseau.",
installSteps: {
firefox: [
"Touchez le menu du navigateur ⋮, en bas ou en haut de l'écran.",
"Choisissez « Installer » ou « Ajouter à l'écran d'accueil ».",
"Confirmez. Ensuite, ouvrez Ustagammon depuis son icône.",
],
ios: [
"Touchez le bouton de partage, le carré d'où sort une flèche, en bas de Safari.",
"Choisissez « Sur l'écran d'accueil ».",
"Touchez « Ajouter ». Ensuite, ouvrez Ustagammon depuis son icône.",
],
} satisfies Record<InstallPlatform, readonly string[]>,
installUnderstood: "J'ai compris",
installed: "Ustagammon est installé : ouvrez-le depuis son icône.",
// Mise à jour
updateAvailable: "Nouvelle version prête.",
updateApply: "Mettre à jour",
updateCheck: "Rechercher une mise à jour",
updateChecking: "Recherche d'une mise à jour…",
updateLatest: "Vous avez la dernière version.",
updateUnavailable:
"Impossible de chercher une mise à jour : pas de réseau, ou navigateur qui ne le permet pas.",
// Tutoriel
tutorialMenu: "Les règles (tutoriel)",
tutorialProgress: "Étapes du tutoriel",
tutorialStepLink: (n: number, title: string) => `Étape ${n} : ${title}`,
tutorialSkip: "Quitter",
tutorialSkipLabel: "Quitter le tutoriel",
tutorialOverline: (n: number, total: number, practice: boolean) =>
`Les règles · ${n} sur ${total}${practice ? " · à vous" : ""}`,
tutorialEndOverline: "Les règles · fin",
tutorialEndTitle: "Vous connaissez les règles",
back: "Retour",
next: "Continuer",
replay: "Revoir",
replayLabel: "Revoir le coup",
take: "Prendre",
drop: "Passer",
tutorialLoading: "Chargement du tutoriel…",
tutorialFailed: "Le tutoriel n'a pas pu se charger.",
pathLabel: "Le sens du jeu de vos pions, en pointillés",
/** Le résultat d'une partie finie par le coup illustré (étape 7). */
illustratedEnd: (kind: ResultKind) =>
kind === "single"
? "Partie finie : l'ordinateur avait sorti des pions, c'est une partie simple."
: kind === "gammon"
? "Partie finie : l'ordinateur n'a sorti aucun pion, c'est un gammon."
: "Partie finie : il reste un pion de l'ordinateur dans votre camp, c'est un backgammon.",
computerDoubles: "L'ordinateur double.",
// Aide
helpTutorialTitle: "Nouveau au backgammon ?",
helpTutorialText: "Le tutoriel des règles, en huit étapes, plateau à l'appui.",
helpTutorialLink: "Apprendre les règles",
helpIntro:
"Une question en partie ? Elle a sûrement sa réponse ici. Touchez une question pour lire la réponse.",
seeOnBoard: "Voir sur le plateau",
glossarySearch: "Chercher un mot",
glossaryNone: "Aucun mot ne correspond.",
glossarySeeAlso: "Voir aussi :",
aboutVersion: (commit: string) => `Version publiée : ${commit}.`,
aboutVersionUnknown: "Version publiée : inconnue (lancée depuis les sources).",
aboutParagraphs: [
"Ustagammon est un logiciel libre, sous licence GNU GPL version 3 ou ultérieure : vous pouvez l'utiliser, l'étudier, le modifier et le partager.",
"Les textes du tutoriel, de l'aide et du glossaire sont sous licence Creative Commons BY-SA 4.0.",
"L'ordinateur joue et juge les coups avec GNU Backgammon (GPLv3), compilé pour le navigateur. Police Alegreya Sans, sous licence SIL Open Font License.",
"Né à l'Atelier du Huit, café associatif à Cugnaux. Sans publicité, sans compte, sans mise : tout reste sur votre téléphone.",
],
} as const;
+242
View File
@@ -0,0 +1,242 @@
// L'accueil (#/accueil) : au premier lancement, le choix « Je découvre » /
// « Je connais les règles » ; ensuite, la partie en cours, l'invitation à
// installer, les règles et la progression résumée
// (docs/identite/charte/accueil-tutoriel-aide.md, « Accueil »).
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
//
// Les leçons (1g) et la progression détaillée (1h) n'existent pas encore :
// l'accueil n'affiche que ce qui existe.
import type { SideBoards } from "../domain/game/game.js";
import { boardModel, OPENING_BOARDS } from "./board-model.js";
import { NO_MARKS, renderBoard } from "./board-view.js";
import { boardGeometry, PORTRAIT_ASPECT } from "./geometry.js";
import { type HomeModel, WELCOME_LINKS } from "./home-model.js";
import { HOME_TEXTS } from "./home-texts.js";
import { icon } from "./icons.js";
import { TUTORIAL_DONE } from "./settings.js";
import { TEXTS } from "./texts.js";
export interface HomeViewOptions {
readonly section: HTMLElement;
/** « Nouvelle partie » (bouton : une action, pas un écran). */
readonly newGame: () => void;
readonly install: () => void;
readonly dismissInstall: () => void;
/** Un des deux choix du premier lancement a été suivi. */
readonly welcomed: () => void;
}
export interface HomeState {
readonly model: HomeModel;
/** Le plateau de la partie en cours ou de la dernière partie (null :
* jamais joué). */
readonly sides: SideBoards | null;
/** La dernière étape du tutoriel vue. */
readonly tutorialSeen: number;
/** Parties jouées et gagnées (null : pas encore lues). */
readonly progress: { readonly played: number; readonly won: number } | null;
/** Le moteur se charge, ou l'écran est occupé. */
readonly busy: boolean;
}
export interface HomeView {
render(state: HomeState): void;
}
const SVG_NS = "http://www.w3.org/2000/svg";
function element<K extends keyof HTMLElementTagNameMap>(
parent: HTMLElement,
tag: K,
options: { id?: string; className?: string; text?: string } = {},
): HTMLElementTagNameMap[K] {
const child = document.createElement(tag);
if (options.id !== undefined) {
child.id = options.id;
}
if (options.className !== undefined) {
child.className = options.className;
}
if (options.text !== undefined) {
child.textContent = options.text;
}
parent.append(child);
return child;
}
function boardSvg(parent: HTMLElement, id: string, className: string): SVGSVGElement {
const svg = document.createElementNS(SVG_NS, "svg");
svg.id = id;
svg.setAttribute("class", `board ${className}`);
svg.setAttribute("aria-hidden", "true");
parent.append(svg);
return svg;
}
function logo(parent: HTMLElement): void {
for (const [variant, file] of [
["logo-light", "logo-horizontal-couleur.svg"],
["logo-dark", "logo-horizontal-sombre.svg"],
] as const) {
const img = element(parent, "img", { className: variant });
img.src = `icons/${file}`;
img.alt = "Ustagammon";
}
}
function drawBoard(svg: SVGSVGElement, sides: SideBoards, width: number): void {
renderBoard(svg, boardGeometry("portrait", PORTRAIT_ASPECT, width / 640), boardModel(sides), {
...NO_MARKS,
dice: [],
cube: { value: 1, owner: null },
band: null,
controls: null,
});
}
/** Une grande carte-lien du premier lancement : le choix et son détail. */
function choice(parent: HTMLElement, id: string, href: string, label: string, detail: string) {
const link = element(parent, "a", { id, className: "choice" });
link.href = href;
element(link, "strong", { text: label });
element(link, "span", { text: detail });
return link;
}
export function createHomeView(options: HomeViewOptions): HomeView {
const { section } = options;
section.replaceChildren();
// Premier lancement.
const welcome = element(section, "div", { id: "home-welcome", className: "welcome" });
const welcomeLogo = element(welcome, "h1", { className: "welcome-logo" });
logo(welcomeLogo);
const welcomeBoard = boardSvg(welcome, "welcome-board", "welcome-board");
const welcomeText = element(welcome, "div", { className: "welcome-text" });
element(welcomeText, "h2", { text: HOME_TEXTS.welcomeTitle });
element(welcomeText, "p", { text: HOME_TEXTS.welcomeText });
const choices = element(welcome, "div", { className: "welcome-choices" });
const discover = choice(
choices,
"welcome-discover",
WELCOME_LINKS.discover,
HOME_TEXTS.discover,
HOME_TEXTS.discoverDetail,
);
discover.classList.add("primary");
const play = choice(
choices,
"welcome-play",
WELCOME_LINKS.play,
HOME_TEXTS.knowRules,
HOME_TEXTS.knowRulesDetail,
);
element(choices, "p", { className: "caption", text: HOME_TEXTS.tutorialInMenu });
for (const link of [discover, play]) {
link.addEventListener("click", () => options.welcomed());
}
// Ensuite.
const main = element(section, "div", { id: "home-main", className: "home-main" });
const intro = element(main, "div", { className: "home-intro" });
element(intro, "h2", { text: HOME_TEXTS.homeTitle });
element(intro, "p", { className: "muted", text: HOME_TEXTS.homeLead });
const card = element(main, "article", { id: "home-game", className: "card home-game" });
const cardTop = element(card, "div", { className: "home-game-top" });
const miniBox = element(cardTop, "div", { className: "home-mini" });
const mini = boardSvg(miniBox, "home-board", "home-board");
const facts = element(cardTop, "div", { className: "home-facts" });
const gameOverline = element(facts, "span", { className: "overline" });
element(facts, "strong", { text: HOME_TEXTS.againstComputer });
const pips = element(facts, "span", { id: "home-pips", className: "home-pips" });
const actions = element(card, "div", { className: "home-actions" });
const resume = element(actions, "a", {
id: "home-resume",
className: "button",
text: HOME_TEXTS.resumeGame,
});
resume.href = "#/";
const fresh = element(actions, "button", { id: "home-new", text: TEXTS.newGame });
fresh.type = "button";
fresh.addEventListener("click", () => options.newGame());
const install = element(main, "aside", { id: "install-card", className: "install-card" });
install.setAttribute("aria-label", HOME_TEXTS.installTitle);
const symbol = element(install, "img", { className: "install-symbol" });
symbol.src = "icons/symbole-couleur.svg";
symbol.alt = "";
const installText = element(install, "span", { className: "install-text" });
element(installText, "strong", { text: HOME_TEXTS.installTitle });
element(installText, "span", { className: "muted", text: HOME_TEXTS.installCardText });
const installButton = element(install, "button", {
id: "install-card-button",
className: "secondary",
text: HOME_TEXTS.install,
});
installButton.type = "button";
installButton.addEventListener("click", () => options.install());
const later = element(install, "button", { id: "install-later", className: "icon-button" });
later.type = "button";
later.append(icon("close"));
later.setAttribute("aria-label", HOME_TEXTS.installLater);
later.title = HOME_TEXTS.installLater;
later.addEventListener("click", () => options.dismissInstall());
const rules = element(main, "a", { id: "home-rules", className: "card home-rules" });
const rulesText = element(rules, "span", { className: "home-rules-text" });
element(rulesText, "strong", { text: HOME_TEXTS.rulesTitle });
element(rulesText, "span", { className: "muted", text: HOME_TEXTS.rulesDetail });
const rulesAction = element(rules, "span", { className: "pill" });
const progress = element(main, "p", {
id: "home-progress",
className: "caption home-progress",
});
const footer = element(main, "footer", { className: "home-footer" });
const footerSymbol = element(footer, "img");
footerSymbol.src = "icons/symbole-couleur.svg";
footerSymbol.alt = "";
element(footer, "span", { text: HOME_TEXTS.footer });
return {
render(state) {
const { model } = state;
welcome.hidden = !model.welcome;
main.hidden = model.welcome;
if (model.welcome) {
drawBoard(welcomeBoard, OPENING_BOARDS, welcomeBoard.clientWidth || 360);
return;
}
const sides = state.sides ?? OPENING_BOARDS;
drawBoard(mini, sides, miniBox.clientWidth || 132);
gameOverline.textContent = model.resume
? HOME_TEXTS.gameOverline
: HOME_TEXTS.noGameOverline;
pips.textContent = HOME_TEXTS.homePips(sides.player.pipCount, sides.computer.pipCount);
// Une seule action principale : « Reprendre la partie », ou
// « Nouvelle partie » quand il n'y a rien à reprendre.
resume.hidden = !model.resume;
fresh.textContent = TEXTS.newGame;
fresh.classList.toggle("secondary", model.resume);
fresh.disabled = state.busy;
install.hidden = !model.installCard;
const seen = state.tutorialSeen;
rules.href = seen > 0 && seen < TUTORIAL_DONE ? `#/tutoriel/${seen}` : "#/tutoriel/1";
rulesAction.textContent =
seen === 0
? HOME_TEXTS.rulesStart
: seen >= TUTORIAL_DONE
? HOME_TEXTS.rulesAgain
: HOME_TEXTS.rulesContinue(seen);
progress.textContent =
state.progress === null || state.progress.played === 0
? ""
: HOME_TEXTS.progress(state.progress.played, state.progress.won);
},
};
}
+3
View File
@@ -22,6 +22,9 @@ export const ICON_PATHS = {
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",
home: "M4 11l8-7 8 7M6 9.5V20h12V9.5M10 20v-6h4v6",
rules: "M4 5h6a2 2 0 0 1 2 2v13a2 2 0 0 0-2-2H4zM20 5h-6a2 2 0 0 0-2 2v13a2 2 0 0 1 2-2h6z",
install: "M12 4v11M7 10l5 5 5-5M5 20h14",
verdictGood: "M12 21a9 9 0 1 0 0-18a9 9 0 0 0 0 18zM8 12.5l3 3 5-6",
verdictDoubtful:
"M12 21a9 9 0 1 0 0-18a9 9 0 0 0 0 18zM9.5 9.3a2.6 2.6 0 1 1 3.6 2.4c-.7.3-1.1.9-1.1 1.6v.6M12 17.2v.1",
+82
View File
@@ -0,0 +1,82 @@
// L'installation de l'application (docs/identite/charte/accueil-tutoriel-
// aide.md, « Installation ») : l'invite du système quand le navigateur en
// donne une (Chrome, événement beforeinstallprompt), sinon une marche à
// suivre en trois étapes (Firefox, iPhone). Plus rien une fois
// l'application installée.
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
/** La marche à suivre à montrer quand le navigateur n'a pas d'invite. */
export type InstallPlatform = "ios" | "firefox";
/** D'après le navigateur : l'iPhone (et l'iPad) a sa propre marche à
* suivre ; les autres suivent celle de Firefox (menu ⋮, « Installer »). */
export function installPlatform(userAgent: string): InstallPlatform {
return /iPhone|iPad|iPod/.test(userAgent) ? "ios" : "firefox";
}
/** L'événement beforeinstallprompt de Chrome (absent des types du DOM). */
interface InstallPromptEvent extends Event {
readonly prompt: () => Promise<void>;
readonly userChoice: Promise<{ outcome: "accepted" | "dismissed" }>;
}
export interface Installer {
/** L'application tourne installée, ou vient de l'être. */
readonly installed: boolean;
/** Le navigateur a donné son invite d'installation. */
readonly canPrompt: boolean;
/** Ouvre l'invite du système ; « unavailable » s'il n'y en a pas. */
offer(): Promise<"accepted" | "dismissed" | "unavailable">;
}
/**
* Écoute le navigateur : garde l'invite reçue avec beforeinstallprompt (et
* empêche le navigateur de l'afficher de lui-même), et note l'installation
* (display-mode: standalone, événement appinstalled). onChange est appelé à
* chaque changement.
*/
export function startInstaller(onChange: (installedNow: boolean) => void): Installer {
const standalone = window.matchMedia("(display-mode: standalone)");
let installedNow = false;
let deferred: InstallPromptEvent | null = null;
window.addEventListener("beforeinstallprompt", (event) => {
event.preventDefault();
deferred = event as InstallPromptEvent;
onChange(false);
});
window.addEventListener("appinstalled", () => {
installedNow = true;
deferred = null;
onChange(true);
});
standalone.addEventListener("change", () => onChange(false));
return {
get installed() {
return installedNow || standalone.matches;
},
get canPrompt() {
return deferred !== null;
},
async offer() {
const event = deferred;
if (event === null) {
return "unavailable";
}
// Une invite ne sert qu'une fois.
deferred = null;
try {
await event.prompt();
const choice = await event.userChoice;
return choice.outcome;
} catch {
return "unavailable";
} finally {
onChange(false);
}
},
};
}
+38 -11
View File
@@ -10,7 +10,7 @@
export type Route =
/** #/ : la partie en cours. */
| { readonly screen: "game" }
/** #/accueil (écran à venir, étape 0e.5). */
/** #/accueil. */
| { readonly screen: "home" }
/** #/aide, et une question ouverte : #/aide/<identifiant>. */
| { readonly screen: "help"; readonly question: string | null }
@@ -18,8 +18,13 @@ export type Route =
| { 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 }
/** #/tutoriel, une étape : #/tutoriel/<n>, et son écran « à vous » :
* #/tutoriel/<n>/a-vous. */
| { readonly screen: "tutorial"; readonly step: number | null; readonly practice: boolean }
/** #/tutoriel/fin : la fin du tutoriel. */
| { readonly screen: "tutorialEnd" }
/** #/installer : la marche à suivre pour installer l'application. */
| { readonly screen: "install" }
/** #/parties, et une partie : #/parties/<identifiant>. */
| { readonly screen: "games"; readonly game: string | null }
/** #/menu. */
@@ -58,6 +63,9 @@ export function parseRoute(hash: string): Route {
if (head === "position") {
return positionRoute(rest.join("/")) ?? unknown;
}
if (head === "tutoriel") {
return tutorialRoute(rest) ?? unknown;
}
if (rest.length > 1) {
return unknown;
}
@@ -80,18 +88,31 @@ export function parseRoute(hash: string): Route {
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;
}
case "installer":
return name === null ? { screen: "install" } : unknown;
default:
return unknown;
}
}
/** #/tutoriel, #/tutoriel/<n>, #/tutoriel/<n>/a-vous, #/tutoriel/fin. */
function tutorialRoute(rest: readonly string[]): Route | null {
const [name, part, ...more] = rest;
if (name === undefined) {
return { screen: "tutorial", step: null, practice: false };
}
if (more.length > 0) {
return null;
}
if (name === "fin") {
return part === undefined ? { screen: "tutorialEnd" } : null;
}
if (!/^[1-9][0-9]?$/.test(name) || (part !== undefined && part !== "a-vous")) {
return null;
}
return { screen: "tutorial", step: Number(name), practice: part === "a-vous" };
}
/**
* #/position/... : le Position ID (14 caractères), puis, après une barre, le
* Match ID. Chaque identifiant est encodé avec encodeURIComponent, mais une
@@ -134,7 +155,13 @@ export function routeHash(route: Route): string {
case "about":
return "#/a-propos";
case "tutorial":
return route.step === null ? "#/tutoriel" : `#/tutoriel/${route.step}`;
return route.step === null
? "#/tutoriel"
: `#/tutoriel/${route.step}${route.practice ? "/a-vous" : ""}`;
case "tutorialEnd":
return "#/tutoriel/fin";
case "install":
return "#/installer";
case "games":
return route.game === null ? "#/parties" : `#/parties/${route.game}`;
case "menu":
+87 -4
View File
@@ -3,6 +3,10 @@
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
//
// Format durable (ADR 0007, amendement du 2026-11-03) : un champ ajouté a
// toujours une valeur par défaut, et un réglage enregistré par une version
// précédente se relit sans perte.
export type Theme = "auto" | "light" | "dark";
@@ -15,6 +19,21 @@ export interface Settings {
/** Parties commencées : la consigne est longue pendant les trois
* premières, courte ensuite. */
readonly gamesStarted: number;
/** Parties terminées (l'invitation à installer vient après la
* première). */
readonly gamesFinished: number;
/** Le choix du premier lancement (« Je découvre », « Je connais les
* règles ») a été fait : il ne revient plus. */
readonly welcomed: boolean;
/** La dernière étape du tutoriel déjà vue (0 : aucune ; TUTORIAL_DONE :
* la fin). La barre de progression ne mène qu'aux étapes vues. */
readonly tutorialSeen: number;
/** Combien de fois « Plus tard » a masqué l'invitation à installer, et
* le nombre de parties terminées à la dernière fois. */
readonly installDismissals: number;
readonly installDismissedAt: number;
/** L'application a été installée (événement appinstalled). */
readonly installed: boolean;
}
export const DEFAULT_SETTINGS: Settings = {
@@ -22,11 +41,24 @@ export const DEFAULT_SETTINGS: Settings = {
keepScreenOn: true,
theme: "auto",
gamesStarted: 0,
gamesFinished: 0,
welcomed: false,
tutorialSeen: 0,
installDismissals: 0,
installDismissedAt: 0,
installed: false,
};
/** Nombre de parties pendant lesquelles la consigne garde sa forme longue. */
export const LONG_PROMPT_GAMES = 3;
/** tutorialSeen quand la fin du tutoriel a été vue. */
export const TUTORIAL_DONE = 99;
/** Parties terminées de plus avant que l'invitation à installer, masquée
* par « Plus tard », revienne (une seule fois). */
export const INSTALL_AGAIN_AFTER = 3;
/** Les réglages lus (JSON), complétés par les valeurs par défaut ; une
* valeur illisible garde sa valeur par défaut. */
export function parseSettings(text: string | null): Settings {
@@ -39,16 +71,33 @@ export function parseSettings(text: string | null): Settings {
} catch {
// Réglages illisibles : valeurs par défaut.
}
const bool = (key: "vibrations" | "keepScreenOn") =>
const bool = (key: "vibrations" | "keepScreenOn" | "welcomed" | "installed") =>
typeof raw[key] === "boolean" ? (raw[key] as boolean) : DEFAULT_SETTINGS[key];
const count = (
key:
| "gamesStarted"
| "gamesFinished"
| "tutorialSeen"
| "installDismissals"
| "installDismissedAt",
) => {
const value = raw[key];
return typeof value === "number" && Number.isInteger(value) && value >= 0
? value
: DEFAULT_SETTINGS[key];
};
const theme = raw.theme === "light" || raw.theme === "dark" ? raw.theme : "auto";
const games = raw.gamesStarted;
return {
vibrations: bool("vibrations"),
keepScreenOn: bool("keepScreenOn"),
theme,
gamesStarted:
typeof games === "number" && Number.isInteger(games) && games >= 0 ? games : 0,
gamesStarted: count("gamesStarted"),
gamesFinished: count("gamesFinished"),
welcomed: bool("welcomed"),
tutorialSeen: count("tutorialSeen"),
installDismissals: count("installDismissals"),
installDismissedAt: count("installDismissedAt"),
installed: bool("installed"),
};
}
@@ -61,3 +110,37 @@ export function nextTheme(theme: Theme): Theme {
export function longPrompts(settings: Settings): boolean {
return settings.gamesStarted <= LONG_PROMPT_GAMES;
}
/**
* Premier lancement : rien n'a encore été fait dans l'application (le choix
* d'accueil, une partie terminée, une étape du tutoriel). La partie
* commencée d'office au chargement compte pour une : jusqu'à une partie
* commencée, c'est encore le premier lancement.
*/
export function isFirstLaunch(settings: Settings): boolean {
return (
!settings.welcomed &&
settings.gamesStarted <= 1 &&
settings.gamesFinished === 0 &&
settings.tutorialSeen === 0
);
}
/**
* L'accueil montre-t-il l'invitation à installer ? Jamais une fois
* l'application installée ; à partir de la première partie terminée ;
* « Plus tard » la masque, et elle ne revient qu'une fois, après
* INSTALL_AGAIN_AFTER parties terminées de plus.
*/
export function installCardShown(settings: Settings, installed: boolean): boolean {
if (installed || settings.installed || settings.gamesFinished < 1) {
return false;
}
if (settings.installDismissals === 0) {
return true;
}
return (
settings.installDismissals === 1 &&
settings.gamesFinished >= settings.installDismissedAt + INSTALL_AGAIN_AFTER
);
}
-1
View File
@@ -164,7 +164,6 @@ export const TEXTS = {
// 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",
+96
View File
@@ -0,0 +1,96 @@
// Le parcours du tutoriel : l'écran d'avant, l'écran d'après, et la barre
// de progression (charte : « les segments sont des liens vers les étapes
// déjà vues, pas vers les suivantes »). Fonctions pures, testées sous Node.
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
import type { Route } from "./router.js";
import { TUTORIAL_DONE } from "./settings.js";
import type { Tutorial } from "./tutorial-text.js";
/** Un écran du tutoriel : une étape (explication ou « à vous »), ou la fin. */
export type TutorialPlace = { readonly step: number; readonly practice: boolean } | "end";
/** L'écran que désigne une adresse, ramené à un écran qui existe : une
* étape hors du tutoriel mène à la première, un « à vous » absent à
* l'explication de son étape. */
export function placeOf(tutorial: Tutorial, route: Route): TutorialPlace {
if (route.screen === "tutorialEnd") {
return "end";
}
if (route.screen !== "tutorial" || route.step === null) {
return { step: 1, practice: false };
}
const step = tutorial.steps[route.step - 1];
if (step === undefined) {
return { step: 1, practice: false };
}
return { step: step.number, practice: route.practice && step.practice !== null };
}
/** L'adresse d'un écran du tutoriel. */
export function placeRoute(place: TutorialPlace): Route {
return place === "end"
? { screen: "tutorialEnd" }
: { screen: "tutorial", step: place.step, practice: place.practice };
}
/** L'écran suivant (la fin après la dernière étape). */
export function nextPlace(tutorial: Tutorial, place: TutorialPlace): TutorialPlace {
if (place === "end") {
return "end";
}
const step = tutorial.steps[place.step - 1];
if (!place.practice && step?.practice != null) {
return { step: place.step, practice: true };
}
return place.step >= tutorial.steps.length ? "end" : { step: place.step + 1, practice: false };
}
/** L'écran précédent (null avant la première étape). */
export function previousPlace(tutorial: Tutorial, place: TutorialPlace): TutorialPlace | null {
if (place === "end") {
const last = tutorial.steps.at(-1);
return last === undefined ? null : { step: last.number, practice: last.practice !== null };
}
if (place.practice) {
return { step: place.step, practice: false };
}
const before = tutorial.steps[place.step - 2];
return before === undefined
? null
: { step: before.number, practice: before.practice !== null };
}
/** Ce que retient la progression quand on arrive sur un écran : la
* dernière étape vue (TUTORIAL_DONE à la fin). */
export function seenAfter(seen: number, place: TutorialPlace): number {
return place === "end" ? TUTORIAL_DONE : Math.max(seen, place.step);
}
/** Un segment de la barre de progression. */
export interface ProgressSegment {
readonly step: number;
/** Étape faite ou en cours : le segment est en brique. */
readonly done: boolean;
/** Étape déjà vue : le segment est un lien vers elle. */
readonly link: boolean;
readonly current: boolean;
}
/** La barre de progression, sur l'écran place, quand les étapes jusqu'à
* seen ont déjà été vues. */
export function progress(total: number, place: TutorialPlace, seen: number): ProgressSegment[] {
const current = place === "end" ? total + 1 : place.step;
const reached = Math.max(seen, current);
return Array.from({ length: total }, (_, i) => {
const step = i + 1;
return {
step,
done: step <= current,
link: step <= reached && step !== current,
current: step === current,
};
});
}
+204
View File
@@ -0,0 +1,204 @@
// Lecture du texte du tutoriel (docs/tutoriel/regles.md, format décrit dans
// docs/tutoriel/README.md), copié tel quel dans le site par make site
// (ADR 0008 : une seule source). Fonction pure, testée sous Node.
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
/** Un morceau de texte : simple, en gras, ou lien vers une fiche du
* glossaire (nom de fichier sans .md). */
export interface Inline {
readonly text: string;
readonly strong?: boolean;
readonly term?: string;
}
export type Paragraph = readonly Inline[];
/** Un écran du tutoriel : explication, ou « à vous ». */
export interface TutorialScreen {
/** Identifiant de position de gnubg, vu du camp au trait. */
readonly position: string;
readonly onRoll: "player" | "computer";
/** Le jet du coup illustré (explication) ou à jouer (« à vous »). */
readonly roll: readonly [number, number] | null;
/** Explication : le coup illustré, en notation de gnubg. */
readonly move: string | null;
/** Explication : le sens du jeu dessiné en pointillés. */
readonly path: boolean;
/** « À vous » : les coups justes, en notation de gnubg. */
readonly expected: readonly string[];
/** « À vous » de videau : la bonne réponse au double. */
readonly answer: "take" | "pass" | null;
readonly right: Paragraph;
readonly wrong: Paragraph;
readonly paragraphs: readonly Paragraph[];
}
export interface TutorialStep {
readonly number: number;
readonly title: string;
readonly explain: TutorialScreen;
readonly practice: TutorialScreen | null;
}
export interface Tutorial {
readonly steps: readonly TutorialStep[];
readonly end: { readonly action: string; readonly paragraphs: readonly Paragraph[] };
}
/** Lit les liens [mot](fiche.md) et le **gras** d'une ligne de texte. */
export function parseInline(text: string): Paragraph {
const parts: Inline[] = [];
const pattern = /\[([^\]]+)\]\(([a-z0-9-]+)\.md\)|\*\*([^*]+)\*\*/g;
let last = 0;
for (const match of text.matchAll(pattern)) {
const index = match.index ?? 0;
if (index > last) {
parts.push({ text: text.slice(last, index) });
}
if (match[1] !== undefined && match[2] !== undefined) {
parts.push({ text: match[1], term: match[2] });
} else {
parts.push({ text: match[3] ?? "", strong: true });
}
last = index + match[0].length;
}
if (last < text.length) {
parts.push({ text: text.slice(last) });
}
return parts;
}
interface RawScreen {
keys: Map<string, string[]>;
paragraphs: string[];
}
function emptyScreen(): RawScreen {
return { keys: new Map(), paragraphs: [] };
}
function rollOf(text: string | undefined, where: string): readonly [number, number] | null {
if (text === undefined) {
return null;
}
const match = /^([1-6])-([1-6])$/.exec(text);
if (match === null) {
throw new Error(`${where} : jet illisible « ${text} »`);
}
return [Number(match[1]), Number(match[2])];
}
function screenOf(raw: RawScreen, where: string): TutorialScreen {
const one = (key: string) => raw.keys.get(key)?.[0];
const position = one("position");
if (position === undefined) {
throw new Error(`${where} : position absente`);
}
const trait = one("trait");
if (trait !== undefined && trait !== "ordinateur" && trait !== "joueur") {
throw new Error(`${where} : trait inconnu « ${trait} »`);
}
const answer = one("réponse");
if (answer !== undefined && answer !== "prendre" && answer !== "passer") {
throw new Error(`${where} : réponse inconnue « ${answer} »`);
}
return {
position,
onRoll: trait === "ordinateur" ? "computer" : "player",
roll: rollOf(one("jet"), where),
move: one("coup") ?? null,
path: one("chemin") === "oui",
expected: raw.keys.get("attendu") ?? [],
answer: answer === undefined ? null : answer === "prendre" ? "take" : "pass",
right: parseInline(one("juste") ?? ""),
wrong: parseInline(one("refus") ?? ""),
paragraphs: raw.paragraphs.map(parseInline),
};
}
/** Lit le texte du tutoriel. Erreur si un écran est incomplet ou si les
* étapes ne se suivent pas. */
export function parseTutorial(text: string): Tutorial {
const steps: {
number: number;
title: string;
explain: RawScreen;
practice: RawScreen | null;
}[] = [];
let end: RawScreen | null = null;
let current: RawScreen | null = null;
let paragraph: string[] = [];
const endParagraph = () => {
if (paragraph.length > 0 && current !== null) {
current.paragraphs.push(paragraph.join(" "));
}
paragraph = [];
};
for (const raw of text.split("\n")) {
const line = raw.trim();
const step = /^## (\d+)\. (.+)$/.exec(line);
const key = /^- ([a-zéè]+): (.+)$/.exec(line);
if (line === "") {
endParagraph();
} else if (step !== null) {
endParagraph();
current = emptyScreen();
steps.push({
number: Number(step[1]),
title: step[2] ?? "",
explain: current,
practice: null,
});
} else if (line === "## Fin") {
endParagraph();
current = emptyScreen();
end = current;
} else if (line === "### À vous") {
endParagraph();
const last = steps.at(-1);
if (last === undefined) {
throw new Error("« À vous » hors d'une étape");
}
current = emptyScreen();
last.practice = current;
} else if (current === null) {
// Notice avant la première étape : non affichée.
} else if (key !== null && paragraph.length === 0 && current.paragraphs.length === 0) {
const values = current.keys.get(key[1] ?? "") ?? [];
values.push((key[2] ?? "").trim());
current.keys.set(key[1] ?? "", values);
} else {
paragraph.push(line);
}
}
endParagraph();
steps.forEach((step, i) => {
if (step.number !== i + 1) {
throw new Error(`étape ${step.number} à la place de l'étape ${i + 1}`);
}
});
if (end === null) {
throw new Error("écran de fin absent");
}
const finish: RawScreen = end;
return {
steps: steps.map((step) => ({
number: step.number,
title: step.title,
explain: screenOf(step.explain, `étape ${step.number}`),
practice:
step.practice === null
? null
: screenOf(step.practice, `étape ${step.number}, à vous`),
})),
end: {
action: finish.keys.get("action")?.[0] ?? "",
paragraphs: finish.paragraphs.map(parseInline),
},
};
}
+734
View File
@@ -0,0 +1,734 @@
// L'écran du tutoriel (#/tutoriel/<n>, #/tutoriel/<n>/a-vous,
// #/tutoriel/fin) : la barre de progression, le titre, le plateau, le texte,
// et les écrans « à vous » où le joueur fait le geste sur le vrai plateau
// (docs/identite/charte/accueil-tutoriel-aide.md, « Tutoriel des règles »).
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
//
// Le texte vient de docs/tutoriel/regles.md (copie du site) ; les plateaux,
// les coups illustrés et le jugement des gestes viennent du domaine, branché
// sur le moteur (TutorialAccess, assemblé dans main.ts). Aucune règle n'est
// écrite ici.
import type { MoveSteps } from "../domain/game/game.js";
import type { HalfMove } from "../domain/game/position.js";
import type { Roll } from "../domain/game/roll.js";
import type { Side } from "../domain/game/side.js";
import type { PracticeVerdict, TutorialBoard } from "../domain/learning/tutorial.js";
import {
boardModel,
moveTargets,
moveTrace,
onlyHalfMove,
type Place,
placeOf as placeOfChecker,
} from "./board-model.js";
import {
type BoardDecor,
checkerSpot,
NO_MARKS,
renderBoard,
type ShownDie,
slideChecker,
} from "./board-view.js";
import { boardGeometry, columnLeft, type Geometry, PORTRAIT_ASPECT } from "./geometry.js";
import { HOME_TEXTS } from "./home-texts.js";
import { icon } from "./icons.js";
import { type Route, routeHash } from "./router.js";
import {
nextPlace,
placeOf,
placeRoute,
previousPlace,
progress,
type TutorialPlace,
} from "./tutorial-nav.js";
import {
type Paragraph,
parseTutorial,
type Tutorial,
type TutorialScreen,
} from "./tutorial-text.js";
/** Le coup illustré se joue tout seul après une seconde. */
const AUTOPLAY_MS = 1000;
const SLIDE_MS = 160;
const PAUSE_MS = 120;
/** Un pion refusé reste un instant à sa place avant de revenir. */
const REFUSED_MS = 450;
const SOURCE = "docs/tutoriel/regles.md";
/** Un écran « à vous » où l'on joue un coup (MovePractice du domaine). */
export interface PracticeSession {
board(): Promise<{
player: TutorialBoard["sides"]["player"];
computer: TutorialBoard["sides"]["computer"];
}>;
nextHalfMoves(): Promise<HalfMove[]>;
play(halfMove: HalfMove): Promise<PracticeVerdict>;
reset(): void;
readonly diceToPlay: readonly { value: number; used: boolean }[];
}
/** Ce que le tutoriel demande au domaine et au moteur. */
export interface TutorialAccess {
board(position: string, onRoll: Side): Promise<TutorialBoard>;
illustrate(
position: string,
roll: Roll,
move: string,
): Promise<{ steps: MoveSteps; after: TutorialBoard }>;
practice(position: string, roll: Roll, expected: readonly string[]): Promise<PracticeSession>;
shouldTake(position: string): Promise<boolean>;
}
export interface TutorialViewOptions {
readonly section: HTMLElement;
readonly access: TutorialAccess;
readonly engineReady: Promise<unknown>;
/** La dernière étape vue, et son enregistrement à l'arrivée sur un
* écran. */
readonly seen: () => number;
readonly visit: (place: TutorialPlace) => void;
/** Un geste juste : la vibration « pion posé ». */
readonly placed: () => void;
readonly reducedMotion: MediaQueryList;
/** L'écran est-il en paysage (plateau à gauche, texte à droite) ? */
readonly landscape: () => boolean;
/** L'adresse a désigné un écran qui n'existe pas : la remplacer. */
readonly replace: (hash: string) => void;
}
export interface TutorialView {
show(route: Route): Promise<void>;
/** La taille de l'écran a changé : redessiner le plateau. */
relayout(): void;
/** L'écran est quitté : arrêter le coup illustré. */
hide(): void;
}
function element<K extends keyof HTMLElementTagNameMap>(
parent: HTMLElement,
tag: K,
options: { id?: string; className?: string; text?: string } = {},
): HTMLElementTagNameMap[K] {
const child = document.createElement(tag);
if (options.id !== undefined) {
child.id = options.id;
}
if (options.className !== undefined) {
child.className = options.className;
}
if (options.text !== undefined) {
child.textContent = options.text;
}
parent.append(child);
return child;
}
/** Un paragraphe du tutoriel : texte, gras, liens vers le glossaire. */
export function fillParagraph(target: HTMLElement, paragraph: Paragraph): void {
target.replaceChildren();
for (const part of paragraph) {
if (part.term !== undefined) {
const link = document.createElement("a");
link.href = routeHash({ screen: "glossary", term: part.term });
link.textContent = part.text;
target.append(link);
} else if (part.strong === true) {
const strong = document.createElement("strong");
strong.textContent = part.text;
target.append(strong);
} else {
target.append(part.text);
}
}
}
const SVG_NS = "http://www.w3.org/2000/svg";
/** Le sens du jeu des pions du joueur, en pointillés : du haut à droite
* (flèche 24) vers la gauche, puis en bas jusqu'à la flèche 1 et la sortie.
* Tracé sous les pions et sous les numéros des flèches, qu'il ne cache
* jamais. pxPerUnit : pixels à l'écran par unité du dessin (le liseré
* ivoire fait 8 px, les points 4 px, quelle que soit la taille). */
function drawPath(svg: SVGSVGElement, g: Geometry, pxPerUnit: number): void {
const center = (column: number) => columnLeft(g, column) + g.pointWidth / 2;
const top = g.height * 0.3;
const bottom = g.height * 0.7;
const right = center(11);
const left = center(0) - g.pointWidth * 0.25;
const turn = g.pointWidth * 0.6;
const end = g.trayLeft + g.tray * 0.25;
const d =
`M ${right} ${top} L ${left + turn} ${top} Q ${left} ${top} ${left} ${top + turn} ` +
`L ${left} ${bottom - turn} Q ${left} ${bottom} ${left + turn} ${bottom} L ${end} ${bottom}`;
const group = document.createElementNS(SVG_NS, "g");
group.setAttribute("class", "tutorial-path");
group.setAttribute("role", "img");
group.setAttribute("aria-label", HOME_TEXTS.pathLabel);
const px = (n: number) => n / pxPerUnit;
const width = px(4);
for (const [className, stroke] of [
["path-edge", px(8)],
["path-dots", width],
] as const) {
const path = document.createElementNS(SVG_NS, "path");
path.setAttribute("class", className);
path.setAttribute("d", d);
path.setAttribute("stroke-width", String(stroke));
if (className === "path-dots") {
path.setAttribute("stroke-dasharray", `0.1 ${px(10)}`);
}
group.append(path);
}
const head = document.createElementNS(SVG_NS, "path");
head.setAttribute("class", "path-head");
const size = px(7);
head.setAttribute(
"d",
`M ${end - size} ${bottom - size} L ${end} ${bottom} L ${end - size} ${bottom + size}`,
);
head.setAttribute("stroke-width", String(px(3)));
group.append(head);
// Sous les pions, et sous les numéros des flèches, remontés au-dessus
// du tracé ; les zones à toucher restent devant tout.
const pieces = svg.querySelector(":scope > g.pieces");
svg.insertBefore(group, pieces);
for (const number of svg.querySelectorAll(":scope > .point-number")) {
svg.insertBefore(number, pieces);
}
}
/** Crée l'écran du tutoriel dans section (vide dans index.html). */
export function createTutorialView(options: TutorialViewOptions): TutorialView {
const { section, access } = options;
section.replaceChildren();
const head = element(section, "header", { className: "tutorial-head" });
const bar = element(head, "nav", { id: "tutorial-progress", className: "tutorial-progress" });
bar.setAttribute("aria-label", HOME_TEXTS.tutorialProgress);
const skip = element(head, "a", { id: "tutorial-skip", className: "tutorial-skip" });
skip.href = "#/";
skip.textContent = HOME_TEXTS.tutorialSkip;
skip.setAttribute("aria-label", HOME_TEXTS.tutorialSkipLabel);
const overline = element(section, "p", { id: "tutorial-overline", className: "overline" });
const title = element(section, "h2", { id: "tutorial-title", className: "tutorial-title" });
const boardBox = element(section, "div", { className: "tutorial-board-box" });
const svg = document.createElementNS(SVG_NS, "svg");
svg.id = "tutorial-board";
svg.setAttribute("class", "board tutorial-board");
svg.setAttribute("role", "img");
boardBox.append(svg);
const replay = element(boardBox, "button", { id: "tutorial-replay", className: "icon-button" });
replay.type = "button";
replay.append(icon("replay"));
replay.setAttribute("aria-label", HOME_TEXTS.replayLabel);
replay.title = HOME_TEXTS.replayLabel;
// Le texte, la consigne « à vous » et la réponse : en paysage, la seule
// partie qui défile si l'écran est trop bas (app.css).
const body = element(section, "div", { className: "tutorial-body" });
const text = element(body, "div", { id: "tutorial-text", className: "tutorial-text" });
const task = element(body, "p", { id: "tutorial-task", className: "tutorial-task" });
const feedback = element(body, "p", {
id: "tutorial-feedback",
className: "tutorial-feedback",
});
feedback.setAttribute("role", "status");
const cube = element(section, "div", { id: "tutorial-cube", className: "tutorial-cube" });
const drop = element(cube, "button", {
id: "tutorial-drop",
className: "secondary",
text: HOME_TEXTS.drop,
});
drop.type = "button";
const take = element(cube, "button", {
id: "tutorial-take",
className: "secondary",
text: HOME_TEXTS.take,
});
take.type = "button";
const nav = element(section, "div", { className: "tutorial-nav" });
const back = element(nav, "a", {
id: "tutorial-back",
className: "button secondary",
text: HOME_TEXTS.back,
});
const next = element(nav, "a", {
id: "tutorial-next",
className: "button",
text: HOME_TEXTS.next,
});
let tutorial: Promise<Tutorial> | null = null;
// Chaque affichage a son numéro : un affichage dépassé ne touche plus
// à l'écran (le joueur est déjà ailleurs).
let shown = 0;
let autoplay: ReturnType<typeof setTimeout> | null = null;
// Un coup illustré se joue, ou un geste est jugé : les touchers
// attendent (data-busy, pour les tests dans un navigateur).
let busy = false;
const setBusy = (value: boolean) => {
busy = value;
section.toggleAttribute("data-busy", value);
};
// L'écran affiché.
let screen: TutorialScreen | null = null;
let place: TutorialPlace | null = null;
let boards: TutorialBoard | null = null;
let illustration: { steps: MoveSteps; after: TutorialBoard } | null = null;
let illustrated = false;
let practice: PracticeSession | null = null;
let targets = new Map<Place, Map<Place, HalfMove>>();
let selected: Place | null = null;
let solved = false;
const load = () => {
tutorial ??= fetch(SOURCE)
.then((response) => {
if (!response.ok) {
throw new Error(`${SOURCE} : ${response.status}`);
}
return response.text();
})
.then(parseTutorial)
.catch((error: unknown) => {
tutorial = null;
throw error;
});
return tutorial;
};
/** En paysage, le plateau occupe toute la hauteur, à gauche de la
* colonne du texte (app.css), avec la géométrie du jeu en paysage. */
function geometry(): Geometry {
const box = svg.getBoundingClientRect();
if (options.landscape()) {
const width = box.width || window.innerWidth;
const height = box.height || window.innerHeight;
return boardGeometry("landscape", width / height, height / 520);
}
const width = box.width || Math.min(window.innerWidth, 640);
return boardGeometry("portrait", PORTRAIT_ASPECT, width / 640);
}
function shownDice(): ShownDie[] {
if (practice !== null) {
return practice.diceToPlay.map((die) => ({
value: die.value,
side: "player",
blocked: false,
used: die.used,
}));
}
const roll = screen?.move != null ? screen.roll : null;
if (roll === null || roll === undefined) {
return [];
}
const [a, b] = roll;
return [a, b].map((value) => ({
value,
side: "player" as const,
blocked: false,
used: false,
}));
}
/** Redessine le plateau dans son état courant. */
function draw(sides: TutorialBoard["sides"] | null = null): void {
const current = sides ?? (illustrated ? illustration?.after.sides : null) ?? boards?.sides;
if (current === undefined || current === null) {
return;
}
const g = geometry();
const decor: BoardDecor = {
...NO_MARKS,
dice: shownDice(),
cube: { value: 1, owner: null },
band: null,
controls: null,
movable: practice !== null && !solved && !busy ? new Set(targets.keys()) : new Set(),
selected,
targets: new Set(selected === null ? [] : (targets.get(selected)?.keys() ?? [])),
trace:
illustrated && illustration !== null
? moveTrace("player", illustration.steps.halfMoves, illustration.steps.hits)
: null,
};
renderBoard(svg, g, boardModel(current), decor);
if (screen?.path === true) {
drawPath(svg, g, (svg.getBoundingClientRect().width || g.width) / g.width);
}
}
function clearTimer(): void {
if (autoplay !== null) {
clearTimeout(autoplay);
autoplay = null;
}
}
/** Le coup illustré, pion par pion (160 ms par pion, 120 ms entre deux),
* puis ses traces ; sans mouvement si le système le demande. */
async function playIllustration(id: number): Promise<void> {
const shownMove = illustration;
if (shownMove === null || id !== shown) {
return;
}
setBusy(true);
replay.disabled = true;
illustrated = false;
try {
if (!options.reducedMotion.matches) {
const { steps } = shownMove;
for (const [i, halfMove] of steps.halfMoves.entries()) {
if (id !== shown) {
return;
}
if (i > 0) {
await new Promise((resolve) => setTimeout(resolve, PAUSE_MS));
}
const from = placeOfChecker("player", halfMove.from);
const to = placeOfChecker("player", halfMove.to);
draw(steps.boards[i] ?? null);
const start = checkerSpot(svg, from);
const victim = steps.hits[i] === true ? checkerSpot(svg, to) : null;
draw(steps.boards[i + 1] ?? null);
await Promise.all([
start && slideChecker(svg, to, start, SLIDE_MS),
victim && slideChecker(svg, "bar-computer", victim, SLIDE_MS),
]);
}
}
} finally {
if (id === shown) {
setBusy(false);
illustrated = true;
replay.disabled = false;
draw();
const result = shownMove.after.result;
feedback.textContent =
result === null ? "" : HOME_TEXTS.illustratedEnd(result.kind);
feedback.dataset.verdict = result === null ? "" : "info";
}
}
}
async function refreshTargets(): Promise<void> {
targets =
practice === null || solved ? new Map() : moveTargets(await practice.nextHalfMoves());
if (selected !== null && !targets.has(selected)) {
selected = null;
}
}
function setVerdict(verdict: "right" | "wrong", words: Paragraph): void {
feedback.dataset.verdict = verdict;
fillParagraph(feedback, words);
if (verdict === "right") {
feedback.prepend(icon("verdictGood"));
}
}
function markSolved(): void {
solved = true;
section.toggleAttribute("data-solved", true);
next.classList.remove("secondary");
take.disabled = true;
drop.disabled = true;
options.placed();
}
async function playHalfMove(halfMove: HalfMove): Promise<void> {
const session = practice;
const current = screen;
if (session === null || current === null || busy || solved) {
return;
}
const id = shown;
setBusy(true);
selected = null;
try {
const verdict = await session.play(halfMove);
if (id !== shown) {
return;
}
draw(await session.board());
if (verdict === "right") {
markSolved();
setVerdict("right", current.right);
} else if (verdict === "wrong") {
// Le pion revient, et une phrase dit pourquoi.
await new Promise((resolve) => setTimeout(resolve, REFUSED_MS));
if (id !== shown) {
return;
}
session.reset();
setVerdict("wrong", current.wrong);
} else {
feedback.textContent = "";
feedback.dataset.verdict = "";
}
await refreshTargets();
} finally {
if (id === shown) {
setBusy(false);
draw(practice === null ? null : await practice.board());
}
}
}
svg.addEventListener("click", (event) => {
if (practice === null || busy || solved) {
return;
}
const target = event.target as Element | null;
const placeName = target
?.closest("[data-place]")
?.getAttribute("data-place") as Place | null;
if (placeName == null) {
return;
}
const halfMove = selected === null ? undefined : targets.get(selected)?.get(placeName);
if (halfMove !== undefined) {
void playHalfMove(halfMove);
return;
}
const only = placeName === selected ? null : onlyHalfMove(targets, placeName);
if (only !== null) {
void playHalfMove(only);
return;
}
selected = targets.has(placeName) && placeName !== selected ? placeName : null;
void practice.board().then((sides) => draw(sides));
});
replay.addEventListener("click", () => {
clearTimer();
void playIllustration(shown);
});
async function answer(takes: boolean): Promise<void> {
const current = screen;
if (current === null || solved || busy) {
return;
}
const id = shown;
setBusy(true);
try {
const right = (await access.shouldTake(current.position)) === takes;
if (id !== shown) {
return;
}
if (right) {
markSolved();
}
setVerdict(right ? "right" : "wrong", right ? current.right : current.wrong);
} finally {
setBusy(false);
}
}
take.addEventListener("click", () => void answer(true));
drop.addEventListener("click", () => void answer(false));
function renderProgress(total: number, at: TutorialPlace, steps: Tutorial["steps"]): void {
bar.replaceChildren();
for (const segment of progress(total, at, options.seen())) {
const stepTitle = steps[segment.step - 1]?.title ?? "";
const label = HOME_TEXTS.tutorialStepLink(segment.step, stepTitle);
const item = segment.link
? document.createElement("a")
: document.createElement("span");
item.className = `segment${segment.done ? " done" : ""}`;
if (item instanceof HTMLAnchorElement) {
item.href = routeHash({ screen: "tutorial", step: segment.step, practice: false });
item.setAttribute("aria-label", label);
} else {
item.setAttribute("aria-hidden", "true");
}
if (segment.current) {
item.setAttribute("aria-current", "step");
}
bar.append(item);
}
bar.setAttribute(
"aria-description",
at === "end"
? HOME_TEXTS.tutorialEndOverline
: HOME_TEXTS.tutorialOverline(at.step, total, at.practice),
);
}
return {
async show(route) {
const id = ++shown;
// Prêt (pour les tests dans un navigateur) une fois ce plateau dessiné.
delete section.dataset.ready;
clearTimer();
setBusy(false);
practice = null;
illustration = null;
illustrated = false;
screen = null;
boards = null;
selected = null;
targets = new Map();
solved = false;
section.toggleAttribute("data-solved", false);
feedback.textContent = "";
feedback.dataset.verdict = "";
task.hidden = true;
cube.hidden = true;
replay.hidden = true;
take.disabled = false;
drop.disabled = false;
let loaded: Tutorial;
try {
if (tutorial === null) {
title.textContent = HOME_TEXTS.tutorialLoading;
text.replaceChildren();
svg.replaceChildren();
}
loaded = await load();
} catch {
if (id === shown) {
title.textContent = HOME_TEXTS.tutorialFailed;
}
return;
}
if (id !== shown) {
return;
}
const at = placeOf(loaded, route);
const wanted = routeHash(placeRoute(at));
if (routeHash(route) !== wanted) {
options.replace(wanted);
return;
}
place = at;
options.visit(at);
const total = loaded.steps.length;
renderProgress(total, at, loaded.steps);
const before = previousPlace(loaded, at);
back.href = before === null ? "#/accueil" : routeHash(placeRoute(before));
if (at === "end") {
overline.textContent = HOME_TEXTS.tutorialEndOverline;
title.textContent = HOME_TEXTS.tutorialEndTitle;
text.replaceChildren();
for (const paragraph of loaded.end.paragraphs) {
fillParagraph(element(text, "p"), paragraph);
}
next.textContent = loaded.end.action;
next.href = "#/";
next.classList.remove("secondary");
screen = loaded.steps[0]?.explain ?? null;
} else {
const step = loaded.steps[at.step - 1];
const current = at.practice ? step?.practice : step?.explain;
if (step === undefined || current == null) {
return;
}
screen = { ...current, path: current.path && !at.practice };
overline.textContent = HOME_TEXTS.tutorialOverline(at.step, total, at.practice);
title.textContent = step.title;
text.replaceChildren();
const paragraphs = at.practice ? [] : current.paragraphs;
for (const paragraph of paragraphs) {
fillParagraph(element(text, "p"), paragraph);
}
if (at.practice) {
task.hidden = false;
task.replaceChildren();
for (const [i, paragraph] of current.paragraphs.entries()) {
if (i > 0) {
task.append(" ");
}
const span = document.createElement("span");
fillParagraph(span, paragraph);
task.append(span);
}
}
const after = nextPlace(loaded, at);
next.textContent = HOME_TEXTS.next;
next.href = routeHash(placeRoute(after));
next.classList.toggle("secondary", at.practice);
}
const currentScreen = screen;
if (currentScreen === null) {
return;
}
// Le plateau : la position, puis le coup illustré ou le geste à
// faire, tous lus par le moteur.
try {
await options.engineReady;
const shownBoard = await access.board(currentScreen.position, currentScreen.onRoll);
if (id !== shown) {
return;
}
boards = shownBoard;
if (
at !== "end" &&
at.practice &&
currentScreen.roll !== null &&
currentScreen.expected.length > 0
) {
practice = await access.practice(
currentScreen.position,
currentScreen.roll,
currentScreen.expected,
);
if (id !== shown) {
return;
}
await refreshTargets();
} else if (at !== "end" && at.practice && currentScreen.answer !== null) {
cube.hidden = false;
feedback.textContent = HOME_TEXTS.computerDoubles;
feedback.dataset.verdict = "info";
} else if (
at !== "end" &&
currentScreen.move !== null &&
currentScreen.roll !== null
) {
illustration = await access.illustrate(
currentScreen.position,
currentScreen.roll,
currentScreen.move,
);
if (id !== shown) {
return;
}
replay.hidden = false;
replay.disabled = true;
autoplay = setTimeout(() => void playIllustration(id), AUTOPLAY_MS);
}
if (id !== shown) {
return;
}
draw(practice === null ? null : await practice.board());
section.dataset.ready = String(id);
} catch (error) {
if (id === shown) {
feedback.textContent = `${HOME_TEXTS.tutorialFailed} ${error instanceof Error ? error.message : ""}`;
}
}
},
relayout() {
if (place === null || busy) {
return;
}
if (practice !== null) {
void practice.board().then((sides) => draw(sides));
} else {
draw();
}
},
hide() {
shown++;
clearTimer();
setBusy(false);
},
};
}
+150
View File
@@ -0,0 +1,150 @@
// La mise à jour de l'application : le service worker (web/sw.js) installe
// chaque nouvelle version publiée dans son propre cache, puis attend
// l'accord du joueur. L'application affiche alors, entre deux tours, le
// bandeau « Nouvelle version prête. » en bas de l'écran ; « Mettre à jour »
// active la nouvelle version et recharge la page, qui reprend la partie
// enregistrée.
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
export interface Updates {
/** Une nouvelle version est installée et attend l'accord du joueur. */
readonly available: boolean;
/** « Rechercher une mise à jour » : demande au navigateur de relire le
* service worker publié. found : une nouvelle version attend (elle a pu
* demander de télécharger tout le site) ; latest : rien de nouveau ;
* unavailable : pas de service worker (http hors localhost, navigateur
* qui le refuse) ou réseau absent. */
check(): Promise<"found" | "latest" | "unavailable">;
/** Active la nouvelle version : la page se recharge dès qu'elle a pris
* la main. */
apply(): void;
}
/** Message au service worker en attente : prendre la main (sw.js). */
export const SKIP_WAITING = "skip-waiting";
/**
* Est-on entre deux tours ? Oui quand la partie attend un lancer de dés
* (aucun pion déplacé) ou qu'elle est finie ; non dès que des dés sont à
* jouer, qu'un double attend sa réponse, ou au jet d'ouverture. Fonction
* pure, testée sous Node.
*/
export function betweenTurns(phase: string, pendingHalfMoves: number): boolean {
return phase === "over" || (phase === "rolling" && pendingHalfMoves === 0);
}
/**
* Le bandeau « Nouvelle version prête. » est-il montré ? Quand une version
* attend, seulement entre deux tours (jamais pendant un coup en cours, ni
* quand l'ordinateur joue), et jamais par-dessus le bandeau « Reprendre
* l'ancienne », qui occupe la même place. Fonction pure, testée sous Node.
*/
export function updateBannerShown(
available: boolean,
betweenTwoTurns: boolean,
otherBanner: boolean,
): boolean {
return available && betweenTwoTurns && !otherBanner;
}
/**
* Enregistre le service worker une fois ready résolue (le moteur chargé :
* voir main.ts), et suit ses mises à jour. onAvailable est appelé quand une
* nouvelle version attend.
*/
export function startUpdates(
script: URL,
ready: Promise<unknown>,
onAvailable: () => void,
): Updates {
const container = "serviceWorker" in navigator ? navigator.serviceWorker : null;
let registration: ServiceWorkerRegistration | null = null;
let waiting: ServiceWorker | null = null;
/** Une version installée attend, et une version plus ancienne a la
* main : c'est une mise à jour (à la toute première installation,
* aucune page n'est encore contrôlée, et rien n'attend). */
const notice = (worker: ServiceWorker | null) => {
if (worker !== null && worker.state === "installed" && container?.controller) {
waiting = worker;
onAvailable();
}
};
/** Suit une version en cours d'installation ; résolue quand elle est
* installée (vrai) ou abandonnée (faux). */
const follow = (worker: ServiceWorker): Promise<boolean> =>
new Promise((resolve) => {
const settle = () => {
if (worker.state === "installed" || worker.state === "activated") {
notice(worker);
resolve(true);
} else if (worker.state === "redundant") {
resolve(false);
}
};
worker.addEventListener("statechange", settle);
settle();
});
if (container !== null) {
ready
.then(() => container.register(script))
.then((reg) => {
registration = reg;
notice(reg.waiting);
if (reg.installing !== null) {
void follow(reg.installing);
}
reg.addEventListener("updatefound", () => {
if (reg.installing !== null) {
void follow(reg.installing);
}
});
})
.catch(() => {
// Refusé (http hors localhost) : l'application marche sans.
});
}
return {
get available() {
return waiting !== null;
},
async check() {
const reg = registration;
if (reg === null) {
return "unavailable";
}
try {
await reg.update();
} catch {
return "unavailable";
}
if (reg.waiting !== null) {
notice(reg.waiting);
return "found";
}
if (reg.installing !== null) {
return (await follow(reg.installing)) ? "found" : "latest";
}
return "latest";
},
apply() {
const worker = waiting ?? registration?.waiting ?? null;
if (container === null || worker === null) {
return;
}
let reloading = false;
container.addEventListener("controllerchange", () => {
if (!reloading) {
reloading = true;
location.reload();
}
});
worker.postMessage({ type: SKIP_WAITING });
},
};
}
+133
View File
@@ -0,0 +1,133 @@
// Tests de l'accueil et de l'invitation à installer, sans navigateur :
// premier lancement, règles d'apparition de la carte d'installation,
// réglages enregistrés par une version précédente.
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { test } from "node:test";
import { installPlatform } from "../src/ui/install.js";
import {
DEFAULT_SETTINGS,
INSTALL_AGAIN_AFTER,
installCardShown,
isFirstLaunch,
parseSettings,
type Settings,
TUTORIAL_DONE,
} from "../src/ui/settings.js";
import { nextPlace, placeOf, previousPlace, progress, seenAfter } from "../src/ui/tutorial-nav.js";
import { parseTutorial } from "../src/ui/tutorial-text.js";
const root = new URL("../../../", import.meta.url);
test("réglages d'une version précédente : les nouveaux champs prennent leur valeur par défaut", () => {
const old = parseSettings('{"vibrations": false, "theme": "dark", "gamesStarted": 4}');
assert.equal(old.welcomed, false);
assert.equal(old.gamesFinished, 0);
assert.equal(old.tutorialSeen, 0);
assert.equal(old.installed, false);
const saved = parseSettings(
'{"welcomed": true, "gamesFinished": 2, "tutorialSeen": 5, "installDismissals": 1, ' +
'"installDismissedAt": 2, "installed": true}',
);
assert.deepEqual(saved, {
...DEFAULT_SETTINGS,
welcomed: true,
gamesFinished: 2,
tutorialSeen: 5,
installDismissals: 1,
installDismissedAt: 2,
installed: true,
});
assert.equal(parseSettings('{"tutorialSeen": -3, "welcomed": "oui"}').tutorialSeen, 0);
});
test("premier lancement : tant que rien n'a été fait, une seule fois", () => {
assert.ok(isFirstLaunch(DEFAULT_SETTINGS));
// La partie commencée d'office au chargement ne compte pas.
assert.ok(isFirstLaunch({ ...DEFAULT_SETTINGS, gamesStarted: 1 }));
assert.ok(!isFirstLaunch({ ...DEFAULT_SETTINGS, welcomed: true }));
assert.ok(!isFirstLaunch({ ...DEFAULT_SETTINGS, gamesStarted: 2 }));
assert.ok(!isFirstLaunch({ ...DEFAULT_SETTINGS, gamesFinished: 1 }));
assert.ok(!isFirstLaunch({ ...DEFAULT_SETTINGS, tutorialSeen: 1 }));
});
test("invitation à installer : après la première partie terminée, une seule relance", () => {
const at = (changes: Partial<Settings>, installed = false) =>
installCardShown({ ...DEFAULT_SETTINGS, ...changes }, installed);
assert.ok(!at({}), "jamais avant la première partie terminée");
assert.ok(at({ gamesFinished: 1 }));
assert.ok(!at({ gamesFinished: 1 }, true), "jamais une fois installée");
assert.ok(!at({ gamesFinished: 1, installed: true }));
// « Plus tard » : masquée, puis de retour après trois parties de plus.
const later = { installDismissals: 1, installDismissedAt: 1 };
assert.equal(INSTALL_AGAIN_AFTER, 3);
assert.ok(!at({ ...later, gamesFinished: 1 }));
assert.ok(!at({ ...later, gamesFinished: 3 }));
assert.ok(at({ ...later, gamesFinished: 4 }));
// Une seconde fois « Plus tard » : plus jamais sur l'accueil.
assert.ok(!at({ installDismissals: 2, installDismissedAt: 4, gamesFinished: 40 }));
});
test("tutoriel : écran suivant, précédent, adresse ramenée à un écran qui existe", () => {
const tutorial = parseTutorial(readFileSync(new URL("docs/tutoriel/regles.md", root), "utf8"));
const explain = (step: number) => ({ step, practice: false });
const practice = (step: number) => ({ step, practice: true });
assert.deepEqual(nextPlace(tutorial, explain(2)), explain(3));
assert.deepEqual(nextPlace(tutorial, explain(3)), practice(3));
assert.deepEqual(nextPlace(tutorial, practice(3)), explain(4));
assert.equal(nextPlace(tutorial, practice(8)), "end");
assert.equal(previousPlace(tutorial, explain(1)), null);
assert.deepEqual(previousPlace(tutorial, explain(4)), practice(3));
assert.deepEqual(previousPlace(tutorial, explain(3)), explain(2));
assert.deepEqual(previousPlace(tutorial, "end"), practice(8));
assert.deepEqual(
placeOf(tutorial, { screen: "tutorial", step: null, practice: false }),
explain(1),
);
assert.deepEqual(
placeOf(tutorial, { screen: "tutorial", step: 2, practice: true }),
explain(2),
);
assert.deepEqual(
placeOf(tutorial, { screen: "tutorial", step: 42, practice: false }),
explain(1),
);
assert.equal(placeOf(tutorial, { screen: "tutorialEnd" }), "end");
assert.equal(seenAfter(5, explain(3)), 5);
assert.equal(seenAfter(0, "end"), TUTORIAL_DONE);
// La barre : des liens vers les étapes vues seulement, pas l'actuelle.
const bar = progress(8, explain(3), 5);
assert.deepEqual(
bar.filter((s) => s.link).map((s) => s.step),
[1, 2, 4, 5],
);
assert.deepEqual(
bar.filter((s) => s.done).map((s) => s.step),
[1, 2, 3],
);
assert.deepEqual(
bar.filter((s) => s.current).map((s) => s.step),
[3],
);
});
test("la marche à suivre pour installer dépend du navigateur", () => {
const iphone =
"Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 " +
"(KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1";
const ipad = "Mozilla/5.0 (iPad; CPU OS 16_0 like Mac OS X) AppleWebKit/605.1.15";
const firefox = "Mozilla/5.0 (Android 14; Mobile; rv:131.0) Gecko/131.0 Firefox/131.0";
const chrome =
"Mozilla/5.0 (Linux; Android 14) AppleWebKit/537.36 (KHTML, like Gecko) " +
"Chrome/130.0 Mobile Safari/537.36";
assert.equal(installPlatform(iphone), "ios");
assert.equal(installPlatform(ipad), "ios");
assert.equal(installPlatform(firefox), "firefox");
// Sans invite du système, Chrome suit la marche à suivre de Firefox
// (menu ⋮, « Installer »), la plus proche de la sienne.
assert.equal(installPlatform(chrome), "firefox");
});
+5 -1
View File
@@ -218,14 +218,18 @@ function sources(dir: string): string[] {
}
test("aucune boîte native du navigateur (confirm, alert, prompt) dans l'application", () => {
// Une méthode d'un autre objet ne compte pas : event.prompt() est l'invite
// d'installation de Chrome (install.ts), pas une boîte de dialogue.
for (const path of sources("app/src/")) {
const text = read(path);
assert.doesNotMatch(
text,
/\b(window\.)?(confirm|alert|prompt)\s*\(/,
/(^|[^.\w])(window\.)?(confirm|alert|prompt)\s*\(/m,
`${path} appelle une boîte native`,
);
}
assert.match("window.alert(1)", /(^|[^.\w])(window\.)?(confirm|alert|prompt)\s*\(/m);
assert.match("x = confirm('?')", /(^|[^.\w])(window\.)?(confirm|alert|prompt)\s*\(/m);
});
test("les deux blocs du thème sombre de app.css sont identiques", () => {
+9 -2
View File
@@ -28,8 +28,11 @@ const TABLE: [string, Route][] = [
["#/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 }],
["#/tutoriel", { screen: "tutorial", step: null, practice: false }],
["#/tutoriel/3", { screen: "tutorial", step: 3, practice: false }],
["#/tutoriel/3/a-vous", { screen: "tutorial", step: 3, practice: true }],
["#/tutoriel/fin", { screen: "tutorialEnd" }],
["#/installer", { screen: "install" }],
["#/parties", { screen: "games", game: null }],
["#/parties/mh2k3x-a1b2c3", { screen: "games", game: "mh2k3x-a1b2c3" }],
["#/menu", { screen: "menu" }],
@@ -63,6 +66,10 @@ test("une adresse inconnue est reconnue comme telle", () => {
"#/menu/plus",
"#/tutoriel/0",
"#/tutoriel/x",
"#/tutoriel/3/b",
"#/tutoriel/3/a-vous/x",
"#/tutoriel/fin/a-vous",
"#/installer/x",
"#/a-propos/x",
"#/position",
"#/position/%E0%A4%A",
+181
View File
@@ -0,0 +1,181 @@
// Étapes des scénarios docs/scenarios/decouvrir-l-application.feature et
// installer-l-application.feature (ADR 0008) : l'accueil et l'invitation à
// installer passent par les modules de l'interface qui les décident
// (home-model.ts, settings.ts, install.ts), la partie par le domaine et le
// vrai moteur, le tutoriel par son texte source.
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { BeforeAll, Given, Then, When } from "@cucumber/cucumber";
import type { Engine } from "../../src/domain/game/engine.js";
import { Game } from "../../src/domain/game/game.js";
import {
dismissInstall,
homeModel,
installMenuShown,
launchHash,
WELCOME_LINKS,
welcomed,
} from "../../src/ui/home-model.js";
import { HOME_TEXTS } from "../../src/ui/home-texts.js";
import { installPlatform } from "../../src/ui/install.js";
import { parseRoute } from "../../src/ui/router.js";
import { parseSettings, type Settings } from "../../src/ui/settings.js";
import { placeOf } from "../../src/ui/tutorial-nav.js";
import { parseTutorial } from "../../src/ui/tutorial-text.js";
import { loadNodeEngine } from "../support/node-engine.js";
import { seededDice, step } from "../support/play.js";
const root = new URL("../../../../", import.meta.url);
let engine: Engine;
let settings: Settings;
let game: Game | null = null;
let installed = false;
let route = "";
let installSteps: readonly string[] = [];
BeforeAll(async () => {
engine = await loadNodeEngine();
});
/** Ce que montre l'accueil, la partie étant celle du scénario. */
function home() {
const playing = game === null ? false : game.state.phase !== "over";
return homeModel(settings, playing, installed);
}
// ------------------------------------------------------- Premier lancement
Given("vous ouvrez Ustagammon pour la toute première fois", () => {
settings = parseSettings(null);
game = null;
installed = false;
});
Then("l'accueil vous propose de découvrir les règles ou de jouer tout de suite", () => {
assert.equal(launchHash(""), "#/accueil");
assert.equal(parseRoute(launchHash("") ?? "").screen, "home");
assert.ok(home().welcome);
assert.equal(parseRoute(WELCOME_LINKS.play).screen, "game");
});
When("vous choisissez de découvrir les règles", () => {
settings = { ...settings, ...welcomed() };
route = WELCOME_LINKS.discover;
});
Then("le tutoriel s'ouvre à sa première étape", () => {
const tutorial = parseTutorial(readFileSync(new URL("docs/tutoriel/regles.md", root), "utf8"));
assert.deepEqual(placeOf(tutorial, parseRoute(route)), { step: 1, practice: false });
});
Then("ce choix ne vous est plus proposé", () => {
assert.ok(!home().welcome);
});
Given("vous avez choisi de jouer tout de suite", () => {
settings = { ...parseSettings(null), ...welcomed() };
installed = false;
});
Given("une partie est en cours", () => {
game = Game.start(engine, { dice: seededDice(11), ply: 0 });
settings = { ...settings, gamesStarted: settings.gamesStarted + 1 };
});
When("vous rouvrez Ustagammon", () => {
route = launchHash("") ?? "";
});
Then("l'accueil vous propose de reprendre votre partie", () => {
assert.equal(parseRoute(route).screen, "home");
assert.ok(home().resume);
});
When("la partie se termine", async () => {
assert.ok(game !== null);
for (let i = 0; game.state.phase !== "over"; i++) {
assert.ok(i < 2000, "la partie ne finit pas");
await step(game, engine);
}
});
Then("l'accueil vous propose une nouvelle partie", () => {
assert.equal(parseRoute(route).screen, "home");
assert.ok(!home().resume);
});
// ------------------------------------------------------------- Installation
Given("votre navigateur ne propose pas lui-même l'installation", () => {
installSteps = [];
});
When("vous demandez à installer l'application avec Firefox", () => {
const firefox = "Mozilla/5.0 (Android 14; Mobile; rv:131.0) Gecko/131.0 Firefox/131.0";
installSteps = HOME_TEXTS.installSteps[installPlatform(firefox)];
});
When("vous demandez à installer l'application sur iPhone", () => {
const iphone =
"Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 " +
"(KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1";
installSteps = HOME_TEXTS.installSteps[installPlatform(iphone)];
});
Then("la marche à suivre compte trois étapes, en commençant par le menu du navigateur", () => {
assert.equal(installSteps.length, 3);
assert.match(installSteps[0] ?? "", /menu du navigateur/);
});
Then("la marche à suivre compte trois étapes, en commençant par le bouton de partage", () => {
assert.equal(installSteps.length, 3);
assert.match(installSteps[0] ?? "", /bouton de partage/);
});
Then("l'accueil ne vous propose pas d'installer l'application", () => {
assert.ok(!home().installCard);
});
Then("l'accueil vous propose d'installer l'application", () => {
assert.ok(home().installCard);
});
When("vous terminez une partie", () => {
settings = { ...settings, gamesFinished: settings.gamesFinished + 1 };
});
When("vous terminez {int} parties de plus", (n: number) => {
settings = { ...settings, gamesFinished: settings.gamesFinished + n };
});
When("vous choisissez « Plus tard »", () => {
settings = { ...settings, ...dismissInstall(settings) };
});
Then("le menu vous le propose toujours", () => {
assert.ok(installMenuShown(settings, installed));
});
Given("vous avez terminé une partie", () => {
settings = { ...parseSettings(null), welcomed: true, gamesFinished: 1 };
installed = false;
assert.ok(home().installCard);
});
When("vous installez l'application", () => {
// L'événement appinstalled du navigateur, retenu dans les réglages.
installed = true;
settings = { ...settings, installed: true };
});
Then("le menu non plus", () => {
assert.ok(!installMenuShown(settings, installed));
// Même dans un onglet ouvert plus tard, hors de l'application installée.
assert.ok(!installMenuShown(settings, false));
assert.ok(!homeModel(settings, true, false).installCard);
});
@@ -0,0 +1,132 @@
// Étapes du scénario docs/scenarios/apprendre-les-regles.feature (ADR 0008) :
// le texte du tutoriel est lu dans sa source (docs/tutoriel/regles.md), et
// chaque geste est jugé par le domaine branché sur le vrai moteur, comme
// dans l'application.
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { BeforeAll, Given, Then, When } from "@cucumber/cucumber";
import type { Engine } from "../../src/domain/game/engine.js";
import {
MovePractice,
type PracticeVerdict,
shouldTake,
tutorialBoard,
} from "../../src/domain/learning/tutorial.js";
import { progress, seenAfter, type TutorialPlace } from "../../src/ui/tutorial-nav.js";
import { parseTutorial, type Tutorial, type TutorialScreen } from "../../src/ui/tutorial-text.js";
import { loadNodeEngine } from "../support/node-engine.js";
const root = new URL("../../../../", import.meta.url);
let engine: Engine;
let tutorial: Tutorial;
let seen = 0;
let place: TutorialPlace = { step: 1, practice: false };
let screen: TutorialScreen;
let practice: MovePractice | null = null;
let verdict: PracticeVerdict = "continue";
let answeredRight = false;
BeforeAll(async () => {
engine = await loadNodeEngine();
tutorial = parseTutorial(readFileSync(new URL("docs/tutoriel/regles.md", root), "utf8"));
});
Given("vous commencez le tutoriel", () => {
seen = 0;
place = { step: 1, practice: false };
seen = seenAfter(seen, place);
});
Then("il compte huit étapes, dont quatre où c'est à vous de jouer", () => {
assert.equal(tutorial.steps.length, 8);
assert.equal(tutorial.steps.filter((s) => s.practice !== null).length, 4);
});
Then("chaque position du tutoriel se lit sur le plateau", async () => {
for (const step of tutorial.steps) {
for (const current of [step.explain, step.practice]) {
if (current !== null) {
const board = await tutorialBoard(engine, current.position, current.onRoll);
assert.equal(board.sides.player.points.length, 24);
}
}
}
});
When("vous arrivez à l'étape {int}", (n: number) => {
for (let step = 1; step <= n; step++) {
place = { step, practice: false };
seen = seenAfter(seen, place);
}
});
Then("la barre de progression vous ramène aux étapes 1 et 2, mais pas aux suivantes", () => {
const links = progress(tutorial.steps.length, place, seen)
.filter((segment) => segment.link)
.map((segment) => segment.step);
assert.deepEqual(links, [1, 2]);
});
Given("vous êtes à l'écran « à vous » de l'étape {int}", async (n: number) => {
const found = tutorial.steps[n - 1]?.practice;
assert.ok(found != null, `l'étape ${n} n'a pas d'écran « à vous »`);
screen = found;
practice =
screen.roll === null || screen.expected.length === 0
? null
: await MovePractice.start(engine, screen.position, screen.roll, screen.expected);
verdict = "continue";
answeredRight = false;
});
When(
"vous déplacez un pion de la flèche {int} à la flèche {int}",
async (from: number, to: number) => {
assert.ok(practice !== null);
verdict = await practice.play({ from, to });
if (verdict === "wrong") {
practice.reset();
}
},
);
Then("le pion revient, et une phrase vous dit pourquoi", () => {
assert.equal(verdict, "wrong");
assert.deepEqual(practice?.pendingHalfMoves, []);
assert.ok(screen.wrong.map((part) => part.text).join("").length > 20);
});
When("vous réunissez deux pions sur la flèche 5", async () => {
assert.ok(practice !== null);
verdict = await practice.play({ from: 8, to: 5 });
assert.equal(verdict, "continue");
verdict = await practice.play({ from: 6, to: 5 });
});
Then("le tutoriel salue votre coup", () => {
assert.equal(verdict, "right");
assert.ok(screen.right.length > 0);
});
When("vous répondez « Passer »", async () => {
answeredRight = (await shouldTake(engine, screen.position)) === false;
});
When("vous répondez « Prendre »", async () => {
answeredRight = (await shouldTake(engine, screen.position)) === true;
});
Then("le maître dit qu'il aurait pris", () => {
assert.ok(!answeredRight);
assert.match(screen.wrong.map((part) => part.text).join(""), /Le maître prendrait/);
});
Then("le tutoriel salue votre réponse", () => {
assert.ok(answeredRight);
assert.match(screen.right.map((part) => part.text).join(""), /Le maître prend aussi/);
});
@@ -20,6 +20,7 @@ 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";
import { betweenTurns, updateBannerShown } from "../../src/ui/updates.js";
import { loadNodeEngine } from "../support/node-engine.js";
setDefaultTimeout(30_000);
@@ -163,3 +164,47 @@ Then("il passe à clair, puis à sombre, puis revient à automatique", () => {
}
assert.deepEqual(seen, ["auto", "light", "dark", "auto"]);
});
// ------------------------------------------------------------ Mise à jour
// Le bandeau dépend de deux choses : une version attend (le service worker
// l'a installée), et la partie est entre deux tours. La partie est celle du
// domaine, sur le vrai moteur.
let updateAvailable = false;
Given("une nouvelle version d'Ustagammon est disponible", () => {
updateAvailable = true;
});
const shownNow = (otherBanner = false) =>
updateBannerShown(
updateAvailable,
betweenTurns(game.state.phase, game.pendingHalfMoves.length),
otherBanner,
);
Given("vous avez des dés à jouer", () => {
// Jet d'ouverture 3-1 : vous commencez.
game = Game.start(engine, { dice: fixedDice([3, 1, 6, 4, 5, 2]), ply: 0 });
assert.equal(game.state.turn, "player");
assert.equal(game.state.phase, "moving");
});
When("vous déplacez un pion de votre coup", async () => {
assert.ok(await game.playHalfMove({ from: 8, to: 5 }));
});
Then("l'application ne vous propose pas encore de mettre à jour", () => {
assert.ok(!shownNow());
});
When("vous avez joué votre coup", async () => {
assert.ok(await game.playHalfMove({ from: 6, to: 5 }));
await game.confirmMove();
});
Then("l'application vous propose de mettre à jour", () => {
assert.ok(shownNow());
// Jamais par-dessus le bandeau « Reprendre l'ancienne ».
assert.ok(!shownNow(true));
});
+212
View File
@@ -0,0 +1,212 @@
// Tests du tutoriel des règles : le texte (docs/tutoriel/regles.md) se lit,
// chaque position est valide pour le vrai moteur, chaque coup illustré et
// chaque coup attendu est légal, chaque écran « à vous » accepte ses coups
// justes et refuse les autres, et la réponse au double est celle du moteur.
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
import assert from "node:assert/strict";
import { existsSync, readFileSync } from "node:fs";
import { test } from "node:test";
import type { Move } from "../src/domain/game/engine.js";
import {
canStillLeadTo,
illustratedMove,
MovePractice,
shouldTake,
tutorialBoard,
} from "../src/domain/learning/tutorial.js";
import { parseInline, parseTutorial, type TutorialScreen } from "../src/ui/tutorial-text.js";
import { loadNodeEngine } from "./support/node-engine.js";
const root = new URL("../../../", import.meta.url);
const tutorial = parseTutorial(readFileSync(new URL("docs/tutoriel/regles.md", root), "utf8"));
test("le texte du tutoriel a huit étapes, une fin, et ses écrans « à vous »", () => {
assert.equal(tutorial.steps.length, 8);
assert.deepEqual(
tutorial.steps.filter((s) => s.practice !== null).map((s) => s.number),
[3, 4, 6, 8],
);
assert.ok(tutorial.end.action !== "");
assert.ok(tutorial.end.paragraphs.length > 0);
for (const step of tutorial.steps) {
assert.ok(step.explain.paragraphs.length > 0, `étape ${step.number} sans texte`);
const practice = step.practice;
if (practice !== null) {
assert.ok(practice.paragraphs.length > 0, `étape ${step.number} : consigne absente`);
assert.ok(
practice.right.length > 0 && practice.wrong.length > 0,
`étape ${step.number} : phrases juste et refus absentes`,
);
assert.ok(
practice.expected.length > 0 || practice.answer !== null,
`étape ${step.number} : ni coup attendu, ni réponse au double`,
);
}
}
});
test("chaque mot du glossaire cité par le tutoriel a sa fiche", () => {
const screens = tutorial.steps.flatMap((s) => [s.explain, ...(s.practice ? [s.practice] : [])]);
const inlines = [
...screens.flatMap((s) => [...s.paragraphs.flat(), ...s.right, ...s.wrong]),
...tutorial.end.paragraphs.flat(),
];
const terms = inlines.flatMap((i) => (i.term === undefined ? [] : [i.term]));
assert.ok(terms.length >= 10, `seulement ${terms.length} liens vers le glossaire`);
for (const term of terms) {
assert.ok(
existsSync(new URL(`docs/glossaire/${term}.md`, root)),
`fiche ${term}.md absente`,
);
}
});
test("les liens et le gras du texte sont lus", () => {
assert.deepEqual(parseInline("Un [blot](blot.md) **seul**, voilà."), [
{ text: "Un " },
{ text: "blot", term: "blot" },
{ text: " " },
{ text: "seul", strong: true },
{ text: ", voilà." },
]);
});
test("un texte mal formé est refusé", () => {
assert.throws(() =>
parseTutorial("## 2. Sans la première\n\n- position: x\n\nTexte.\n\n## Fin\n"),
);
assert.throws(() => parseTutorial("## 1. Sans position\n\nTexte.\n\n## Fin\n"));
assert.throws(() => parseTutorial("## 1. Jet faux\n\n- position: x\n- jet: 7-1\n\n## Fin\n"));
assert.throws(() => parseTutorial("## 1. Sans fin\n\n- position: x\n\nTexte.\n"));
});
/** Tous les écrans, avec leur nom pour les messages. */
function screens(): [string, TutorialScreen][] {
return tutorial.steps.flatMap((s) => [
[`étape ${s.number}`, s.explain] as [string, TutorialScreen],
...(s.practice === null
? []
: [[`étape ${s.number}, à vous`, s.practice] as [string, TutorialScreen]]),
]);
}
test("chaque position du tutoriel est valide pour le moteur", async () => {
const engine = await loadNodeEngine();
for (const [name, screen] of screens()) {
const board = await tutorialBoard(engine, screen.position, screen.onRoll);
const count = (side: "player" | "computer") => {
const s = board.sides[side];
return s.points.reduce((a, b) => a + b, 0) + s.bar + s.off;
};
assert.equal(count("player"), 15, `${name} : pions ivoire`);
assert.equal(count("computer"), 15, `${name} : pions noyer`);
}
});
test("chaque coup illustré est légal, et la partie de l'étape 7 finit par un gammon", async () => {
const engine = await loadNodeEngine();
for (const [name, screen] of screens()) {
if (screen.move === null) {
continue;
}
assert.ok(screen.roll !== null, `${name} : coup sans jet`);
const { steps, after } = await illustratedMove(
engine,
screen.position,
screen.roll,
screen.move,
);
assert.equal(steps.boards.length, steps.halfMoves.length + 1, name);
if (name === "étape 7") {
assert.deepEqual(after.result, { winner: "player", kind: "gammon" });
} else {
assert.equal(after.result, null, name);
}
if (name === "étape 4") {
assert.ok(steps.hits.some(Boolean), "étape 4 : le coup illustré ne frappe pas");
}
}
});
test("chaque écran « à vous » accepte ses coups attendus et refuse les autres", async () => {
const engine = await loadNodeEngine();
for (const [name, screen] of screens()) {
if (screen.expected.length === 0) {
continue;
}
assert.ok(screen.roll !== null, `${name} : pas de jet`);
const legal = await engine.legalMoves(screen.position, screen.roll);
for (const move of legal) {
const practice = await MovePractice.start(
engine,
screen.position,
screen.roll,
screen.expected,
);
let verdict = "continue";
for (const halfMove of move.halfMoves) {
verdict = await practice.play(halfMove);
if (verdict !== "continue") {
break;
}
}
const right = screen.expected.includes(move.notation);
assert.equal(verdict, right ? "right" : "wrong", `${name} : ${move.notation}`);
}
// Un coup attendu au moins, et un coup refusé au moins.
assert.ok(
legal.some((m) => !screen.expected.includes(m.notation)),
`${name} : rien à refuser`,
);
}
});
test("le joueur peut revenir en arrière après un coup refusé", async () => {
const engine = await loadNodeEngine();
const practice = await MovePractice.start(engine, "4HPwATDgc/ABMA", [3, 1], ["8/5 6/5"]);
assert.equal(await practice.play({ from: 24, to: 21 }), "wrong");
practice.reset();
assert.deepEqual(practice.pendingHalfMoves, []);
assert.equal(await practice.play({ from: 8, to: 5 }), "continue");
assert.equal(await practice.play({ from: 6, to: 5 }), "right");
});
test("la réponse au double de l'étape 8 est celle du moteur", async () => {
const engine = await loadNodeEngine();
for (const [name, screen] of screens()) {
if (screen.answer === null) {
continue;
}
assert.equal(screen.onRoll, "computer", `${name} : l'ordinateur doit avoir la main`);
assert.equal(await shouldTake(engine, screen.position), screen.answer === "take", name);
}
});
test("des demi-coups peuvent encore mener à un coup attendu, ou plus", () => {
const move = (notation: string, halfMoves: [number, number][]): Move => ({
notation,
halfMoves: halfMoves.map(([from, to]) => ({ from, to })),
position: notation,
});
const expected = [
move("8/5 6/5", [
[8, 5],
[6, 5],
]),
];
assert.ok(canStillLeadTo([], expected));
assert.ok(canStillLeadTo([{ from: 6, to: 5 }], expected));
assert.ok(!canStillLeadTo([{ from: 8, to: 7 }], expected));
assert.ok(
!canStillLeadTo(
[
{ from: 8, to: 5 },
{ from: 8, to: 5 },
],
expected,
),
);
});
+75 -8
View File
@@ -19,7 +19,15 @@ import {
pointColor,
pointSlot,
} from "../src/ui/board-model.js";
import { parseFeature, parseGlossaryEntry, parseGlossaryIndex } from "../src/ui/help-text.js";
import { FEATURES } from "../src/ui/help.js";
import { HELP_QUESTIONS, helpHash } from "../src/ui/help-context.js";
import {
matchesSearch,
parseFeature,
parseGlossaryEntry,
parseGlossaryIndex,
} from "../src/ui/help-text.js";
import { slug } from "../src/ui/router.js";
import { TEXTS } from "../src/ui/texts.js";
// Compilé dans build/web/tests/ : la racine du dépôt est trois dossiers plus haut.
@@ -143,21 +151,77 @@ test("l'aide lit le scénario tel qu'il est écrit dans docs/scenarios/", () =>
const feature = parseFeature(read("docs/scenarios/jouer-une-partie.feature"));
assert.equal(feature.title, "Jouer une partie contre l'ordinateur");
assert.match(feature.description[0] ?? "", /^Vous jouez une partie complète/);
assert.equal(feature.scenarios[0]?.title, "Commencer une partie");
assert.equal(feature.scenarios[0]?.title, "Comment commence une partie ?");
assert.equal(feature.scenarios[0]?.position, "4HPwATDgc/ABMA");
assert.deepEqual(feature.scenarios[0]?.steps, [
"Quand vous commencez une nouvelle partie",
"Alors les pions sont en position de départ",
"Et le premier jet de dés désigne qui commence",
]);
const refused = feature.scenarios.find((s) => s.title === "Un coup interdit est refusé");
assert.match(refused?.description[0] ?? "", /^La case 12 est tenue/);
const refused = feature.scenarios.find(
(s) => s.title === "Pourquoi je ne peux pas poser mon pion sur cette flèche ?",
);
assert.match(
refused?.description[0] ?? "",
/^Une flèche où l'ordinateur a au moins deux pions/,
);
assert.equal(refused?.steps[0], "Étant donné que c'est à vous de jouer avec le jet 3-1");
assert.equal(feature.scenarios[2]?.position, null);
// Chaque scénario a des étapes : aucune n'a été prise pour du texte libre.
for (const scenario of feature.scenarios) {
assert.ok(scenario.steps.length >= 2, scenario.title);
}
});
test("aide : chaque scénario affiché est une question avec sa réponse", () => {
const listed = read("docs/scenarios/README.md");
const ids = new Set<string>();
for (const url of FEATURES) {
assert.ok(
listed.includes(`](${url.replace("docs/scenarios/", "")})`),
`${url} hors de l'index`,
);
const feature = parseFeature(read(url));
assert.ok(feature.description.length > 0, `${url} : pas d'introduction`);
for (const scenario of feature.scenarios) {
// La réponse se lit seule : pas de vocabulaire technique.
assert.ok(scenario.description.length > 0, `${scenario.title} : pas de réponse`);
for (const word of ["scénario", "événement", "moteur", "Étant donné"]) {
assert.ok(
!scenario.description.join(" ").includes(word),
`${scenario.title} : « ${word} » dans la réponse`,
);
}
const id = slug(scenario.title);
assert.ok(id !== "" && !ids.has(id), `${scenario.title} : identifiant ${id} en double`);
ids.add(id);
}
}
});
test("aide contextuelle : chaque question ouverte depuis la partie existe dans les scénarios", () => {
const titles = FEATURES.flatMap((url) => parseFeature(read(url)).scenarios.map((s) => s.title));
for (const title of Object.values(HELP_QUESTIONS)) {
assert.ok(titles.includes(title), `« ${title} » n'est plus le titre d'un scénario`);
}
assert.equal(helpHash("stuck"), "#/aide/je-ne-peux-rien-jouer-avec-mes-des");
assert.equal(helpHash(null), "#/aide");
});
test("glossaire : nom anglais, renvois, recherche sans accents", () => {
const entry = parseGlossaryEntry(read("docs/glossaire/camp.md"));
assert.ok(entry !== null);
assert.equal(entry.english, "Home board");
assert.deepEqual(
entry.seeAlso.map((s) => s.name),
["sortie", "barre", "point"],
);
assert.ok(matchesSearch(entry, "camp"));
assert.ok(matchesSearch(entry, "HOME"));
assert.ok(matchesSearch(entry, "fleches sortir"));
assert.ok(!matchesSearch(entry, "videau"));
});
test("l'aide lit chaque fiche de l'index du glossaire", () => {
const files = parseGlossaryIndex(read("docs/glossaire/README.md"));
assert.ok(files.includes("videau.md"));
@@ -167,10 +231,13 @@ test("l'aide lit chaque fiche de l'index du glossaire", () => {
assert.ok(entry !== null, file);
assert.ok(entry.brief.length > 10, file);
}
assert.deepEqual(parseGlossaryEntry(read("docs/glossaire/videau.md")), {
term: "Videau",
brief: "le dé à doubler, marqué 2, 4, 8, 16, 32 et 64 : il indique la valeur actuelle de la partie.",
});
const videau = parseGlossaryEntry(read("docs/glossaire/videau.md"));
assert.equal(videau?.term, "Videau");
assert.equal(videau?.english, "Cube");
assert.equal(
videau?.brief,
"le dé à doubler, marqué 2, 4, 8, 16, 32 et 64 : il indique la valeur actuelle de la partie.",
);
assert.equal(parseGlossaryEntry("pas une fiche"), null);
});
+13
View File
@@ -85,3 +85,16 @@ unique pour tous les écrans, avant sa première mise en œuvre.
- Une adresse inconnue mène à la partie en cours avec un message clair.
- L'adresse de l'écran courant est la source de vérité de la navigation :
l'application l'écoute (`hashchange`) et dessine l'écran correspondant.
### 2026-11-09 — Adresses du tutoriel et de l'installation
Ajoutées avec l'accueil et le tutoriel (étape 0e.5), dans le même schéma :
| Écran | Adresse |
| --- | --- |
| Écran « à vous » d'une étape du tutoriel | `#/tutoriel/<n>/a-vous` |
| Fin du tutoriel | `#/tutoriel/fin` |
| Marche à suivre pour installer l'application | `#/installer` |
Lancée sans adresse, l'application ouvre l'accueil (`#/accueil`) ; `#/` reste
la partie en cours, pour que les liens existants ne changent pas.
+1 -1
View File
@@ -20,7 +20,7 @@ Repris du cadrage (modules, écrans clés, IA générative en V1) et des ADR 001
| 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 (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 |
| 1d. Conseil du maître | Après chaque coup du joueur, jamais avant (ADR 0011) : appréciation du moteur (bon coup, imprécision, erreur, grosse erreur) 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 |
| 1f. Prompts à copier | Gabarits versionnés et testés (`docs/prompts/` ou équivalent, source unique), bouton « Demander à une IA » sur le plateau, le conseil du maître, l'analyse et la fin de partie ; le moteur décide, l'IA explique | 1d, 1e |
| 1g. Accueil, tutoriel et leçons | Accueil (reprendre, nouvelle partie, prochaine leçon, progression résumée), tutoriel des règles interactif et illustré (0e.5), cinq premières leçons numérotées (ADR 0011) avec positions et « à vous de jouer », progression locale ; contenus CC BY-SA 4.0 en brouillon, à relire par un joueur fort | 0e.5, 1a |
-1
View File
@@ -23,7 +23,6 @@ Demandes de Pierre du 2026-11-03, traitées avant d'ouvrir l'étape 1.
| Sous-étape | Contenu | État | Plan |
| --- | --- | --- | --- |
| 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
+1
View File
@@ -40,6 +40,7 @@ exportée. `make test` vérifie le lien dans les deux sens : chaque fiche avec
| [Barre](barre.md) | Bar |
| [Blot](blot.md) | Blot |
| [Branche](branche.md) | Branch |
| [Camp](camp.md) | Home board |
| [Chance](chance.md) | Luck |
| [Compte de points](compte-de-points.md) | Pip count |
| [Coup](coup.md) | Move |
+12
View File
@@ -0,0 +1,12 @@
---
term: Camp
english: Home board
---
# Camp
**En bref** : les six dernières flèches d'un joueur, avant sa sortie : il y rassemble ses pions pour les sortir, et un pion adverse frappé doit y rentrer.
Votre camp est en bas à droite (vos flèches 1 à 6) ; celui de l'ordinateur,
en haut à droite (vos flèches 19 à 24), où rentrent vos pions frappés.
Voir aussi : [Sortie](sortie.md), [Barre](barre.md), [Point](point.md)
+1
View File
@@ -14,6 +14,7 @@ Décisions : ADR 0010. Fichiers du logo et des icônes : `../logo/`.
| Fichier | Contenu |
| --- | --- |
| `guide-de-marque.md` | Règles d'usage : ton, couleurs, typographie, formes, mouvement, icônes, composants |
| `ecrans-etape-1.md` | Écrans de l'étape 1, lot par lot : nouvelle partie et match (1c), conseil du maître (1d), bilan et analyse (1e), IA (1f), leçons (1g), progression (1h), gestes (1i) |
| `accueil-tutoriel-aide.md` | Accueil, installation sans intrusion, tutoriel des règles (forme et première version du texte), aide en questions |
| `recette-ux.md` | Critères de recette design de l'écran de jeu, à cocher sur les captures et sur le téléphone |
| `interaction.md` | Comportement : réponse immédiate, annuler plutôt que confirmer, mouvement, vibrations, clavier, mises en page de l'écran de jeu (portrait, paysage, ordinateur), pictogrammes |
@@ -67,7 +67,8 @@ interrompre.
deux ou trois phrases. Une étape sur deux finit par un écran **« à vous »**
où le joueur fait le geste sur le vrai plateau, contrôlé par le moteur.
- En haut : une barre de progression en huit segments (`accent` pour les
étapes faites) et « Passer » à droite. En bas : « Retour » et
étapes faites) et « Quitter » à droite (pas « Passer » : ce mot appartient au
videau, et le tutoriel enseigne les mots exacts du jeu). En bas : « Retour » et
« Continuer ».
- Les positions sont données par leurs identifiants gnubg (ADR 0013) ; le
plateau est le composant de l'app, non jouable sauf sur les écrans
@@ -113,9 +114,10 @@ interrompre.
flèche 6, un 1 un pion de la flèche 1. Touchez le pion, puis la sortie à
droite. » *À vous* : sortir deux pions avec 6-3.
7. **Gammon et backgammon.** « Une partie gagnée vaut 1 point. Si
l'adversaire n'a sorti aucun pion quand vous finissez, c'est un gammon :
2 points. S'il lui reste en plus un pion sur la barre ou dans votre camp,
c'est un backgammon : 3 points. »
l'adversaire n'a sorti aucun pion, c'est un gammon : 2 points ; s'il lui
reste en plus un pion sur la barre ou dans votre camp, c'est un
backgammon : 3 points. En partie libre, gammon et backgammon ne comptent
que si le videau a été doublé : c'est la règle de Jacoby. »
8. **Le videau.** « Le cube aux faces 2, 4, 8, 16, 32 et 64 sert à miser.
Avant de lancer vos dés, vous pouvez proposer de doubler la valeur de la
partie. L'ordinateur prend, et la partie continue en valant le double, ou il
+253
View File
@@ -0,0 +1,253 @@
# Écrans de l'étape 1
Règles de design des écrans de l'étape 1 (`docs/etape-1.md`), lot par lot.
Maquettes : canevas https://claude.ai/artifact/CQw6vbDpi6XQqVWQa4sANe, rangées
« Étape 1 ». Comportement général : `interaction.md` ; liens : ADR 0013.
## 1c. Nouvelle partie et match
### Écran « Nouvelle partie » (`#/nouvelle-partie`)
Ouvert depuis le menu, l'accueil et la fin de partie. Une seule page, sans
étapes, de haut en bas :
1. **Type de partie** : un sélecteur à deux segments, « Partie libre » et
« Match » (fond `surface-sunken`, segment choisi en `surface` avec
`shadow-raised`).
- **Match** : « Le premier à [3] [5] [7] [11] points », quatre boutons
carrés de 44 px, le choisi en brique. Dessous, une phrase sur la règle de
Crawford, avec le lien vers sa fiche du glossaire.
- **Partie libre** : une phrase sur la règle de Jacoby, avec le lien vers
sa fiche. Pas d'interrupteur pour Crawford ni Jacoby : ce sont les règles
usuelles, et le joueur débutant n'a pas à les choisir.
2. **Niveau de l'ordinateur** : six barres de hauteur croissante, à toucher
(Débutant, Amateur, Intermédiaire, Avancé, Expert, Champion) ; les barres
jusqu'au niveau choisi sont en brique, le niveau choisi porte l'anneau de
focus. Dessous, le nom en `heading` et une phrase qui le décrit en mots de
joueur, jamais en plis ni en bruit :
- Débutant : « Joue simplement et laisse passer des occasions. Idéal pour
vos premières parties. »
- Amateur : « Un joueur qui connaît les règles et commet encore des
erreurs. »
- Intermédiaire : « Un habitué de la table : solide, sans être
implacable. »
- Avancé : « Un bon joueur de club. Peu d'erreurs de pions, un videau
sérieux. »
- Expert : « Très peu d'erreurs. Chaque point se gagne. »
- Champion : « GNU Backgammon à son meilleur niveau sur ce téléphone. »
3. **Pendant la partie** : interrupteur « Conseil du maître », sous-titre
« Une ligne après chacun de vos coups ».
4. **Barre d'action collée en bas** : un seul bouton principal, qui dit ce
qu'il lance (« Commencer la partie » ou « Commencer le match en 5
points »), et la mention « Vos choix sont gardés pour la prochaine fois. ».
Par défaut : partie libre, Intermédiaire, conseil activé. Fermer (croix en
haut à gauche, ou retour du téléphone) ne change rien à la partie en cours.
Commencer une nouvelle partie alors qu'une partie est en cours suit la règle
« annuler plutôt que confirmer » (bandeau « Reprendre l'ancienne »).
### Score de match sur l'écran de jeu
- **Portrait** : une ligne `overline` centrée sous la barre haute, « Match en
5 points », complétée par « · partie Crawford » pendant la partie Crawford ;
dans chaque bandeau, une pastille carrée (contour `border-strong`, chiffre
gras) avec le score de ce joueur, juste avant son compte de points.
- **Paysage** : dans la colonne de commandes, sous le menu, une case qui
empile le score de l'ordinateur, « sur 5 », puis le vôtre (même ordre que
les bandeaux : l'ordinateur en haut, vous en bas).
- Le score se lit aussi au lecteur d'écran : « Match en 5 points :
l'ordinateur 4, vous 2 ».
- Pendant la partie Crawford, le videau n'est pas affiché : il n'existe pas
pour cette partie.
## 1d. Conseil du maître
### La ligne du maître
Maquette « Portrait révisé : conseil du maître après le coup ». Une ligne de
44 px, réservée sous le bandeau du joueur (elle ne pousse rien) :
- icône d'appréciation, puis le **mot en couleur** suivi d'un point
(« Bon coup. »), puis la **phrase du maître en italique**, sur une ligne,
tronquée par des points de suspension si elle est trop longue, et un
chevron ;
- elle apparaît **après** la validation du coup du joueur (ou sa décision de
videau), dès que l'analyse est prête, en fondu de `duration-fast` ; elle
s'efface à l'action suivante du joueur (lancer les dés, toucher un pion) ;
- **pendant que le joueur réfléchit, rien n'apparaît** ; si l'analyse arrive
trop tard (le joueur a déjà relancé), la ligne n'apparaît pas, et le coup
reste commenté dans le bilan ;
- conseil désactivé : la ligne reste réservée et vide (la mise en page ne
bouge pas).
### Les mots
| Seuil gnubg (écart d'équité) | Mot | Couleur | Icône |
| --- | --- | --- | --- |
| meilleur coup, ou moins de 0,03 | Bon coup | `verdict-good` | coche |
| 0,03 à 0,06 (doubtful) | Imprécision | `verdict-doubtful` | point d'interrogation |
| 0,06 à 0,12 (bad) | Erreur | `verdict-bad` | croix |
| plus de 0,12 (very bad) | Grosse erreur | `verdict-bad` | croix |
Pour le videau : « Bon choix » et « Bonne décision » remplacent « Bon coup ».
« Grosse erreur » est la recommandation de design ; `docs/etape-1.md` écrit
« gaffe », et le choix final revient à Pierre (ton).
### La feuille d'explication
Maquette « Feuille d'explication du maître ». Toucher la ligne ouvre une
feuille qui monte du bas (`duration-moderate`), au-dessus du plateau voilé :
1. le mot d'appréciation et le contexte (« Erreur · votre 3-1 d'ouverture ») ;
2. le titre : le coup du maître, à la première personne (« J'aurais joué
8/5 6/5 ») ; pour un bon coup : « C'est aussi le coup que j'aurais joué » ;
3. la phrase du maître, en italique ;
4. un sélecteur à deux segments, « Votre coup » et « Le coup du maître », au-
dessus d'un plateau qui montre la position d'avant le coup ; le coup choisi
se joue pion par pion (`duration-fast`), avec un tracé du départ à
l'arrivée : `text` pour votre coup, `verdict-good` pour celui du maître ;
5. « Voir les chiffres du moteur » (repliée par défaut) : le classement des
coups avec leur écart d'équité et le niveau d'évaluation ; votre coup est en
gras ;
6. « Demander à une IA » (1f) et « Fermer ».
Le lien vers la fiche d'un concept du glossaire n'apparaît que si le moteur
permet de le déterminer, sinon rien.
### Les phrases du maître
- **Le maître parle à la première personne et vouvoie** : « J'aurais joué… »,
jamais « Vous auriez dû… », jamais « Il fallait… ».
- **Il ne cite que des faits** : le coup recommandé, le rang de votre coup,
les pions frappés, les points faits ou laissés (lisibles dans le coup
lui-même), l'écart d'équité sur demande. Aucune explication stratégique
inventée (« la case la plus précieuse », « trop risqué ») : celles-là
viendront des leçons, relues par un joueur fort.
- **Trois parties au plus** : ce que vous avez fait, ce que le maître préfère,
un fait qui les distingue. Moins de 140 caractères pour la ligne ; la
feuille peut aller jusqu'à deux phrases.
- **Ton** : encourageant et sobre. Pas de point d'exclamation, pas d'émoji, pas
de superlatif. Les bons coups ont plusieurs formules qui tournent
(« C'est le coup du maître. », « Exactement ce que j'aurais joué. »,
« Bien vu. »), pour ne pas lasser.
- **Exemples** (gabarits à remplir avec les données du moteur) :
- Bon coup : « Bien vu. 8/5 6/5 fait votre point 5. »
- Imprécision : « J'aurais joué {meilleur}. Votre coup était le
{rang}e choix, tout près. »
- Erreur : « J'aurais joué {meilleur}. Votre {joué} était le {rang}e
choix. »
- Videau, prise juste : « Bonne décision : prendre était le bon choix. »
- Videau, double manqué : « Ici, j'aurais doublé. »
## 1e. Bilan et analyse
### Bilan (`#/parties/<id>/bilan`)
Maquette « Bilan de fin de partie ». Il remplace le panneau de fin de partie
quand l'analyse est prête ; avant, le panneau actuel s'affiche et le bilan le
remplace en place, sans saut.
1. `overline` « Fin de partie », titre `display` (« Vous gagnez », « L'ordinateur
gagne un gammon »), puis une phrase : le résultat en points et une
lecture simple du jeu et des dés (« Votre jeu a fait la différence, avec
des dés un peu contre vous. »), construite à partir du taux d'erreur et de
la chance donnés par le moteur, jamais inventée.
2. Trois cases côte à côte : le nombre de bons coups, d'imprécisions et
d'erreurs (grosses erreurs comprises), chiffre en 28 px, mot et icône en
couleur d'appréciation.
3. Deux jauges sur une ligne chacune :
- « Votre jeu » : le taux d'erreur traduit en niveau (correspondance avec
les classements de gnubg documentée, mêmes noms que les niveaux de
l'ordinateur), jauge en `verdict-good` ;
- « Les dés » : la chance, jauge centrée (à droite si elle vous a aidé, à
gauche sinon), en `border-strong`, avec trois mots possibles : « pour
vous », « équilibrés », « contre vous » (« un peu » ou « nettement »
selon l'écart).
Lien « Comment lire ces mesures » vers les fiches du glossaire.
4. « Le coup à revoir » : la plus grosse erreur, mini-plateau, mot
d'appréciation, coup joué et coup du maître ; « Revoir ce coup » (ouvre
l'analyse sur ce coup) et « Demander à une IA ». Sans erreur ni
imprécision : la carte dit « Aucun coup à revoir : vous avez joué comme le
maître. »
5. Barre d'actions : « Voir l'analyse » et « Nouvelle partie » (principale).
### Analyse (`#/parties/<id>/analyse/<coup>`)
Maquette interactive « Analyse ». Une page qui défile, de haut en bas :
1. **Courbe de vos chances de gagner**, coup après coup, en pourcentage (pas
en équité : un débutant lit « 41 % », pas « −0,18 »). Tracé `text` de 2 px,
aire `accent-soft`, ligne pointillée à 50 %. Les coups commentés sont des
points de couleur d'appréciation, les autres de petits points `text`.
**On parcourt la courbe du doigt** : chaque coup possède une bande verticale
sur toute la hauteur du graphique, qui sert de cible tactile ; le coup
choisi porte un trait vertical brique et un point cerclé.
2. **Visualiseur** : « ‹ Coup 7 sur 24 · 6-1 › », le plateau de la position
d'avant le coup, la phrase du maître, le sélecteur « Votre coup » / « Le
coup du maître » de la feuille 1d, puis « Rejouer d'ici » (crée une
branche, ADR 0011 et 1b) et « Demander à une IA ».
3. **Les coups à revoir** : filtres en pastilles de 44 px (« Tout »,
« Erreurs », « Imprécisions », avec leurs nombres), puis une ligne par
coup : numéro, dés, coup joué, mot d'appréciation. Toucher une ligne place
le visualiseur et la courbe sur ce coup.
Sur ordinateur, `←` et `→` passent d'un coup à l'autre.
## 1f. Demander à une IA
Le bouton est secondaire partout, jamais l'action principale, avec l'icône
de copie : dans la feuille du maître (1d), dans la carte « Le coup à
revoir » du bilan, dans le visualiseur de l'analyse, et dans une fiche du
glossaire. Après la copie, le bandeau « Prompt copié » (`verdict-good`, 2 s),
puis le texte du prompt reste lisible dans une zone `surface-sunken`
repliable, pour qui veut le relire avant de le coller (composant `AskAI`).
## 1g. Leçons (`#/lecons`, `#/lecons/<n>/<écran>`)
Maquette « Leçons ». En tête, un rappel discret « Les règles du jeu ·
revoir » (le tutoriel de 0e.5). Puis les cinq leçons numérotées, dans
l'ordre, chacune avec une pastille ronde à son numéro :
- terminée : pastille `verdict-good`, « Terminée » ;
- en cours : carte bordée de brique, pastille brique, « 2 exercices sur 4 »,
barre de progression, bouton « Continuer » (seule action principale de
l'écran) ;
- à commencer : pastille au contour `border-strong`.
Toutes les leçons restent ouvertes : l'ordre est conseillé, jamais imposé.
Les écrans d'une leçon suivent la forme du tutoriel (barre de progression,
« Passer », « Retour », « Continuer », écrans « à vous » contrôlés par le
moteur).
## 1h. Progression (`#/progression`)
Maquette « Progression ».
- Une phrase de synthèse, factuelle (« Sur vos 8 dernières parties, vous
faites moins d'erreurs : votre taux d'erreur est passé de 14 à 7. »). Si la
tendance monte, la phrase reste encourageante et sans reproche (« Votre
taux d'erreur remonte un peu ces derniers jours. Les leçons sont là
pour reprendre un point. »).
- La courbe du taux d'erreur par partie terminée, avec le rappel « plus bas,
c'est mieux » dans son titre, le dernier point en `verdict-good`, et un lien
vers la fiche « taux d'erreur ».
- La liste des parties (résultat, date, niveau de l'ordinateur, taux d'erreur
en chiffre gras aligné à droite), chacune ouvrant son bilan.
- Moins de trois parties analysées : pas de courbe, une phrase « Votre
courbe apparaîtra après trois parties. ».
## 1i. Gestes sur le plateau
- **Glisser-déposer** : le pion suit le doigt, agrandi à 115 % et décalé de
12 px vers le haut pour rester visible sous le doigt ; à son départ, un pion
fantôme en pointillés ; les cases d'arrivée permises gardent leur voile et
leur anneau (révision UX). Lâché sur une case permise : il s'y pose
(`duration-fast`, vibration « pion posé ») ; lâché ailleurs : il revient à
sa place en `duration-fast`, sans vibration ni message.
- **Double toucher** : joue le coup évident quand il existe (un seul coup légal
complet commence par ce pion, ou le moteur n'en propose qu'un) ; sinon, il
vaut un simple toucher, sans message d'erreur.
- **Toucher simple** : inchangé (pion puis case, ou avance directe s'il n'a
qu'une case d'arrivée).
- Les trois gestes coexistent ; aucun n'est expliqué par une fenêtre : le
tutoriel et l'aide en parlent.
@@ -1,76 +0,0 @@
# Accueil, installation, tutoriel des règles, aide (0e.5)
- **Date** : 2026-11-03
- **Étape** : 0e.5 (feuille de route), qui couvre aussi le tutoriel de 1g
- **Statut** : prévu (après la fusion de 0e.4)
- **Exécutant** : sous-agent general-purpose, dans un worktree
## Objectif
Traiter les points 2 et 3 du message de Pierre du 2026-11-03 et sa demande d'installation en un toucher :
- une page d'accueil ;
- l'installation proposée par l'app ;
- un tutoriel des règles très didactique, illustré par le plateau ;
- une aide plus humaine.
## Références
- Règles : `docs/identite/charte/accueil-tutoriel-aide.md`. Le texte du tutoriel y figure en première version, à faire valider par Pierre.
- Maquettes : https://claude.ai/artifact/CQw6vbDpi6XQqVWQa4sANe, rangée « Étape 0e.5 » et artboard « Accueil ».
- ADR 0008 (source unique), ADR 0013 (positions par identifiants gnubg).
## Consignes
1. **Accueil**, selon la charte :
- au premier lancement, et une seule fois, le choix entre « Je découvre » et « Je connais les règles » ;
- ensuite : la partie en cours, la carte d'installation, la prochaine leçon et la progression résumée. La leçon et la progression restent en attente tant que 1g et 1h n'existent pas : n'afficher que ce qui existe.
2. **Installation** :
- sous Chrome, garder l'invite reçue avec `beforeinstallprompt` ;
- sous Firefox et sur iPhone, une feuille en 3 étapes ;
- la carte n'apparaît qu'à partir de la première partie terminée. « Plus tard » la masque, et elle ne revient qu'une fois, après 3 parties. Le menu garde une ligne tant que l'app n'est pas installée ;
- plus rien en `display-mode: standalone` ni après l'événement `appinstalled`.
3. **Tutoriel**, en 8 étapes :
- le texte vit dans un fichier source unique sous `docs/` (par exemple `docs/tutoriel/`), copié dans le site par `make site` comme le glossaire, sous licence CC BY-SA 4.0 ;
- positions données par leurs identifiants gnubg ;
- sur les écrans « à vous », le moteur vérifie le coup joué en le comparant aux coups attendus de l'étape ;
- le coup illustré se joue seul après 1 s, avec « Revoir » ;
- un test vérifie que chaque position du tutoriel est valide pour le moteur et que chaque coup attendu est légal ;
- ajouter la fiche du glossaire « camp ».
4. **Aide**, en trois onglets :
- « Jouer » : les scénarios deviennent des questions repliables. Le titre est la question et la description la réponse ; les étapes Gherkin sont cachées. Reformuler les titres des `.feature` sans casser les tests ;
- « 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. **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.
Chaque écran (accueil, étapes du tutoriel, onglets et questions de l'aide) a son adresse et s'ouvre par un vrai lien, selon l'amendement de l'ADR 0013 ; le routeur vient de 0e.4.
`make lint`, `make test` et `make test-browser` doivent passer. Aucune nouvelle dépendance.
## Critères de réussite
- Un débutant suit le tutoriel jusqu'au bout sur le téléphone, et chaque écran « à vous » accepte le bon coup et refuse les autres.
- Les trois commandes passent, et les captures sont livrées pour la recette.
## Résultat
À remplir à la clôture.
+1 -1
View File
@@ -29,7 +29,7 @@ du moteur.
(événement d'analyse rétrocompatible, ou cache séparé justifié) pour servir
au bilan (1e) sans recalcul.
2. **Ligne du maître** : icône d'appréciation, mot en couleur (« Bon coup. »,
« Imprécision. », « Erreur. », « Gaffe. » : correspondance avec les seuils
« Imprécision. », « Erreur. », « Grosse erreur. » (choix de Pierre du 2026-11-09, plutôt que « gaffe ») : correspondance avec les seuils
de gnubg documentée), phrase courte en italique, chevron. Toucher ouvre la
feuille d'explication : coup joué et meilleur coup montrés sur le plateau
(pions animés, ADR 0011 « comparaison visuelle »), écart d'équité seulement
+13 -1
View File
@@ -33,6 +33,15 @@ Fonctionnalité: <la tâche>
Et <autre constat>
```
Dans l'aide, chaque scénario devient une question repliable : son titre est
la question, formulée comme le joueur se la pose (« Comment jouer un
coup ? ») ; le paragraphe sous le titre est la réponse, qui doit se lire
seule ; les étapes ne s'affichent pas. Une étiquette
`@position:<Position ID>` au-dessus du scénario ajoute à la réponse le lien
« Voir sur le plateau ». L'adresse d'une question (`#/aide/<identifiant>`)
est tirée de son titre : changer un titre change son adresse, et les titres
cités par `app/src/ui/help-context.ts` sont vérifiés par `make test`.
Règles d'écriture :
- la deuxième personne (« vous ») et les mots du glossaire (`docs/glossaire/`) ;
@@ -49,4 +58,7 @@ Règles d'écriture :
| [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 |
| [regler-l-application.feature](regler-l-application.feature) | Régler l'application (menu), la mettre à jour | Étape 0e |
| [decouvrir-l-application.feature](decouvrir-l-application.feature) | Premier lancement et accueil | Étape 0e.5 |
| [apprendre-les-regles.feature](apprendre-les-regles.feature) | Apprendre les règles avec le tutoriel | Étape 0e.5 |
| [installer-l-application.feature](installer-l-application.feature) | Installer l'application | Étape 0e.5 |
@@ -0,0 +1,42 @@
# language: fr
Fonctionnalité: Apprendre les règles avec le tutoriel
Le tutoriel explique les règles en huit petites étapes, sur le vrai
plateau : les pions bougent pour montrer chaque règle, et une étape sur
deux vous fait jouer le geste vous-même. GNU Backgammon vérifie chaque
coup, comme en partie.
Scénario: Comment se passe le tutoriel ?
Chaque étape tient en un écran court : un titre, le plateau, deux ou
trois phrases. Le coup montré se joue tout seul après une seconde ;
« Revoir » le rejoue. La barre du haut montre où vous en êtes et vous
ramène aux étapes déjà vues. « Quitter » vous emmène jouer.
Étant donné que vous commencez le tutoriel
Alors il compte huit étapes, dont quatre où c'est à vous de jouer
Et chaque position du tutoriel se lit sur le plateau
Quand vous arrivez à l'étape 3
Alors la barre de progression vous ramène aux étapes 1 et 2, mais pas aux suivantes
@position:4HPwATDgc/ABMA
Scénario: Je joue un autre coup que celui demandé
Sur les écrans « à vous », vous jouez sur le vrai plateau. Si votre coup
n'est pas celui demandé, le pion revient et une phrase vous dit
pourquoi ; réessayez autant que vous voulez. Le bon coup est salué, et
« Continuer » vous mène à la suite.
Étant donné que vous êtes à l'écran « à vous » de l'étape 3
Quand vous déplacez un pion de la flèche 24 à la flèche 21
Alors le pion revient, et une phrase vous dit pourquoi
Quand vous réunissez deux pions sur la flèche 5
Alors le tutoriel salue votre coup
Scénario: Comment répondre au double dans le tutoriel ?
À la dernière étape, l'ordinateur vous propose de doubler. Prenez ou
passez : le maître vous dit ensuite ce qu'il aurait fait, et pourquoi.
Étant donné que vous êtes à l'écran « à vous » de l'étape 8
Quand vous répondez « Passer »
Alors le maître dit qu'il aurait pris
Quand vous répondez « Prendre »
Alors le tutoriel salue votre réponse
@@ -0,0 +1,31 @@
# language: fr
Fonctionnalité: Découvrir l'application
À la toute première ouverture, Ustagammon vous demande d'où vous partez,
puis s'efface : ensuite, il s'ouvre sur l'accueil, avec votre partie en
cours à reprendre d'un toucher.
@position:4HPwATDgc/ABMA
Scénario: Que me propose l'application à la première ouverture ?
Deux choix, et c'est tout : « Je découvre » ouvre le tutoriel des règles,
« Je connais les règles » lance une partie tout de suite. Ce choix ne
revient plus ; le tutoriel reste dans le menu.
Étant donné que vous ouvrez Ustagammon pour la toute première fois
Alors l'accueil vous propose de découvrir les règles ou de jouer tout de suite
Quand vous choisissez de découvrir les règles
Alors le tutoriel s'ouvre à sa première étape
Et ce choix ne vous est plus proposé
Scénario: Où retrouver ma partie en rouvrant l'application ?
L'application s'ouvre sur l'accueil. Votre partie en cours y est, avec
son plateau et les comptes de points : « Reprendre la partie » vous y
ramène. Sans partie en cours, « Nouvelle partie » en commence une.
Étant donné que vous avez choisi de jouer tout de suite
Et qu'une partie est en cours
Quand vous rouvrez Ustagammon
Alors l'accueil vous propose de reprendre votre partie
Quand la partie se termine
Et que vous rouvrez Ustagammon
Alors l'accueil vous propose une nouvelle partie
@@ -0,0 +1,46 @@
# language: fr
Fonctionnalité: Installer l'application
Installé, Ustagammon a son icône sur l'écran d'accueil du téléphone,
s'ouvre en plein écran et marche sans réseau. L'application vous le
propose au bon moment, une fois, sans jamais interrompre une partie.
Scénario: Comment installer l'application ?
Touchez « Installer » sur l'accueil, ou « Installer l'application » dans
le menu. Avec Chrome, le téléphone vous demande de confirmer. Avec
Firefox ou sur iPhone, l'application vous montre la marche à suivre, en
trois étapes.
Étant donné que votre navigateur ne propose pas lui-même l'installation
Quand vous demandez à installer l'application avec Firefox
Alors la marche à suivre compte trois étapes, en commençant par le menu du navigateur
Quand vous demandez à installer l'application sur iPhone
Alors la marche à suivre compte trois étapes, en commençant par le bouton de partage
Scénario: Quand l'application me propose-t-elle de l'installer ?
Jamais à la première ouverture ni pendant une partie : l'invitation
apparaît sur l'accueil après votre première partie terminée. « Plus
tard » la masque ; elle revient une seule fois, trois parties plus tard.
Le menu garde « Installer l'application » tant qu'elle n'est pas
installée.
Étant donné que vous ouvrez Ustagammon pour la toute première fois
Alors l'accueil ne vous propose pas d'installer l'application
Quand vous terminez une partie
Alors l'accueil vous propose d'installer l'application
Quand vous choisissez « Plus tard »
Alors l'accueil ne vous propose pas d'installer l'application
Mais le menu vous le propose toujours
Quand vous terminez 3 parties de plus
Alors l'accueil vous propose d'installer l'application
Quand vous choisissez « Plus tard »
Et que vous terminez 3 parties de plus
Alors l'accueil ne vous propose pas d'installer l'application
Scénario: Et une fois l'application installée ?
Une fois Ustagammon installé, plus rien ne vous propose de l'installer.
Étant donné que vous avez terminé une partie
Quand vous installez l'application
Alors l'accueil ne vous propose pas d'installer l'application
Et le menu non plus
+48 -23
View File
@@ -1,16 +1,22 @@
# language: fr
Fonctionnalité: Jouer une partie contre l'ordinateur
Vous jouez une partie complète contre l'ordinateur, qui s'appuie sur le
moteur GNU Backgammon. L'application tire les dés, n'accepte que les coups
Vous jouez une partie complète contre l'ordinateur, qui s'appuie sur
GNU Backgammon. L'application tire les dés, n'accepte que les coups
permis par les règles, et l'ordinateur joue et décide du videau tout seul.
Scénario: Commencer une partie
@position:4HPwATDgc/ABMA
Scénario: Comment commence une partie ?
Les pions sont en position de départ. Chaque camp lance un dé : celui
qui a le plus fort commence, et joue ces deux dés. En cas d'égalité, on
relance.
Quand vous commencez une nouvelle partie
Alors les pions sont en position de départ
Et le premier jet de dés désigne qui commence
Scénario: Jouer un coup permis
@position:4HPwATDgc/ABMA
Scénario: Comment jouer un coup ?
Touchez un pion, puis sa case d'arrivée ; s'il n'en a qu'une, il y va dès
que vous le touchez. Chaque dé joué s'estompe. Une fois tous vos dés
joués, votre coup attend que vous le validiez : touchez vos dés, d'un côté
@@ -24,16 +30,17 @@ Fonctionnalité: Jouer une partie contre l'ordinateur
Alors votre coup est accepté
Et c'est ensuite au tour de l'ordinateur
Scénario: Un pion qui n'a qu'une case d'arrivée
Après 8/5, il ne vous reste que le 1 : chaque pion jouable n'a plus
qu'une case d'arrivée, et il y va dès que vous le touchez.
Scénario: Pourquoi mon pion part-il dès que je le touche ?
Un pion qui n'a qu'une case d'arrivée possible y va dès que vous le
touchez, sans attendre un second toucher. Par exemple, avec 3-1, une fois
8/5 joué, il ne vous reste que le 1.
Étant donné que c'est à vous de jouer avec le jet 3-1
Quand vous jouez 8/5
Alors seul votre 3 est marqué comme joué
Et chacun de vos pions jouables n'a plus qu'une case d'arrivée
Scénario: Reprendre un pion avant de valider
Scénario: Je me suis trompé de pion
Tant que vous n'avez pas validé votre coup, « Annuler » reprend le
dernier pion déplacé ; touchez-le encore pour reprendre le précédent.
@@ -45,21 +52,27 @@ Fonctionnalité: Jouer une partie contre l'ordinateur
Alors la position n'a pas changé
Et aucun de vos dés n'est marqué comme joué
Scénario: Un coup interdit est refusé
La case 12 est tenue par l'ordinateur, qui y a cinq pions : vous ne
pouvez pas vous y poser.
@position:4HPwATDgc/ABMA
Scénario: Pourquoi je ne peux pas poser mon pion sur cette flèche ?
Une flèche où l'ordinateur a au moins deux pions est fermée : vous ne
pouvez pas vous y poser. Au départ, par exemple, la flèche 12 est tenue
par cinq de ses pions.
Étant donné que c'est à vous de jouer avec le jet 3-1
Quand vous essayez de jouer 13/12
Alors votre coup est refusé
Et la position n'a pas changé
Scénario: L'ordinateur joue
Scénario: Comment joue l'ordinateur ?
L'ordinateur lance ses dés et joue le coup que GNU Backgammon, l'un des
meilleurs programmes de backgammon, juge le meilleur. Il répond en
moins de deux secondes.
Étant donné que c'est au tour de l'ordinateur
Quand ses dés sont tirés
Alors il joue le coup recommandé par le moteur en moins de 2 secondes
Scénario: Aucun coup possible avec votre jet
Scénario: Je ne peux rien jouer avec mes dés
Il arrive que les dés ne permettent de bouger aucun pion. Votre jet reste
affiché, barré, avec un message. L'application ne joue jamais à votre
place : le tour ne passe à l'ordinateur que quand vous touchez vos dés
@@ -72,7 +85,7 @@ Fonctionnalité: Jouer une partie contre l'ordinateur
Quand vous laissez passer votre tour
Alors c'est ensuite au tour de l'ordinateur
Scénario: Voir le coup de l'ordinateur
Scénario: Qu'a joué l'ordinateur ?
Après chaque coup de l'ordinateur, ses pions glissent un par un sur le
plateau. Restent ensuite, jusqu'à votre prochaine action, une flèche par
pion déplacé, ses pions arrivés entourés et un pion fantôme là où ils
@@ -84,11 +97,13 @@ Fonctionnalité: Jouer une partie contre l'ordinateur
Et votre pion frappé part sur la barre
Et vous pouvez revoir son coup
Scénario: Voir le compte de points
Au-dessus et au-dessous du plateau, le bandeau de chaque camp donne son
compte de points, et « mène de 7 » s'affiche du côté de celui qui mène la
course. Touchez un compte pour lire ce qu'il veut dire. En paysage, les
comptes sont aux deux bouts de la barre.
Scénario: À quoi sert le compte de points ?
Le compte de points dit combien de cases vos pions doivent encore
parcourir avant de sortir : le plus petit mène la course. Au-dessus et
au-dessous du plateau, le bandeau de chaque camp donne son compte, et
« mène de 7 » s'affiche du côté de celui qui mène. Touchez un compte pour
lire ce qu'il veut dire. En paysage, les comptes sont aux deux bouts de
la barre.
Quand vous commencez une nouvelle partie
Alors chaque camp a un compte de points de 167
@@ -96,7 +111,7 @@ Fonctionnalité: Jouer une partie contre l'ordinateur
Quand vous jouez 13/8 13/11
Alors vous menez la course de 7
Scénario: Doubler, après confirmation
Scénario: Comment doubler ?
Avant de lancer les dés, vous pouvez doubler en touchant le videau ou
« Doubler ». L'application vous demande de confirmer : une fois proposé,
un double ne se reprend pas.
@@ -109,19 +124,29 @@ Fonctionnalité: Jouer une partie contre l'ordinateur
Et que vous confirmez
Alors l'ordinateur prend ou passe, comme le recommande le moteur
Scénario: Prendre le double de l'ordinateur
Scénario: L'ordinateur double : que se passe-t-il si je prends ?
Si vous prenez, la partie continue et vaut deux fois plus. Le videau
passe de votre côté : vous seul pourrez redoubler.
Étant donné que l'ordinateur vous propose de doubler
Quand vous prenez
Alors la partie continue à valeur doublée
Et le videau est de votre côté : vous seul pourrez redoubler
Scénario: Passer le double de l'ordinateur
Scénario: L'ordinateur double : que se passe-t-il si je passe ?
Passer, c'est abandonner la partie tout de suite : vous la perdez à sa
valeur d'avant le double.
Étant donné que l'ordinateur vous propose de doubler
Quand vous passez
Alors la partie s'arrête
Et vous perdez la partie à sa valeur d'avant le double
Scénario: Finir la partie
Scénario: Comment se termine une partie ?
La partie s'arrête dès qu'un joueur a sorti tous ses pions. Le panneau de
fin dit qui gagne, et combien de points : la valeur du videau, doublée
pour un gammon et triplée pour un backgammon, une fois le videau doublé.
Étant donné qu'un joueur a sorti tous ses pions
Alors la partie s'arrête
Et le résultat indique le gagnant et les points gagnés
+11 -3
View File
@@ -7,7 +7,11 @@ Fonctionnalité: Retrouver mes parties
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
Scénario: Comment reprendre une autre partie ?
Toutes vos parties restent sur le téléphone. Dans « Mes parties », depuis
le menu, la dernière jouée vient en premier : « Reprendre » vous ramène à
une partie telle que vous l'aviez laissée.
É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
@@ -15,7 +19,7 @@ Fonctionnalité: Retrouver mes parties
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
Scénario: Puis-je rejouer un moment de la partie autrement ?
Revenez à un moment passé de la partie et jouez autre chose : une
nouvelle branche commence là, et la partie d'origine reste entière.
@@ -28,7 +32,11 @@ Fonctionnalité: Retrouver mes parties
Alors vous êtes sur la nouvelle branche
Et vous pouvez revenir à la partie d'origine, au coup près
Scénario: Exporter une partie
Scénario: Comment garder une partie dans un fichier ?
Dans « Mes parties », « Exporter (SGF) » enregistre la partie dans un
fichier que GNU Backgammon sait ouvrir ; les suites rejouées y sont des
variantes.
Étant donné que vous avez joué plusieurs coups
Et que vous avez rejoué d'ici une autre suite
Quand vous exportez la partie
+3 -3
View File
@@ -6,7 +6,7 @@ Fonctionnalité: Partager une position
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
Scénario: Comment partager une 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.
@@ -17,7 +17,7 @@ Fonctionnalité: Partager une position
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
Scénario: On m'a envoyé le lien d'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
@@ -33,7 +33,7 @@ Fonctionnalité: Partager une position
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é
Scénario: Le lien d'une position ne s'ouvre pas
Un lien coupé ou mal recopié ne casse rien : un message le dit, et
« Retour à ma partie » reste là.
+23 -4
View File
@@ -7,7 +7,7 @@ Fonctionnalité: Régler l'application
l'écran allumé pendant la partie. Rien de ce qu'il contient ne vous pose de
question : ce qui se fait se rattrape.
Scénario: Commencer une nouvelle partie, puis reprendre l'ancienne
Scénario: J'ai commencé une nouvelle partie par erreur
Une nouvelle partie commence tout de suite, sans question. Pendant 10
secondes, le bandeau « Reprendre l'ancienne » vous rend la partie que
vous venez de quitter, exactement là où vous l'aviez laissée.
@@ -19,7 +19,7 @@ Fonctionnalité: Régler l'application
Quand vous reprenez l'ancienne partie
Alors vous retrouvez exactement la partie quittée
Scénario: Couper les vibrations
Scénario: Comment couper les vibrations ?
Le téléphone vibre brièvement quand votre pion se pose, quand un pion est
frappé, quand l'ordinateur vous propose de doubler et à la fin de la
partie. Ces vibrations accompagnent toujours ce que montre l'écran ; rien
@@ -32,7 +32,7 @@ Fonctionnalité: Régler l'application
Et qu'un pion est frappé
Alors le téléphone ne vibre pas
Scénario: Garder l'écran allumé pendant la partie
Scénario: Pourquoi l'écran ne se met-il pas en veille ?
Pour que le téléphone ne se mette pas en veille pendant que vous
réfléchissez, l'écran reste allumé tant qu'une partie est en cours.
@@ -41,8 +41,27 @@ Fonctionnalité: Régler l'application
Quand vous coupez « Écran allumé pendant la partie » dans le menu
Alors l'écran peut se mettre en veille comme d'habitude
Scénario: Choisir le thème
Scénario: Comment passer en thème sombre ?
Le thème suit d'abord celui du téléphone. Dans le menu, « Thème » passe à
clair, puis à sombre, puis revient à automatique.
Étant donné que vous ouvrez l'application pour la première fois
Alors le thème suit celui du téléphone
Quand vous changez de thème dans le menu
Alors il passe à clair, puis à sombre, puis revient à automatique
Scénario: Comment mettre l'application à jour ?
Dès qu'une nouvelle version d'Ustagammon est publiée, l'application la
télécharge d'elle-même, puis affiche en bas de l'écran « Nouvelle
version prête. », seulement entre deux tours, jamais pendant que vous
jouez un coup. « Mettre à jour » l'installe et vous ramène où vous en
étiez. Dans le menu, « Rechercher une mise à
jour » vérifie tout de suite s'il y en a une.
Étant donné qu'une nouvelle version d'Ustagammon est disponible
Et que vous avez des dés à jouer
Alors l'application ne vous propose pas encore de mettre à jour
Quand vous déplacez un pion de votre coup
Alors l'application ne vous propose pas encore de mettre à jour
Quand vous avez joué votre coup
Alors l'application vous propose de mettre à jour
+65
View File
@@ -0,0 +1,65 @@
# Tutoriel des règles
Le texte du tutoriel de l'application, en une seule source (ADR 0008) :
`regles.md`. `make site` copie ce dossier dans le site publié
(`docs/tutoriel/`) ; l'application le lit à l'exécution
(`app/src/ui/tutorial-text.ts`), et `make test` vérifie chaque position et
chaque coup avec le vrai moteur (`app/tests/tutorial.test.ts`).
Texte sous licence **CC BY-SA 4.0**. Première version, rédigée par Claude
Code d'après la charte (`docs/identite/charte/accueil-tutoriel-aide.md`),
**à valider par Pierre**.
## Format
Tout ce qui précède la première étape (ce titre, cette notice) n'est pas
affiché.
```markdown
## 3. Les dés et les doubles
- position: 4HPwATDgc/ABMA
- jet: 5-2
- coup: 13/11 13/8
Un paragraphe, avec un [mot du glossaire](pion.md) et du **gras**.
### À vous
- position: 4HPwATDgc/ABMA
- jet: 3-1
- attendu: 8/5 6/5
- juste: La phrase affichée quand le coup est juste.
- refus: La phrase affichée quand le coup est refusé.
La consigne de l'écran.
## Fin
- action: Jouer ma première partie
Le texte de la fin du tutoriel.
```
- `## <n>. <titre>` commence l'étape *n* (huit au plus, dans l'ordre) ;
`### À vous` commence son écran « à vous », facultatif ; `## Fin`, l'écran
qui suit la dernière étape.
- Les lignes `- clé: valeur` en tête d'écran le décrivent :
- `position` : l'identifiant de position de GNU Backgammon (ADR 0013), vu
du camp au trait ;
- `trait` : `ordinateur` si c'est l'ordinateur qui a la main dans cette
position (par défaut, vous) ;
- `jet` et `coup` : sur un écran d'explication, le coup illustré, qui se
joue tout seul (notation de gnubg) ;
- `chemin: oui` : le sens du jeu, dessiné en pointillés sur le plateau ;
- `attendu` (une ligne par coup accepté) : sur un écran « à vous », les
coups justes, en notation de gnubg, tels que le moteur les écrit ;
- `réponse` : `prendre` ou `passer`, pour un écran « à vous » où
l'ordinateur double ; le moteur doit donner la même réponse ;
- `juste` et `refus` : les phrases qui suivent un geste juste, ou refusé ;
- `action` : le libellé du bouton de la fin.
- Les autres lignes forment les paragraphes. `[mot](fiche.md)` renvoie à une
fiche du glossaire, à la première apparition du mot.
Aucune règle n'est écrite dans le code : le moteur lit les positions,
illustre les coups, juge les coups joués et les décisions de videau.
+128
View File
@@ -0,0 +1,128 @@
# Les règles du backgammon
Tutoriel d'Ustagammon, en huit étapes. Texte sous licence CC BY-SA 4.0,
validé par Pierre le 9 novembre 2026. Format : voir README.md.
## 1. Le but du jeu
- position: 4HPwATDgc/ABMA
Vous avez quinze [pions](pion.md) ivoire, l'ordinateur quinze pions noyer. Chacun fait faire le tour du [plateau](plateau.md) à ses pions, puis les [sort](sortie.md).
Le premier qui a sorti tous les siens gagne la [partie](partie.md).
## 2. Dans quel sens avancer
- position: 4HPwATDgc/ABMA
- chemin: oui
Vos pions ivoire font le tour du plateau dans le sens des pointillés : ils partent d'en haut à droite, passent à gauche, puis reviennent en bas jusqu'aux **six dernières flèches**, en bas à droite. C'est là que vous les rassemblerez avant de les sortir.
Les pions noyer de l'ordinateur font le même chemin, en sens inverse.
## 3. Les dés et les doubles
- position: 4HPwATDgc/ABMA
- jet: 5-2
- coup: 13/11 13/8
Chaque dé déplace un pion d'autant de flèches. Avec 5 et 2, vous jouez un pion de 5 et un pion de 2, ou le même pion de 5 puis de 2.
Un double se joue quatre fois : avec 4-4, vous avancez quatre fois de 4.
### À vous
- position: 4HPwATDgc/ABMA
- jet: 3-1
- attendu: 8/5 6/5
- juste: Deux pions sur la flèche 5 : elle est à vous, l'ordinateur ne peut plus s'y poser.
- refus: Ce coup est permis, mais il ne réunit pas deux pions sur la flèche 5. Jouez un pion de la flèche 8 avec le 3, et un de la flèche 6 avec le 1.
Vous avez 3 et 1. Réunissez deux pions sur la flèche 5 : touchez un pion, puis sa case d'arrivée.
## 4. Frapper un pion seul
- position: 4HPwESDgc/ABMA
- jet: 4-2
- coup: 13/11 13/9*
Un pion seul sur une flèche s'appelle un [blot](blot.md). Si vous vous posez dessus, vous le frappez : il part sur la [barre](barre.md) et devra refaire tout le chemin.
Une flèche où l'adversaire a deux pions ou plus est fermée : vous ne pouvez pas vous y poser.
### À vous
- position: 4HPwIDDgc/ABMA
- jet: 6-1
- attendu: 13/7* 8/7
- attendu: 13/7*/6
- attendu: 13/7* 6/5
- attendu: 24/23 13/7*
- attendu: 24/18 8/7*
- attendu: 8/7* 8/2
- juste: Touché : le pion noyer part sur la barre, il devra refaire tout le chemin.
- refus: Ce coup ne frappe pas le pion seul de la flèche 7. Un pion qui s'y pose le frappe : celui de la flèche 13 avec le 6, ou celui de la flèche 8 avec le 1.
Vous avez 6 et 1. Le pion noyer de la flèche 7 est seul : frappez-le.
## 5. Rentrer depuis la barre
- position: 4HPwATDgc/ABUA
- jet: 4-3
- coup: bar/22 13/9
Votre pion frappé attend sur la barre, au milieu. Avant de jouer quoi que ce soit d'autre, il doit rentrer dans le [camp](camp.md) adverse, en haut à droite.
Chaque dé indique une flèche d'entrée : avec 3 et 4, la 22 ou la 21. Si les deux flèches sont fermées, votre tour passe.
## 6. Sortir ses pions
- position: bHcAANh2dwAAAA
- jet: 5-2
- coup: 5/off 2/off
Quand vos quinze pions sont dans les six dernières flèches, vous pouvez commencer à les sortir : un 6 sort un pion de la flèche 6, un 1 un pion de la flèche 1.
Touchez le pion, puis la sortie à droite.
### À vous
- position: bHcAANh2dwAAAA
- jet: 6-3
- attendu: 6/off 3/off
- juste: Deux pions sortis. Plus que treize !
- refus: Ce coup est permis, mais il ne sort qu'un pion. Le 6 sort un pion de la flèche 6, le 3 un pion de la flèche 3.
Vous avez 6 et 3 : sortez deux pions.
## 7. Gammon et backgammon
- position: 8J4HBwAFAAAAAA
- jet: 2-1
- coup: 2/off 1/off
Une partie gagnée vaut 1 point. Si l'adversaire n'a sorti aucun pion, c'est un [gammon](gammon.md) : 2 points ; s'il lui reste en plus un pion sur la barre ou dans votre camp, c'est un [backgammon](backgammon.md) : 3 points. En partie libre, gammon et backgammon ne comptent que si le videau a été doublé : c'est la [règle de Jacoby](regle-de-jacoby.md).
## 8. Le videau
- position: 4HPwATDgc/ABMA
Le cube aux faces 2, 4, 8, 16, 32 et 64 s'appelle le [videau](videau.md) : il sert à miser. Avant de lancer vos dés, vous pouvez proposer de [doubler](doubler.md) la valeur de la partie.
L'ordinateur [prend](prendre.md), et la partie continue en valant le double, ou il [passe](passer.md), et vous gagnez tout de suite. Celui qui a pris garde le cube : lui seul pourra redoubler.
### À vous
- position: 7O7YAADs7jYAAA
- trait: ordinateur
- réponse: prendre
- juste: Le maître prend aussi. L'ordinateur mène la course, mais vous gagnez encore assez souvent pour que prendre vous coûte moins cher, en moyenne, que passer.
- refus: Le maître prendrait. Passer vous coûte un point tout de suite ; en prenant, vous gardez vos chances de gagner, et le videau est chez vous.
C'est à l'ordinateur de jouer, et il mène la course. Il vous propose de doubler : prenez-vous, ou passez-vous ?
## Fin
- action: Jouer ma première partie
Vous connaissez les règles. Le reste s'apprend en jouant, et le maître vous accompagne.
+35 -4
View File
@@ -2,7 +2,11 @@
# Joue une partie complète par l'interface dans Chromium et Firefox sans
# écran (tests/browser/play.mjs), sur le site servi par l'image de production
# construite en local, comme tests/test_site.sh. Les captures d'écran vont
# dans build/browser-shots/.
# dans build/browser-shots/. Puis l'accueil, le tutoriel, l'aide,
# l'installation et la mise à jour (tests/browser/accueil.mjs), avec leurs
# captures dans build/browser-shots/accueil-tutoriel/.
#
# BROWSERS=chromium et SCRIPTS=accueil (ou play) restreignent le test.
#
# Outils : l'image de Playwright (navigateurs) et le paquet playwright,
# installé une fois par npm dans build/browser-tools/ (réseau nécessaire la
@@ -14,6 +18,8 @@ cd "$(dirname "$0")/.."
image="${PLAYWRIGHT_IMAGE:?lancer via make test-browser}"
browsers="${BROWSERS:-chromium firefox}"
# Les scripts à lancer (tests/browser/<nom>.mjs), tous par défaut.
scripts="${SCRIPTS:-play accueil}"
unset MAKEFLAGS MFLAGS MAKELEVEL
make -s package >/dev/null
@@ -33,15 +39,40 @@ if [ ! -f "$tools/node_modules/playwright/package.json" ]; then
"$image" npm install --no-audit --no-fund --save-exact "playwright@$version" >/dev/null
fi
# Pour la mise à jour de l'application (tests/browser/accueil.mjs) : une
# copie du site, servie par la même image mais depuis un dossier que le test
# modifie, pour publier une deuxième version sur la même adresse.
updates=build/browser-site
rm -rf "$updates"
cp -r build/site "$updates"
update_container="$(docker run -d --rm -p 127.0.0.1::80 -v "$PWD/$updates:/srv:ro" "$site_image")"
# shellcheck disable=SC2317 # appelée par trap, à la sortie
update_cleanup() {
docker rm -f "$update_container" >/dev/null 2>&1 || true
site_cleanup
}
trap update_cleanup EXIT
update_port="$(docker port "$update_container" 80/tcp | head -n1 | sed 's/.*://')"
rm -rf build/browser-shots
mkdir -p build/browser-shots
cp tests/browser/play.mjs "$tools/"
cp tests/browser/play.mjs tests/browser/accueil.mjs "$tools/"
status=0
for browser in $browsers; do
# Réseau de l'hôte : le site est sur 127.0.0.1. Le service worker
# demande un contexte sûr, ce qu'est localhost.
if [[ " $scripts " == *" play "* ]]; then
docker run --rm --init --ipc=host --network host --user "$(id -u):$(id -g)" -e HOME=/tmp \
-e TRACE -v "$PWD/$tools:/tools" -v "$PWD/build/browser-shots:/shots" -w /tools "$image" \
node play.mjs "http://localhost:$port/" "$browser" /shots || status=1
fi
[[ " $scripts " == *" accueil "* ]] || continue
# Accueil, tutoriel, aide, installation et mise à jour ; captures de la
# recette dans build/browser-shots/accueil-tutoriel/.
docker run --rm --init --ipc=host --network host --user "$(id -u):$(id -g)" -e HOME=/tmp \
-e TRACE -v "$PWD/$tools:/tools" -v "$PWD/build/browser-shots:/shots" -w /tools "$image" \
node play.mjs "http://localhost:$port/" "$browser" /shots || status=1
-v "$PWD/$tools:/tools" -v "$PWD/build/browser-shots:/shots" -v "$PWD/$updates:/site" \
-w /tools "$image" \
node accueil.mjs "http://localhost:$port/" "$browser" /shots \
"http://localhost:$update_port/" /site || status=1
done
exit "$status"
+548
View File
@@ -0,0 +1,548 @@
// accueil.mjs -- l'accueil, le tutoriel des règles, l'aide, l'installation
// et la mise à jour de l'application, dans un vrai navigateur sans écran
// (Playwright), sur le site servi par l'image de production
// (docs/plans/2026-11-03-accueil-tutoriel-aide.md, point 7).
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
//
// Usage : node accueil.mjs <adresse du site> <chromium|firefox> <dossier des
// captures> <adresse du site modifiable> <dossier de ce site>
// Lancé par tests/browser.sh. Le second site sert la même chose depuis un
// dossier que ce script modifie : il y publie une deuxième version pour
// essayer la mise à jour. Captures de la recette design (Chromium) dans
// <dossier des captures>/accueil-tutoriel/, en 412×915 et 915×412, et aussi
// en 844×320 pour le tutoriel.
import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
import { chromium, firefox } from "playwright";
const [base, browserName, shots, updateBase, updateDir] = process.argv.slice(2);
const browserType = { chromium, firefox }[browserName];
if (!base || !browserType || !shots || !updateBase || !updateDir) {
console.error("usage : node accueil.mjs <adresse> <chromium|firefox> <captures> <adresse 2> <dossier 2>");
process.exit(2);
}
const full = browserName === "chromium";
const dir = `${shots}/accueil-tutoriel`;
mkdirSync(dir, { recursive: true });
const SIZES = [
[412, 915],
[915, 412],
];
// Le tutoriel est aussi vérifié dans le plus petit paysage de la recette.
const TUTORIAL_SIZES = [...SIZES, [844, 320]];
const browser = await browserType.launch();
const problems = [];
const log = (text) => console.log(`[${browserName}] ${text}`);
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
let captures = 0;
function check(condition, message) {
if (!condition) {
throw new Error(message);
}
}
/** Un contexte neuf (stockage vide), avec des réglages déjà enregistrés si
* settings est donné. Erreurs de la page et violations de la CSP relevées. */
async function newPage(settings = null, url = base) {
const context = await browser.newContext({
viewport: { width: 412, height: 915 },
deviceScaleFactor: 1,
locale: "fr-FR",
});
await context.addInitScript((saved) => {
window.__csp = [];
document.addEventListener("securitypolicyviolation", (e) =>
window.__csp.push(`${e.effectiveDirective} ${e.blockedURI}`),
);
// Un élément est-il à l'écran ? (Utilisé par les attentes.)
window.__shown = (id) => {
const el = document.getElementById(id);
return el !== null && !el.closest("[hidden]") && el.getClientRects().length > 0;
};
window.__vibrations = [];
Object.defineProperty(Navigator.prototype, "vibrate", {
configurable: true,
value: (pattern) => {
window.__vibrations.push(String(pattern));
return true;
},
});
if (saved !== null && localStorage.getItem("ustagammon.settings") === null) {
localStorage.setItem("ustagammon.settings", saved);
}
}, settings === null ? null : JSON.stringify(settings));
const page = await context.newPage();
page.on("pageerror", (error) => problems.push(`erreur de la page : ${error.message}`));
page.on("console", (message) => {
if (message.type() === "error") {
problems.push(`console : ${message.text()}`);
}
});
page.on("dialog", (dialog) => {
problems.push(`boîte native : ${dialog.type()}`);
void dialog.dismiss();
});
await page.goto(url);
return { context, page };
}
/** Attend que test (évalué dans la page) soit vrai, au plus ms. (Pas de
* waitForFunction : Playwright y passe par eval, que la CSP interdit.) */
async function until(page, what, test, arg, ms = 30_000) {
let last = null;
for (const end = Date.now() + ms; Date.now() < end; ) {
last = await page.evaluate(test, arg).catch((error) => `page illisible : ${error.message}`);
if (last === true) {
return;
}
await sleep(100);
}
await page.screenshot({ path: `${shots}/${browserName}-accueil-echec.png` }).catch(() => {});
throw new Error(`${what} : attente dépassée (${JSON.stringify(last)})`);
}
/** Range une capture de l'écran courant dans chaque taille (Chromium). */
async function capture(page, name, settle = async () => {}, sizes = SIZES) {
if (!full) {
return;
}
for (const [width, height] of sizes) {
await page.setViewportSize({ width, height });
await sleep(250);
await settle();
await page.screenshot({ path: `${dir}/${name}-${width}x${height}.png`, fullPage: true });
captures++;
}
await page.setViewportSize({ width: 412, height: 915 });
await sleep(150);
}
/** Vérifie la CSP et ferme le contexte. */
async function close(context, page) {
const csp = await page.evaluate(() => window.__csp).catch(() => []);
check(csp.length === 0, `violations de la CSP : ${csp.join(", ")}`);
await context.close();
}
// ------------------------------------------------- Premier lancement
/** L'écran du tutoriel prêt : plateau dessiné pour cette adresse, coup
* illustré joué (« Revoir » utilisable) s'il y en a un. */
async function tutorialReady(page, hash) {
await until(page, `tutoriel ${hash}`, (hash) => {
const section = document.getElementById("tutorial");
const replay = document.getElementById("tutorial-replay");
return location.hash === hash && section.dataset.ready !== undefined &&
document.querySelectorAll("#tutorial-board polygon[data-point]").length === 24 &&
(replay.hidden || !replay.disabled);
}, hash);
}
/** Recette du tutoriel dans la taille courante : numéros des flèches de
* 12 px au moins ; en paysage, rien ne défile et les boutons sont en vue. */
async function checkTutorialView(page, name) {
const view = await page.evaluate(() => {
const svg = document.getElementById("tutorial-board");
const box = svg.getBoundingClientRect();
const vb = svg.viewBox.baseVal;
const scale = Math.min(box.width / vb.width, box.height / vb.height);
const numbers = [...svg.querySelectorAll(".point-number")]
.map((t) => Number(t.getAttribute("font-size")) * scale);
const buttons = ["tutorial-back", "tutorial-next", "tutorial-drop", "tutorial-take"]
.map((id) => document.getElementById(id))
.filter((el) => el !== null && el.closest("[hidden]") === null)
.map((el) => {
const r = el.getBoundingClientRect();
return { id: el.id, top: r.top, bottom: r.bottom };
});
return {
landscape: document.documentElement.classList.contains("landscape"),
minNumber: numbers.length === 0 ? 0 : Math.min(...numbers),
scroll: document.scrollingElement.scrollHeight - innerHeight,
body: (() => {
const b = document.querySelector("#tutorial .tutorial-body");
return b.scrollHeight - b.clientHeight;
})(),
height: innerHeight,
buttons,
};
});
const size = `${name} en ${await page.evaluate(() => `${innerWidth}x${innerHeight}`)}`;
check(view.minNumber >= 12, `${size} : numéros des flèches de ${view.minNumber.toFixed(1)} px`);
if (view.landscape) {
check(view.scroll <= 1, `${size} : la page défile de ${view.scroll} px`);
check(view.body <= 1, `${size} : le texte défile de ${view.body} px`);
if (view.body > tutorialBodyScroll.px) {
tutorialBodyScroll = { px: view.body, where: size };
}
for (const b of view.buttons) {
check(b.top >= 0 && b.bottom <= view.height + 0.5,
`${size} : « ${b.id} » hors de l'écran (${b.top}-${b.bottom} pour ${view.height})`);
}
}
tutorialViews++;
tutorialMinNumber = Math.min(tutorialMinNumber, view.minNumber);
}
let tutorialViews = 0;
let tutorialBodyScroll = { px: 0, where: "" };
let tutorialMinNumber = Infinity;
/** Capture d'un écran du tutoriel, recette comprise, dans les trois tailles. */
async function tutorialCapture(page, name) {
await capture(page, name, () => checkTutorialView(page, name), TUTORIAL_SIZES);
}
/** Touche une case du plateau du tutoriel. */
async function touch(page, place) {
await page.click(`#tutorial-board [data-place="${place}"]`);
await sleep(200);
}
async function verdict(page) {
await until(page, "verdict", () => {
const v = document.getElementById("tutorial-feedback").dataset.verdict;
return v === "right" || v === "wrong";
});
return page.evaluate(() => document.getElementById("tutorial-feedback").dataset.verdict);
}
/** Les gestes des écrans « à vous » : un coup refusé, puis le bon. */
const PRACTICE = {
// 24/21 : ne peut plus mener à 8/5 6/5, refusé tout de suite.
3: { wrong: [["p24", "p21"]], right: [["p8", "p5"], ["p6"]] },
// 24/23 puis 24/18 : un coup complet qui ne frappe pas.
4: { wrong: [["p24", "p23"], ["p24"]], right: [["p13"], ["p8"]] },
// 6/3 : ne sort pas deux pions.
6: { wrong: [["p6", "p3"]], right: [["p6", "off-player"], ["p3"]] },
};
async function playTouches(page, touches) {
for (const group of touches) {
for (const place of group) {
await touch(page, place);
}
await until(page, "demi-coup joué", () => !document.querySelector("#tutorial[data-busy]"));
await sleep(150);
}
}
{
const { context, page } = await newPage();
await until(page, "premier lancement", () =>
location.hash === "#/accueil" && !document.getElementById("home-welcome").hidden);
const first = await page.evaluate(() => ({
topbar: getComputedStyle(document.querySelector(".topbar")).display,
links: [...document.querySelectorAll("#home-welcome a.choice")].map((a) => a.getAttribute("href")),
main: document.getElementById("home-main").hidden,
}));
check(first.topbar === "none" && first.main, `premier lancement : ${JSON.stringify(first)}`);
check(JSON.stringify(first.links) === '["#/tutoriel/1","#/"]', `choix : ${first.links}`);
await capture(page, "01-premier-lancement");
log("premier lancement : « Je découvre » et « Je connais les règles », de vrais liens");
// Le tutoriel complet, par ses liens « Continuer ».
await page.click("#welcome-discover");
let screens = 0;
for (let step = 1; step <= 8; step++) {
const hash = `#/tutoriel/${step}`;
await tutorialReady(page, hash);
// La barre ne mène qu'aux étapes déjà vues.
const links = await page.evaluate(() =>
[...document.querySelectorAll("#tutorial-progress a")].map((a) => a.getAttribute("href")));
const expected = Array.from({ length: step - 1 }, (_, i) => `#/tutoriel/${i + 1}`);
check(JSON.stringify(links) === JSON.stringify(expected),
`étape ${step} : barre de progression ${JSON.stringify(links)}`);
const illustrated = await page.evaluate(() => !document.getElementById("tutorial-replay").hidden);
if (illustrated) {
check(await page.locator("#tutorial-board .trace-arrow").count() > 0,
`étape ${step} : le coup illustré n'a pas laissé de traces`);
// « Revoir » rejoue le coup.
await page.click("#tutorial-replay");
await tutorialReady(page, hash);
}
if (step === 2) {
check(await page.locator("#tutorial-board .tutorial-path").count() === 1,
"étape 2 : pas de chemin en pointillés");
// Sous les pions et sous les numéros des flèches.
const order = await page.evaluate(() => {
const children = [...document.getElementById("tutorial-board").children];
const at = (selector) => children.findIndex((c) => c.matches(selector));
const numbers = children.filter((c) => c.matches(".point-number")).map((c) => children.indexOf(c));
return { path: at(".tutorial-path"), pieces: at("g.pieces"), firstNumber: Math.min(...numbers) };
});
check(order.path >= 0 && order.path < order.pieces && order.path < order.firstNumber,
`étape 2 : le chemin passe au-dessus des pions ou des numéros ${JSON.stringify(order)}`);
}
await tutorialCapture(page, `tutoriel-${step}`);
screens++;
await page.click("#tutorial-next");
const practice = await page.evaluate(() => location.hash.endsWith("/a-vous"));
if (!practice) {
continue;
}
const avous = `${hash}/a-vous`;
await tutorialReady(page, avous);
check(await page.evaluate(() => document.getElementById("tutorial-next").classList.contains("secondary")),
`étape ${step}, à vous : « Continuer » principal avant le geste`);
await tutorialCapture(page, `tutoriel-${step}-a-vous`);
if (step === 8) {
await page.click("#tutorial-drop");
check(await verdict(page) === "wrong", "étape 8 : « Passer » accepté");
await tutorialCapture(page, "tutoriel-8-a-vous-refuse");
await page.click("#tutorial-take");
await until(page, "étape 8 juste", () =>
document.getElementById("tutorial-feedback").dataset.verdict === "right");
} else {
const moves = PRACTICE[step];
await playTouches(page, moves.wrong);
check(await verdict(page) === "wrong", `étape ${step} : coup faux accepté`);
// Le pion revient : de nouveau des pions jouables.
await until(page, "pion revenu", () => document.querySelectorAll("#tutorial-board .hit.can-move").length > 0);
const words = await page.textContent("#tutorial-feedback");
check(!/erreur/i.test(words), `étape ${step} : « erreur » dans le refus`);
await tutorialCapture(page, `tutoriel-${step}-a-vous-refuse`);
await playTouches(page, moves.right);
check(await verdict(page) === "right", `étape ${step} : bon coup refusé`);
check(await page.locator("#tutorial-board .hit.can-move").count() === 0,
`étape ${step} : plateau encore jouable après le bon coup`);
}
check(!(await page.evaluate(() => document.getElementById("tutorial-next").classList.contains("secondary"))),
`étape ${step} : « Continuer » pas principal après le bon geste`);
const vibrated = await page.evaluate(() => window.__vibrations.at(-1));
check(vibrated === "12", `étape ${step} : vibration ${vibrated}`);
await tutorialCapture(page, `tutoriel-${step}-a-vous-reussi`);
screens++;
await page.click("#tutorial-next");
}
await until(page, "fin du tutoriel", () => location.hash === "#/tutoriel/fin" &&
document.getElementById("tutorial").dataset.ready !== undefined);
await tutorialCapture(page, "tutoriel-fin");
// Le retour du téléphone : l'écran d'avant.
await page.goBack();
await tutorialReady(page, "#/tutoriel/8/a-vous");
await page.goForward();
await until(page, "fin", () => location.hash === "#/tutoriel/fin");
await page.click("#tutorial-next");
await until(page, "première partie", () => location.hash === "#/" && window.__shown("game"));
log(`tutoriel : ${screens} écrans, gestes refusés puis justes sur les 4 écrans « à vous », fin` +
(full ? ` ; recette sur ${tutorialViews} vues, numéros de ${tutorialMinNumber.toFixed(1)} px au moins, ` +
`texte défilant au plus de ${tutorialBodyScroll.px} px${tutorialBodyScroll.px > 0 ? ` (${tutorialBodyScroll.where})` : ""}` : ""));
// Le choix ne revient plus : l'accueil, ensuite.
await page.goto(base);
await until(page, "accueil", () => location.hash === "#/accueil" &&
document.getElementById("home-welcome").hidden && !document.getElementById("home-main").hidden);
await close(context, page);
}
// --------------------------------------------------- Accueil, installation
{
// Une partie terminée : l'invitation à installer apparaît.
const settings = { welcomed: true, gamesStarted: 2, gamesFinished: 1, tutorialSeen: 99 };
const { context, page } = await newPage(settings);
await until(page, "accueil prêt", () => location.hash === "#/accueil" &&
!document.getElementById("home-new").disabled && !document.getElementById("install-card").hidden);
const home = await page.evaluate(() => ({
resume: document.getElementById("home-resume").getAttribute("href"),
primaries: [...document.querySelectorAll("#home a.button:not(.secondary), #home button:not(.secondary):not(.icon-button)")]
.filter((el) => !el.closest("[hidden]")).length,
rules: document.getElementById("home-rules").getAttribute("href"),
}));
check(home.resume === "#/" && home.primaries === 1 && home.rules === "#/tutoriel/1",
`accueil : ${JSON.stringify(home)}`);
await capture(page, "02-accueil");
await capture(page, "03-carte-installation", async () => {
await page.locator("#install-card").scrollIntoViewIfNeeded();
});
// Sans invite du navigateur : la marche à suivre, une feuille avec son
// adresse, que le retour referme.
await page.click("#install-card-button");
await until(page, "feuille d'installation", () => location.hash === "#/installer" && window.__shown("install-sheet"));
check((await page.locator("#install-steps li").count()) === 3, "marche à suivre sans trois étapes");
await capture(page, "04-feuille-installation");
await page.goBack();
await until(page, "feuille refermée", () => location.hash === "#/accueil" && !window.__shown("install-sheet"));
if (browserName === "chromium") {
// L'invite de Chrome, simulée : « Installer » l'ouvre.
await page.evaluate(() => {
window.__prompted = 0;
const event = new Event("beforeinstallprompt", { cancelable: true });
event.prompt = () => {
window.__prompted++;
return Promise.resolve();
};
event.userChoice = Promise.resolve({ outcome: "dismissed" });
window.dispatchEvent(event);
});
await page.click("#install-card-button");
await until(page, "invite du navigateur", () => window.__prompted === 1);
check(await page.evaluate(() => location.hash) === "#/accueil", "l'invite a ouvert la feuille");
// Installée : plus aucune proposition.
await page.evaluate(() => window.dispatchEvent(new Event("appinstalled")));
await until(page, "installée", () => document.getElementById("install-card").hidden &&
document.getElementById("menu-install").hidden);
log("installation : invite de Chrome simulée, puis plus rien une fois installée");
} else {
await page.click("#install-later");
await until(page, "plus tard", () => document.getElementById("install-card").hidden);
log("installation : marche à suivre, « Plus tard »");
}
await close(context, page);
}
// ------------------------------------------------------------------- Aide
{
const { context, page } = await newPage({ welcomed: true, gamesStarted: 2 }, `${base}#/aide`);
await until(page, "aide", () => window.__shown("help-play") && document.getElementById("help-content").dataset.loaded === "true");
const help = await page.evaluate(() => ({
steps: document.querySelectorAll("#help-play .steps, #help-play li").length,
questions: [...document.querySelectorAll("#help-play .question h3")].map((h) => h.textContent),
tutorial: document.getElementById("help-tutorial")?.getAttribute("href"),
}));
check(help.steps === 0, "aide : les étapes Gherkin sont affichées");
check(help.questions.length >= 25 && help.tutorial === "#/tutoriel/1", `aide : ${JSON.stringify(help)}`);
check(help.questions.filter((q) => q.endsWith("?")).length >= 20, "aide : trop peu de questions");
await capture(page, "05-aide-jouer");
const question = await page.evaluate(() =>
[...document.querySelectorAll("#help-play .question")].find((q) => q.querySelector(".see-board"))?.id);
await page.click(`#${question} h3 a`);
await until(page, "question ouverte", (id) => document.getElementById(id).classList.contains("open"), question);
await capture(page, "06-aide-question");
// Le retour du téléphone referme la question.
await page.goBack();
await until(page, "question refermée", () => location.hash === "#/aide" && !document.querySelector(".question.open"));
await page.click('#help-tabs a[data-screen="glossary"]');
await until(page, "glossaire", () => window.__shown("help-glossary"));
const before = await page.locator("#help-glossary .term-card:not([hidden])").count();
await page.fill("#glossary-search", "sortir");
await sleep(200);
const after = await page.locator("#help-glossary .term-card:not([hidden])").count();
check(after > 0 && after < before, `recherche : ${after} fiches sur ${before}`);
await capture(page, "07-aide-glossaire-recherche");
await page.click('#help-tabs a[data-screen="about"]');
await until(page, "à propos", () => window.__shown("help-about"));
const version = await page.textContent("#about-version");
check(/^Version publiée : [0-9a-f]{12}\.$/.test(version), `version : « ${version} »`);
await capture(page, "08-aide-a-propos");
log(`aide : ${help.questions.length} questions, recherche dans le glossaire (${after}/${before}), ${version}`);
await close(context, page);
}
// ----------------------------------------------------------- Mise à jour
if (browserName === "chromium") {
const { context, page } = await newPage({ welcomed: true, gamesStarted: 2 }, `${updateBase}#/`);
await until(page, "partie prête", () => !document.body.classList.contains("busy"), undefined, 60_000);
// Le service worker installé et aux commandes (première installation :
// sans attendre l'accord du joueur).
await until(page, "service worker", async () => {
const reg = await navigator.serviceWorker.getRegistration();
return reg?.active?.state === "activated" && navigator.serviceWorker.controller !== null;
}, undefined, 120_000);
const journal = () => page.evaluate(() => localStorage.length >= 0 &&
new Promise((resolve) => {
const open = indexedDB.open("ustagammon");
open.onsuccess = () => {
const db = open.result;
const tx = db.transaction(["settings"]);
const current = tx.objectStore("settings").get("current");
current.onsuccess = () => {
db.close();
resolve(current.result?.value ?? null);
};
};
open.onerror = () => resolve(null);
}));
check(await page.isHidden("#update-banner"), "bandeau de mise à jour sans nouvelle version");
// Si vous avez la main (jet d'ouverture), le bandeau attend la fin du
// tour : on publie la nouvelle version d'abord, puis on joue le coup.
const toPlay = () => page.evaluate(() => {
if (document.body.classList.contains("busy")) {
return "busy";
}
const usable = (id) => {
const b = document.getElementById(id);
return b !== null && !b.hidden && !b.disabled;
};
for (const id of ["roll", "again", "confirm", "skip", "take"]) {
if (usable(id)) {
return id;
}
}
if (document.querySelector("#board .hit.can-land")) {
return "land";
}
return document.querySelector("#board .hit.can-move") ? "move" : "busy";
});
// Une deuxième version publiée : autre version.json, autre sw.js.
writeFileSync(`${updateDir}/version.json`, '{"commit": "0123456789abcdef0123", "dirty": false}\n');
const sw = readFileSync(`${updateDir}/sw.js`, "utf8");
writeFileSync(`${updateDir}/sw.js`, sw.replace(/^const VERSION = "[0-9a-f]+";$/m, 'const VERSION = "0000000000000002";'));
await page.click("#menu-button");
await page.waitForSelector("#menu", { state: "visible" });
await page.click("#menu-update");
await until(page, "nouvelle version trouvée", () =>
document.getElementById("update-text")?.textContent !== "Recherche d'une mise à jour…", undefined, 120_000);
// Pendant votre coup, jamais de bandeau : seulement entre deux tours.
let waited = false;
for (let i = 0; i < 40; i++) {
const todo = await toPlay();
if (todo === "roll" || todo === "again") {
break;
}
if (todo === "busy") {
await sleep(100);
continue;
}
if (todo === "move" || todo === "land" || todo === "confirm") {
await sleep(400);
check(await page.isHidden("#update-banner"), `bandeau de mise à jour pendant votre coup (${todo})`);
waited = true;
}
await page.click(todo === "move" ? "#board .hit.can-move" : todo === "land" ? "#board .hit.can-land" : `#${todo}`);
await sleep(200);
}
await until(page, "bandeau de mise à jour", () => window.__shown("update-banner") && window.__shown("update-apply"), undefined, 120_000);
check((await page.textContent("#update-text")) === "Nouvelle version prête.",
`bandeau : « ${await page.textContent("#update-text")} »`);
// En bas, au-dessus de la barre d'actions, comme « Reprendre l'ancienne ».
const place = await page.evaluate(() => {
const banner = document.getElementById("update-banner").getBoundingClientRect();
const actions = document.getElementById("actions").getBoundingClientRect();
const top = document.querySelector(".topbar").getBoundingClientRect();
return { bottom: banner.bottom, top: banner.top, actions: actions.top, bar: top.bottom };
});
check(place.bottom <= place.actions && place.top > place.bar,
`bandeau de mise à jour mal placé : ${JSON.stringify(place)}`);
const gameBefore = await journal();
await capture(page, "09-mise-a-jour");
await Promise.all([page.waitForEvent("load", { timeout: 60_000 }), page.click("#update-apply")]);
await until(page, "partie reprise", () => location.hash === "#/" && !document.body.classList.contains("busy"), undefined, 60_000);
check(await journal() === gameBefore, "après la mise à jour, ce n'est plus la même partie");
await page.goto(`${updateBase}#/a-propos`);
await until(page, "nouvelle version", () =>
document.getElementById("about-version")?.textContent === "Version publiée : 0123456789ab.");
log(`mise à jour : deuxième version publiée, bandeau ${waited ? "retenu pendant votre coup, puis " : ""}entre deux tours, « Mettre à jour », partie retrouvée, nouvelle version`);
// Rien de neuf : « Vous avez la dernière version ».
await page.goto(`${updateBase}#/`);
await until(page, "partie", () => !document.body.classList.contains("busy"), undefined, 60_000);
await page.click("#menu-button");
await page.waitForSelector("#menu", { state: "visible" });
await page.click("#menu-update");
await until(page, "dernière version", () =>
document.getElementById("update-text").textContent === "Vous avez la dernière version.", undefined, 60_000);
await close(context, page);
}
await browser.close();
if (problems.length > 0) {
console.error(problems.join("\n"));
process.exit(1);
}
log(`aucune erreur dans la page${full ? ` ; ${captures} captures dans accueil-tutoriel/` : ""}`);
+33 -8
View File
@@ -677,6 +677,9 @@ async function screenOf(tab) {
term: document.querySelector("#help-glossary dt.current")?.id ?? null,
menu: shown("menu"),
games: shown("games"),
home: shown("home"),
tutorial: shown("tutorial"),
install: shown("install-sheet"),
listed: document.querySelectorAll("#games .games-item").length,
expanded:
document
@@ -747,17 +750,19 @@ async function checkAddresses() {
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],
["#/accueil", (s) => s.home && !s.game && !s.tutorial],
["#/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],
["#/tutoriel", (s) => s.hash === "#/tutoriel/1" && s.tutorial && !s.game],
["#/tutoriel/2", (s) => s.tutorial && !s.home],
["#/tutoriel/3/a-vous", (s) => s.tutorial && s.hash === "#/tutoriel/3/a-vous"],
["#/tutoriel/fin", (s) => s.tutorial && s.hash === "#/tutoriel/fin"],
["#/installer", (s) => s.install && s.hash === "#/installer"],
["#/parties", (s) => s.games && s.listed >= 2 && !s.menu],
[() => `#/parties/${gameId}`, (s) => s.games && s.expanded === gameId],
["#/menu", (s) => s.menu && !s.games],
@@ -810,7 +815,7 @@ async function checkAddresses() {
const seen = await tab.evaluate(() => {
const bar = document.getElementById("help-bar").getBoundingClientRect();
const target = document.querySelector(
"#help-play .question.open, #help-glossary dt.current",
"#help-play .question.open, #help-glossary .term-card.current",
).getBoundingClientRect();
return { scrolled: window.scrollY, barTop: bar.top, barBottom: bar.bottom, top: target.top };
});
@@ -875,8 +880,26 @@ async function checkAddresses() {
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);
// Le bouton « Aide » ouvre la question du moment de la partie
// (help-context.ts) ; ici, l'onglet « Jouer » sans question.
// L'adresse est lue au moment du toucher : la partie qui vient de
// s'ouvrir peut encore changer de moment (jet d'ouverture).
const helpHref = await tab.evaluate(() => {
const button = document.getElementById("help-button");
const href = button.getAttribute("href");
button.click();
return href;
});
check(/^#\/aide(\/[a-z0-9-]+)?$/.test(helpHref), `bouton Aide : ${helpHref}`);
await expectScreen(tab, "aide par son lien", (s) => s.hash === helpHref && s.help && s.play);
if (helpHref !== "#/aide") {
await tab.goBack();
await expectScreen(tab, "retour à la partie", playing);
await tab.evaluate(() => {
location.hash = "#/aide";
});
await expectScreen(tab, "aide", (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`);
}
@@ -1018,7 +1041,9 @@ await context.route("**/wasm/engine.data", async (route) => {
await route.continue();
});
const start = Date.now();
await page.goto(base);
// « #/ » : la partie ; sans adresse d'écran, l'application s'ouvrirait sur
// l'accueil (tests/browser/accueil.mjs).
await page.goto(`${base}#/`);
await page.waitForSelector("#board polygon[data-point]");
{
const loading = await page.evaluate(() => ({
+9
View File
@@ -83,6 +83,15 @@ check "docs/glossaire/README.md" "text/markdown"
check "docs/glossaire/videau.md" "text/markdown"
cmp -s <(curl -s "$base/docs/glossaire/videau.md") docs/glossaire/videau.md \
|| report "/docs/glossaire/videau.md diffère de la source"
# Tutoriel : copie de sa source unique (ADR 0008).
check "docs/tutoriel/regles.md" "text/markdown"
cmp -s <(curl -s "$base/docs/tutoriel/regles.md") docs/tutoriel/regles.md \
|| report "/docs/tutoriel/regles.md diffère de la source"
for feature in apprendre-les-regles decouvrir-l-application installer-l-application; do
check "docs/scenarios/$feature.feature" "text/plain"
done
# Le service worker attend l'accord du joueur avant de prendre la main.
grep -q 'skip-waiting' build/site/sw.js || report "sw.js : pas d'attente de l'accord du joueur"
# Le manifeste désigne des icônes servies, et l'application à la racine.
manifest="$(curl -s "$base/manifest.webmanifest")"
+720 -2
View File
@@ -1293,7 +1293,7 @@ html.rotated .banner {
box-shadow: var(--shadow-raised);
}
.glossary dt {
.glossary .term-card {
padding-top: var(--space-3);
font-weight: 700;
}
@@ -1337,7 +1337,7 @@ html.rotated .banner {
}
.help .question,
.glossary dt {
.glossary .term-card {
scroll-margin-top: calc(var(--help-bar-height) + 12px + env(safe-area-inset-top));
}
@@ -1542,6 +1542,724 @@ a.link-button {
height: 20px;
}
/* ------------------------------------------- Accueil, tutoriel, aide */
/* Maquettes « Étape 0e.5 » et « Accueil » ; règles :
* docs/identite/charte/accueil-tutoriel-aide.md. */
.brand-link {
display: flex;
align-items: center;
min-height: 44px;
}
/* Le tutoriel et le premier lancement ont leur propre en-tête. */
html.bare .topbar {
display: none;
}
.home,
.tutorial {
max-width: var(--page-width);
margin: 0 auto;
padding-bottom: var(--space-6);
}
.overline {
margin: 0;
font-size: 13px;
line-height: 16px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--muted);
}
.muted {
color: var(--muted);
}
.caption {
margin: 0;
font-size: 13px;
line-height: 18px;
color: var(--muted);
}
.card {
display: block;
padding: var(--space-4);
color: var(--text);
text-decoration: none;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-md);
box-shadow: var(--shadow-raised);
}
/* Une étiquette d'action dans une carte-lien, en contour. */
.pill {
display: inline-flex;
flex: none;
align-items: center;
min-height: 44px;
padding: 0 16px;
font-weight: 700;
color: var(--text);
border: 2px solid var(--border-strong);
border-radius: var(--radius-pill);
}
/* Premier lancement. */
.welcome {
display: flex;
flex-direction: column;
gap: var(--space-6);
min-height: 100vh;
min-height: 100dvh;
padding: var(--space-6) 0 var(--space-6);
}
.welcome-logo {
margin: 0;
}
.welcome-logo img {
height: 34px;
width: auto;
}
.welcome-board {
margin-inline: calc(-1 * var(--space-2));
width: calc(100% + 2 * var(--space-2));
}
.welcome-text h2,
.home-intro h2 {
margin: 0 0 var(--space-2);
font-size: 32px;
line-height: 36px;
font-weight: 800;
letter-spacing: -0.01em;
}
.welcome-text p,
.home-intro p {
margin: 0;
}
.welcome-choices {
display: flex;
flex-direction: column;
gap: 10px;
margin-top: auto;
}
.choice {
display: flex;
flex-direction: column;
justify-content: center;
min-height: 64px;
padding: 10px 20px;
color: var(--text);
text-decoration: none;
border: 2px solid var(--border-strong);
border-radius: 16px;
touch-action: manipulation;
-webkit-tap-highlight-color: transparent;
}
.choice span {
font-size: 15px;
line-height: 20px;
color: var(--muted);
}
.choice.primary {
color: var(--on-accent);
background: var(--accent);
border-color: var(--accent);
}
.choice.primary span {
color: var(--on-accent);
}
.choice:active {
transform: translateY(1px);
filter: brightness(0.94);
}
.welcome-choices .caption {
margin-top: var(--space-1);
text-align: center;
}
/* Accueil. */
.home-main {
display: flex;
flex-direction: column;
gap: 20px;
padding-top: var(--space-2);
}
.home-game {
display: flex;
flex-direction: column;
gap: 14px;
}
.home-game-top {
display: flex;
gap: 14px;
align-items: center;
}
.home-mini {
flex: none;
width: 132px;
}
/* La miniature ne porte pas les numéros des flèches, illisibles à cette
* taille. */
.home-board .point-number {
display: none;
}
.home-facts {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.home-pips {
font-size: 15px;
line-height: 20px;
color: var(--muted);
font-variant-numeric: tabular-nums lining-nums;
}
.home-actions {
display: flex;
gap: var(--space-2);
}
.home-actions > * {
flex: 1 1 0;
min-width: 0;
padding: 0 12px;
}
.install-card {
display: flex;
gap: var(--space-3);
align-items: center;
margin-top: calc(-1 * var(--space-2));
padding: 10px 4px 10px 14px;
background: var(--surface-sunken);
border-radius: var(--radius-md);
}
.install-card .install-symbol {
flex: none;
width: 32px;
height: 32px;
}
.install-text {
display: flex;
flex: 1;
flex-direction: column;
min-width: 0;
font-size: 15px;
line-height: 20px;
}
.install-card button.secondary {
flex: none;
min-height: 44px;
padding: 0 14px;
background: var(--surface);
}
.install-card .icon-button .icon {
width: 18px;
height: 18px;
color: var(--muted);
}
.home-rules {
display: flex;
gap: var(--space-3);
align-items: center;
}
.home-rules-text {
display: flex;
flex: 1;
flex-direction: column;
min-width: 0;
}
.home-rules-text .muted {
font-size: 15px;
line-height: 20px;
}
.home-progress:empty {
display: none;
}
.home-footer {
display: flex;
gap: 10px;
align-items: center;
margin-top: var(--space-4);
font-size: 13px;
line-height: 18px;
color: var(--muted);
}
.home-footer img {
flex: none;
width: 28px;
height: 28px;
}
/* Feuille d'installation. */
.sheet-head .install-symbol {
width: 40px;
height: 40px;
margin-right: var(--space-3);
}
#install-sheet .sheet-title {
flex: 1;
}
.install-lead {
margin: var(--space-2) 0 var(--space-4);
color: var(--muted);
}
.install-steps {
display: flex;
flex-direction: column;
gap: var(--space-3);
margin: 0 0 var(--space-4);
padding: 0;
list-style: none;
}
.install-steps li {
display: flex;
gap: var(--space-3);
align-items: flex-start;
}
.step-number {
display: flex;
flex: none;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
font-weight: 800;
font-variant-numeric: tabular-nums lining-nums;
background: var(--accent-soft);
border-radius: 50%;
}
.install-done {
width: 100%;
}
/* Tutoriel. */
.tutorial-head {
display: flex;
gap: var(--space-3);
align-items: center;
min-height: 52px;
padding-top: var(--space-2);
}
.tutorial-progress {
display: grid;
flex: 1;
grid-template-columns: repeat(8, minmax(0, 1fr));
gap: var(--space-1);
}
/* Un segment : 4 px de trait, dans une zone de 44 px de haut quand c'est
* un lien vers une étape déjà vue. */
.tutorial-progress .segment {
position: relative;
display: block;
height: 44px;
}
.tutorial-progress .segment::before {
content: "";
position: absolute;
top: 20px;
left: 0;
right: 0;
height: 4px;
background: var(--border);
border-radius: var(--radius-pill);
}
.tutorial-progress .segment.done::before {
background: var(--accent);
}
.tutorial-progress .segment[aria-current]::before {
top: 19px;
height: 6px;
}
.tutorial-skip {
display: inline-flex;
align-items: center;
min-height: 44px;
font-size: 15px;
font-weight: 500;
color: var(--muted);
}
.tutorial-title {
margin: var(--space-1) 0 var(--space-3);
font-size: 24px;
line-height: 30px;
}
.tutorial-board-box {
position: relative;
}
@media (max-width: 40rem) {
.tutorial-board-box {
margin-inline: calc(var(--space-2) - var(--space-4));
}
}
#tutorial-replay {
position: absolute;
right: var(--space-1);
bottom: calc(-44px - var(--space-1));
color: var(--accent);
}
.tutorial-text {
margin-top: var(--space-3);
padding-right: 52px;
}
.tutorial-text p {
margin: 0 0 10px;
}
.tutorial-task {
margin: var(--space-3) 0 0;
padding: 10px 14px;
font-weight: 700;
background: var(--accent-soft);
border-radius: var(--radius-md);
}
.tutorial-feedback {
display: flex;
gap: var(--space-2);
align-items: flex-start;
min-height: 25px;
margin: var(--space-3) 0 0;
}
.tutorial-feedback:empty {
min-height: 0;
}
.tutorial-feedback[data-verdict="right"] {
font-weight: 700;
}
.tutorial-feedback[data-verdict="right"] .icon {
color: var(--verdict-good);
}
.tutorial-feedback[data-verdict="info"] {
color: var(--muted);
}
.tutorial-cube {
display: flex;
gap: var(--space-2);
margin-top: var(--space-3);
}
.tutorial-cube button {
flex: 1 1 0;
}
.tutorial-nav {
display: grid;
grid-template-columns: 1fr 1.4fr;
gap: var(--space-2);
margin-top: var(--space-6);
}
.tutorial-nav .button {
min-width: 0;
}
/* Le sens du jeu : pointillés text sur un liseré ivoire, jamais en brique
* (la brique veut dire « à faire »). Couleurs de marque, les mêmes dans les
* deux thèmes : le liseré reste lisible sur le bois. */
.tutorial-path path {
fill: none;
stroke-linecap: round;
stroke-linejoin: round;
pointer-events: none;
}
.tutorial-path .path-edge {
stroke: var(--brand-ivoire);
}
.tutorial-path .path-dots,
.tutorial-path .path-head {
stroke: var(--brand-noyer);
}
/* En paysage, comme la position partagée : le plateau à gauche sur toute
* la hauteur (avec la colonne gauche du cadre), et à droite une colonne
* avec la barre de progression, le titre, le texte, la consigne « à vous »
* et les boutons, le tout sans défilement de la page. Si le texte est trop
* long pour l'écran, lui seul défile : les boutons restent en vue. */
html.landscape .tutorial {
position: fixed;
inset: 0;
display: grid;
grid-template-columns: minmax(0, 1fr) clamp(288px, 44vw, 400px);
grid-template-rows: auto auto minmax(0, 1fr) auto auto;
max-width: none;
margin: 0;
padding: 0;
overflow: hidden;
background: var(--background);
}
html.landscape .tutorial > * {
grid-column: 2;
margin-inline: 0;
padding-inline: var(--space-4) calc(var(--space-4) + env(safe-area-inset-right));
}
html.landscape .tutorial .overline {
display: none;
}
html.landscape .tutorial-head {
grid-row: 1;
min-height: 44px;
padding-top: 0;
}
html.landscape .tutorial-title {
grid-row: 2;
margin: 0 0 var(--space-1);
padding-right: calc(44px + var(--space-4));
font-size: 20px;
line-height: 26px;
}
/* La boîte du plateau s'efface : le plateau et « Revoir » se placent
* eux-mêmes dans la grille. */
html.landscape .tutorial-board-box {
display: contents;
}
html.landscape .tutorial-board {
grid-row: 1 / -1;
grid-column: 1;
width: 100%;
height: 100%;
margin: 0;
aspect-ratio: auto;
background: var(--frame);
}
html.landscape #tutorial-replay {
position: static;
grid-row: 2;
grid-column: 2;
justify-self: end;
align-self: start;
margin: -9px calc(var(--space-2) + env(safe-area-inset-right)) 0 0;
padding: 0;
}
html.landscape .tutorial-body {
grid-row: 3;
min-height: 0;
overflow-y: auto;
font-size: 15px;
line-height: 20px;
}
html.landscape .tutorial-text {
margin: 0;
padding-right: 0;
}
html.landscape .tutorial-text p {
margin-bottom: var(--space-2);
}
html.landscape .tutorial-task {
margin: 0;
padding: var(--space-2) 12px;
}
html.landscape .tutorial-feedback {
min-height: 0;
margin: var(--space-2) 0 0;
}
html.landscape .tutorial-cube {
grid-row: 4;
margin-top: var(--space-2);
}
/* La réponse au double donnée et saluée, les deux boutons n'ont plus
* d'usage : en paysage, leur place revient au texte. */
html.landscape .tutorial[data-solved] .tutorial-cube {
display: none;
}
/* Écran très bas (844 × 320) : texte un cran plus petit. */
@media (max-height: 360px) {
html.landscape .tutorial-body {
font-size: 14px;
line-height: 18px;
}
html.landscape .tutorial-title {
font-size: 18px;
line-height: 24px;
}
}
html.landscape .tutorial-nav {
grid-row: 5;
margin: 0;
padding-block: var(--space-2) calc(var(--space-2) + env(safe-area-inset-bottom));
}
/* Le premier lancement, en paysage : le plateau tient dans la hauteur. */
html.landscape .welcome-board {
width: min(100%, calc(70vh * 640 / 520));
height: auto;
margin-inline: auto;
aspect-ratio: 640 / 520;
}
html.landscape .welcome {
min-height: 0;
}
/* Aide. */
.help-tutorial {
display: flex;
gap: var(--space-3);
align-items: center;
margin-top: var(--space-4);
}
.help-tutorial-text {
display: flex;
flex: 1;
flex-direction: column;
min-width: 0;
}
.help-tutorial-text .muted {
font-size: 15px;
line-height: 20px;
}
.help-intro {
margin-top: var(--space-4);
}
.see-board {
display: inline-flex;
align-items: center;
min-height: 44px;
font-weight: 500;
}
.glossary-search {
display: flex;
flex-direction: column;
gap: var(--space-1);
margin-top: var(--space-3);
}
.search-label {
font-size: 15px;
line-height: 20px;
font-weight: 500;
}
.glossary-search input {
min-height: 48px;
padding: 0 var(--space-3);
font: inherit;
color: var(--text);
background: var(--surface);
border: 1px solid var(--border-strong);
border-radius: var(--radius-md);
user-select: text;
-webkit-user-select: text;
}
.term-card + .term-card {
border-top: 1px solid var(--border);
}
.glossary .english {
margin-left: var(--space-2);
font-size: 13px;
font-style: italic;
font-weight: 400;
color: var(--muted);
}
.glossary dd p {
margin: 0 0 var(--space-1);
}
.see-also {
font-size: 15px;
line-height: 20px;
color: var(--muted);
}
.glossary .term-card.current {
margin-inline: calc(-1 * var(--space-4));
padding-inline: var(--space-4);
background: var(--accent-soft);
}
.glossary .term-card.current dt,
.glossary .term-card.current dd {
margin-inline: 0;
padding-inline: 0;
background: none;
}
/* 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. */
+35 -2
View File
@@ -26,8 +26,10 @@
<!-- Logo de la charte (docs/identite/logo/, copié dans icons/ par make
site), version sombre en thème sombre (choix par app.css). -->
<h1 class="brand">
<img class="logo-light" src="icons/logo-horizontal-couleur.svg" alt="Ustagammon">
<img class="logo-dark" src="icons/logo-horizontal-sombre.svg" alt="Ustagammon">
<a id="home-link" href="#/accueil" class="brand-link">
<img class="logo-light" src="icons/logo-horizontal-couleur.svg" alt="Ustagammon">
<img class="logo-dark" src="icons/logo-horizontal-sombre.svg" alt="Ustagammon">
</a>
</h1>
<!-- Ce qui mène à un autre écran est un lien vers son adresse (ADR 0013,
amendement) ; les actions sont des boutons. -->
@@ -82,6 +84,11 @@
<noscript><p>Ustagammon a besoin de JavaScript.</p></noscript>
</main>
<!-- L'accueil (#/accueil) et le tutoriel (#/tutoriel/...) : leur contenu
est construit par app/src/ui/home-view.ts et tutorial-view.ts. -->
<section id="home" class="home page" hidden></section>
<section id="tutorial" class="tutorial page" hidden></section>
<!-- 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. -->
@@ -121,12 +128,31 @@
</div>
<button id="menu-new-game" type="button" class="item"></button>
<a id="menu-games" href="#/parties" class="item"></a>
<a id="menu-home" href="#/accueil" class="item"></a>
<a id="menu-tutorial" href="#/tutoriel/1" class="item"></a>
<button id="menu-copy-link" type="button" class="item"></button>
<button id="menu-fullscreen" type="button" class="item"></button>
<a id="menu-help" href="#/aide" class="item"></a>
<button id="menu-install" type="button" class="item"></button>
<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>
<button id="menu-update" type="button" class="item"></button>
</div>
</div>
<!-- La marche à suivre pour installer (#/installer) : une feuille. -->
<div id="install-veil" class="veil sheet-veil" hidden>
<div id="install-sheet" class="sheet" role="dialog" aria-modal="true" aria-labelledby="install-title">
<div class="sheet-head">
<span class="grip" aria-hidden="true"></span>
<img class="install-symbol" src="icons/symbole-couleur.svg" alt="">
<h2 id="install-title" class="sheet-title"></h2>
<button id="install-close" type="button" class="icon-button"></button>
</div>
<p id="install-lead" class="install-lead"></p>
<ol id="install-steps" class="install-steps"></ol>
<button id="install-done" type="button" class="secondary install-done"></button>
</div>
</div>
@@ -162,6 +188,13 @@
<span class="banner-timer" aria-hidden="true"></span>
</div>
<!-- « Nouvelle version prête. » : en bas, comme « Reprendre l'ancienne »,
seulement entre deux tours. -->
<div id="update-banner" class="banner update-banner" role="status" hidden>
<span id="update-text"></span>
<button id="update-apply" type="button" class="secondary"></button>
</div>
</div>
</body>
</html>
+15 -4
View File
@@ -10,9 +10,14 @@
//
// Mise à jour : à chaque publication, VERSION change, donc ce fichier. Le
// navigateur installe alors la nouvelle version, qui télécharge tout dans un
// nouveau cache, prend la main tout de suite et efface l'ancien cache. La
// page déjà ouverte a chargé tous ses fichiers au démarrage : elle continue
// sans rien remarquer, et le prochain lancement utilise la nouvelle version.
// nouveau cache, puis attend : la version en place garde la main, et son
// cache, tant que le joueur n'a pas accepté la mise à jour. Une page ouverte
// continue donc de trouver tous ses fichiers. L'application affiche le
// bandeau « Nouvelle version disponible » (app/src/ui/updates.ts) ; « Mettre
// à jour » envoie le message « skip-waiting » : la nouvelle version prend
// la main, efface l'ancien cache, et la page se recharge. À la toute
// première installation, aucune version n'est en place : elle prend la main
// sans attendre.
/* global VERSION, FILES */
@@ -25,11 +30,17 @@ self.addEventListener("install", (event) => {
// no-cache : revalider auprès du serveur, jamais une copie
// périmée du cache HTTP du navigateur.
await cache.addAll(FILES.map((file) => new Request(file, { cache: "no-cache" })));
await self.skipWaiting();
})(),
);
});
// L'accord du joueur (même nom que SKIP_WAITING dans updates.ts).
self.addEventListener("message", (event) => {
if (event.data?.type === "skip-waiting") {
void self.skipWaiting();
}
});
self.addEventListener("activate", (event) => {
event.waitUntil(
(async () => {