Compare commits

..
Author SHA1 Message Date
Pierre MARTINandClaude Opus 5.5 1eeb6c95ae Décisions de Pierre du 2026-11-09 ; journal
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 19:12:54 +01:00
54 changed files with 283 additions and 5263 deletions
+3 -4
View File
@@ -127,9 +127,8 @@ 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, des scénarios et du tutoriel sous docs/, lues par
# l'aide et le tutoriel de l'application (ADR 0008 : jamais commitées
# ailleurs) ;
# - les copies du glossaire et des scénarios sous docs/, lues par l'aide 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/
@@ -141,7 +140,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 docs/tutoriel $(BUILD_DIR)/site/docs/
cp -r docs/glossaire docs/scenarios $(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
@@ -1,241 +0,0 @@
// 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;
}
+12 -26
View File
@@ -8,19 +8,12 @@
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.
@@ -103,27 +96,20 @@ 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 {
+18 -328
View File
@@ -52,19 +52,11 @@ 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 { isFirstLaunch, longPrompts, nextTheme, parseSettings, type Settings } from "./settings.js";
import { 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). */
@@ -103,11 +95,6 @@ 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. */
@@ -235,21 +222,6 @@ 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
@@ -332,28 +304,6 @@ 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({
@@ -402,23 +352,6 @@ 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)");
@@ -582,7 +515,6 @@ 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.
@@ -631,7 +563,6 @@ export function startApp(options: AppOptions): void {
ui.info.textContent = notice;
}
renderMenu();
renderChrome();
}
/** Les commandes de la colonne de gauche, en paysage. */
@@ -710,52 +641,6 @@ 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. */
@@ -916,18 +801,10 @@ 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 {
@@ -1011,8 +888,7 @@ 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" || route.screen === "install";
const isSheet = (route: Route) => route.screen === "menu" || route.screen === "games";
// « Mes parties » : reprendre une partie (ou une branche) en change à
// l'écran, sans recharger la page.
@@ -1142,7 +1018,7 @@ export function startApp(options: AppOptions): void {
},
help: () => {
closeTransient();
navigation.go(helpHash(helpMoment()));
navigation.go("#/aide");
},
fullscreen: () => {
closeOverlays();
@@ -1161,8 +1037,6 @@ 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");
}
},
};
@@ -1201,7 +1075,6 @@ 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();
@@ -1252,8 +1125,6 @@ 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 = () => {
@@ -1288,56 +1159,19 @@ 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 {
// 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 help = isHelpScreen(route);
const position = route.screen === "position";
const wasPage = ui.game.hidden;
const wasScreen = ui.root.dataset.screen;
ui.game.hidden = page;
ui.game.hidden = help || position;
ui.help.hidden = !help;
ui.position.hidden = !position;
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)) {
ui.root.classList.toggle("page-open", help || position);
if (wasPage && !ui.game.hidden) {
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";
@@ -1370,161 +1204,26 @@ export function startApp(options: AppOptions): void {
gamesView?.hide();
}
const sheet = route.screen === "install";
if (isHelpScreen(below) && !sheet) {
void helpView.show(below);
if (help) {
void helpView.show(route);
}
if (below.screen === "position") {
if (!sheet) {
void showPosition(below);
}
if (position) {
void showPosition(route);
} else {
positionRequest++;
shownPosition = null;
}
// Une adresse inconnue : la partie, avec un message, à l'adresse de
// la partie.
if (route.screen === "unknown") {
notice = TEXTS.unknownAddress;
// Écrans à venir (0e.5) et adresses inconnues : la partie, avec un
// message, à l'adresse de la partie.
if (route.screen === "home" || route.screen === "tutorial" || route.screen === "unknown") {
notice = route.screen === "unknown" ? TEXTS.unknownAddress : TEXTS.screenNotYet;
navigation.replace("#/");
return;
}
render();
}
// ----------------------------------------------- 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. */
@@ -1719,9 +1418,6 @@ export function startApp(options: AppOptions): void {
if (shownPosition !== null) {
renderPosition({ kind: "shown", shared: shownPosition });
}
if (!ui.tutorial.hidden) {
tutorialView.relayout();
}
});
};
window.addEventListener("resize", relayout);
@@ -1864,14 +1560,8 @@ 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. 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);
}
// ou l'écran que désigne l'adresse.
applyRoute(navigation.route);
options.engineReady.then(
async () => {
game = options.savedGame();
-1
View File
@@ -166,7 +166,6 @@ 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
@@ -1,32 +0,0 @@
// 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]),
});
}
+6 -54
View File
@@ -14,16 +14,10 @@ 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 ». Elles ne
* s'affichent pas dans l'aide : elles prouvent la réponse. */
/** Les étapes, mot-clé compris : « Quand vous prenez ». */
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/;
@@ -33,20 +27,13 @@ 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 ; une étiquette @position:<Position ID> donne la position de
* l'exemple du scénario qui la suit.
* ignorés.
*/
export function parseFeature(text: string): HelpFeature {
let title = "";
const description: string[] = [];
const scenarios: {
title: string;
description: string[];
steps: string[];
position: string | null;
}[] = [];
const scenarios: { title: string; description: string[]; steps: string[] }[] = [];
let paragraph: string[] = [];
let tags: string[] = [];
// Le paragraphe en cours rejoint la description de la fonctionnalité ou
// du dernier scénario.
@@ -63,23 +50,12 @@ 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();
const position = tags.find((tag) => tag.startsWith("@position:"));
scenarios.push({
title: scenario[2] ?? "",
description: [],
steps: [],
position: position === undefined ? null : position.slice("@position:".length),
});
tags = [];
scenarios.push({ title: scenario[2] ?? "", description: [], steps: [] });
} else if (STEP.test(line) && scenarios.length > 0) {
endParagraph();
scenarios.at(-1)?.steps.push(line);
@@ -98,42 +74,18 @@ export function parseGlossaryIndex(readme: string): string[] {
return [...section.matchAll(/\]\(([a-z0-9-]+\.md)\)/g)].map((match) => match[1] ?? "");
}
/** Une fiche du glossaire : le terme, son nom anglais (celui du code), sa
* phrase « En bref », et ses renvois « Voir aussi ». */
/** Une fiche du glossaire : le terme et sa phrase « En bref ». */
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;
}
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));
return { term, brief: brief.replaceAll("**", "") };
}
+21 -112
View File
@@ -8,33 +8,25 @@
//
// 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). 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 »).
// est ouvert (show).
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. */
export const FEATURES = [
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/";
@@ -67,18 +59,6 @@ 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,
@@ -121,7 +101,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, { dt: HTMLElement; card: HTMLElement; entry: GlossaryCard }>();
const terms = new Map<string, HTMLElement>();
let loading: Promise<boolean> | null = null;
// La question ouverte au dernier affichage : refermée, elle reste en vue.
let lastOpen: Question | null = null;
@@ -146,14 +126,10 @@ export function createHelpView(container: HTMLElement, tabs: HTMLElement): HelpV
for (const paragraph of scenario.description) {
element(answer, "p", paragraph);
}
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,
});
const steps = element(answer, "ul");
steps.className = "steps";
for (const step of scenario.steps) {
element(steps, "li", step);
}
questions.push({ id, section, link, answer });
}
@@ -161,7 +137,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).className = "feature-intro";
element(play, "p", paragraph);
}
for (const scenario of feature.scenarios) {
renderScenario(play, scenario);
@@ -170,103 +146,38 @@ 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";
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);
for (const entry of entries) {
const term = element(list, "dt");
term.id = `glossaire-${entry.name}`;
const link = element(term, "a", entry.term);
link.href = routeHash({ screen: "glossary", term: entry.name });
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 });
element(list, "dd", entry.brief);
terms.set(entry.name, term);
}
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(version: string | null): void {
function renderAbout(): void {
element(about, "h2", TEXTS.aboutTitle);
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";
const text = element(about, "p", TEXTS.about);
text.className = "about";
}
async function load(): Promise<boolean> {
container.replaceChildren(notice);
notice.textContent = TEXTS.helpLoading;
try {
const [features, entries, version] = await Promise.all([
const [features, entries] = 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(version);
renderAbout();
notice.textContent = "";
renderTutorialCard();
container.append(play, glossary, about);
container.dataset.loaded = "true";
return true;
@@ -304,12 +215,10 @@ 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, card }] of terms) {
for (const [name, dt] 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");
}
@@ -324,7 +233,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)?.card);
const target = opened?.section ?? (term === null ? undefined : terms.get(term));
if (target !== undefined) {
target.scrollIntoView({ block: "start" });
} else if (closed !== null) {
-64
View File
@@ -1,64 +0,0 @@
// 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
@@ -1,125 +0,0 @@
// 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
@@ -1,242 +0,0 @@
// 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,9 +22,6 @@ 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
@@ -1,82 +0,0 @@
// 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);
}
},
};
}
+11 -38
View File
@@ -10,7 +10,7 @@
export type Route =
/** #/ : la partie en cours. */
| { readonly screen: "game" }
/** #/accueil. */
/** #/accueil (écran à venir, étape 0e.5). */
| { readonly screen: "home" }
/** #/aide, et une question ouverte : #/aide/<identifiant>. */
| { readonly screen: "help"; readonly question: string | null }
@@ -18,13 +18,8 @@ export type Route =
| { readonly screen: "glossary"; readonly term: string | null }
/** #/a-propos. */
| { readonly screen: "about" }
/** #/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" }
/** #/tutoriel, et une étape : #/tutoriel/<n> (écran à venir). */
| { readonly screen: "tutorial"; readonly step: number | null }
/** #/parties, et une partie : #/parties/<identifiant>. */
| { readonly screen: "games"; readonly game: string | null }
/** #/menu. */
@@ -63,9 +58,6 @@ 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;
}
@@ -88,31 +80,18 @@ export function parseRoute(hash: string): Route {
return name === null ? { screen: "menu" } : unknown;
case "parties":
return { screen: "games", game: name };
case "installer":
return name === null ? { screen: "install" } : unknown;
case "tutoriel": {
if (name === null) {
return { screen: "tutorial", step: null };
}
const step = Number(name);
return /^[1-9][0-9]?$/.test(name) ? { screen: "tutorial", step } : unknown;
}
default:
return unknown;
}
}
/** #/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
@@ -155,13 +134,7 @@ export function routeHash(route: Route): string {
case "about":
return "#/a-propos";
case "tutorial":
return route.step === null
? "#/tutoriel"
: `#/tutoriel/${route.step}${route.practice ? "/a-vous" : ""}`;
case "tutorialEnd":
return "#/tutoriel/fin";
case "install":
return "#/installer";
return route.step === null ? "#/tutoriel" : `#/tutoriel/${route.step}`;
case "games":
return route.game === null ? "#/parties" : `#/parties/${route.game}`;
case "menu":
+4 -87
View File
@@ -3,10 +3,6 @@
//
// 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";
@@ -19,21 +15,6 @@ 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 = {
@@ -41,24 +22,11 @@ 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 {
@@ -71,33 +39,16 @@ export function parseSettings(text: string | null): Settings {
} catch {
// Réglages illisibles : valeurs par défaut.
}
const bool = (key: "vibrations" | "keepScreenOn" | "welcomed" | "installed") =>
const bool = (key: "vibrations" | "keepScreenOn") =>
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: count("gamesStarted"),
gamesFinished: count("gamesFinished"),
welcomed: bool("welcomed"),
tutorialSeen: count("tutorialSeen"),
installDismissals: count("installDismissals"),
installDismissedAt: count("installDismissedAt"),
installed: bool("installed"),
gamesStarted:
typeof games === "number" && Number.isInteger(games) && games >= 0 ? games : 0,
};
}
@@ -110,37 +61,3 @@ 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,6 +164,7 @@ 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
@@ -1,96 +0,0 @@
// 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
@@ -1,204 +0,0 @@
// 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
@@ -1,734 +0,0 @@
// 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
@@ -1,150 +0,0 @@
// 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
@@ -1,133 +0,0 @@
// 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");
});
+1 -5
View File
@@ -218,18 +218,14 @@ 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,
/(^|[^.\w])(window\.)?(confirm|alert|prompt)\s*\(/m,
/\b(window\.)?(confirm|alert|prompt)\s*\(/,
`${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", () => {
+2 -9
View File
@@ -28,11 +28,8 @@ const TABLE: [string, Route][] = [
["#/glossaire", { screen: "glossary", term: null }],
["#/glossaire/videau", { screen: "glossary", term: "videau" }],
["#/a-propos", { screen: "about" }],
["#/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" }],
["#/tutoriel", { screen: "tutorial", step: null }],
["#/tutoriel/3", { screen: "tutorial", step: 3 }],
["#/parties", { screen: "games", game: null }],
["#/parties/mh2k3x-a1b2c3", { screen: "games", game: "mh2k3x-a1b2c3" }],
["#/menu", { screen: "menu" }],
@@ -66,10 +63,6 @@ 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
@@ -1,181 +0,0 @@
// É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);
});
@@ -1,132 +0,0 @@
// É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,7 +20,6 @@ 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);
@@ -164,47 +163,3 @@ 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
@@ -1,212 +0,0 @@
// 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,
),
);
});
+8 -75
View File
@@ -19,15 +19,7 @@ import {
pointColor,
pointSlot,
} from "../src/ui/board-model.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 { parseFeature, parseGlossaryEntry, parseGlossaryIndex } from "../src/ui/help-text.js";
import { TEXTS } from "../src/ui/texts.js";
// Compilé dans build/web/tests/ : la racine du dépôt est trois dossiers plus haut.
@@ -151,77 +143,21 @@ 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, "Comment commence une partie ?");
assert.equal(feature.scenarios[0]?.position, "4HPwATDgc/ABMA");
assert.equal(feature.scenarios[0]?.title, "Commencer une partie");
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 === "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/,
);
const refused = feature.scenarios.find((s) => s.title === "Un coup interdit est refusé");
assert.match(refused?.description[0] ?? "", /^La case 12 est tenue/);
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"));
@@ -231,13 +167,10 @@ test("l'aide lit chaque fiche de l'index du glossaire", () => {
assert.ok(entry !== null, file);
assert.ok(entry.brief.length > 10, file);
}
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.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.",
});
assert.equal(parseGlossaryEntry("pas une fiche"), null);
});
-13
View File
@@ -85,16 +85,3 @@ 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.
+20 -11
View File
@@ -36,21 +36,30 @@ Repris du cadrage (modules, écrans clés, IA générative en V1) et des ADR 001
ton des phrases du maître est encourageant ; l'espace qu'il occupe est
réservé dans la mise en page (revue UX 0e.2), pas en fenêtre surgissante.
## Questions pour Pierre (ne bloquent pas le démarrage)
## Décisions de Pierre (2026-11-09)
- **Indice** : le cadrage le liste dans le module Jeu, l'ADR 0011 l'a laissé
« à concevoir séparément ». Il n'est pas dans l'étape 1 sans décision.
- **Questionnaire de premier lancement** (choix du parcours) : le parcours 1
seul existe en V1 ; proposé : un simple choix « Je découvre » / « Je connais
les règles » qui oriente vers le tutoriel ou la partie.
- **Indice avant le coup** : pas en V1 (« l'idée est bonne »), à reprendre en
V1.5 ou V2. Le conseil du maître après le coup reste le seul mécanisme de
l'étape 1.
- **Premier lancement** : un choix « Je découvre » / « Je connais les règles »
(fait en 0e.5).
- **Seuils des appréciations** : ceux de GNU Backgammon (douteux 0,03,
mauvais 0,06, très mauvais 0,12 d'équité, `arSkillLevel`), exposés par le
moteur depuis 1a ; à calibrer sur de vraies parties.
- **Relecture des leçons** par un joueur fort de l'Atelier du Huit.
mauvais 0,06, très mauvais 0,12, `arSkillLevel`), exposés par le moteur
depuis 1a ; à recalibrer après les retours de Pierre sur de vraies parties.
Le niveau le plus grave se dit « Grosse erreur », pas « gaffe ».
- **Leçons** : pas de relecture préalable par un joueur fort ; elles sont
livrées telles quelles et améliorées sur retours d'experts. Garde-fous
maintenus : chaque réponse attendue est le meilleur coup du moteur, vérifié
par un test, et le texte n'affirme rien que le moteur ne confirme.
- **Plein écran par défaut dans l'app installée** : non (pas de
`display_override` fullscreen) ; la barre d'état reste visible en portrait.
- **Validés** : le texte du tutoriel et toutes les maquettes du canevas
(accueil, installation, tutoriel, aide, écrans 1c à 1i), ainsi que le cadre
des phrases du maître (section « Les phrases du maître » de
`docs/identite/charte/ecrans-etape-1.md`).
## Ordre de réalisation
1a et 1b d'abord (fondations, sans conflit avec l'interface), en parallèle des
sous-étapes 0e. Puis 1c, 1d, 1e, 1f dans cet ordre ; 1g après la validation des
maquettes d'accueil et de tutoriel par Pierre ; 1h et 1i ; 1j en dernier. Chaque
sous-étapes 0e. Puis 1c, 1d, 1e, 1f dans cet ordre ; 1g (maquettes validées par Pierre) ; 1h et 1i ; 1j en dernier. Chaque
sous-étape a son plan dans `docs/plans/` et sa merge request.
+2 -1
View File
@@ -23,13 +23,14 @@ 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
| Étape | Contenu | État |
| --- | --- | --- |
| 1. V1 Jouer et comprendre | Détail et ordre dans `docs/etape-1.md` : moteur pour l'analyse, parties enregistrées et branches, match et réglages, conseil du maître, fin de partie et analyse, prompts, accueil, tutoriel et leçons, progression, plateau soigné, plan de test | 🔄 |
| 2. V1.5 Apprendre | Parcours 2, exercices, progression | ⏳ |
| 2. V1.5 Apprendre | Parcours 2, exercices, progression ; idée retenue par Pierre : un indice avant le coup (sans la réponse) | ⏳ |
| 3. V2 Perfectionnement et IA | Parcours 3 et 4, rollouts, révision espacée | ⏳ |
Chaque étape reçoit son fichier `docs/etape-N.md` avant de démarrer.
-1
View File
@@ -40,7 +40,6 @@ 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
@@ -1,12 +0,0 @@
---
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)
+35
View File
@@ -163,3 +163,38 @@ Une entrée datée par session : ce qui a été fait, décidé, et la suite.
Playwright (412×915, 915×412, 844×320, clair et sombre) avant l'essai de
Pierre sur le téléphone.
- Réglage en main des vibrations sur le Fairphone 4.
## 2026-11-03 → 2026-11-09 : retours 0e et début de l'étape 1
**Fait**
- Retours du premier test sur téléphone, traités et publiés :
- saisie du coup (MR !28) ;
- révision UX arbitrée par la session « identité » (MR !35) ;
- parties enregistrées avec branches et export SGF, sous-étape 1b (MR !37).
- Fusionnés :
- adresses par écran et liens de position, sous-étape 0e.4 (MR !43). L'ADR 0013 est amendé : une adresse par écran, de vrais liens ;
- moteur pour l'analyse et les niveaux, sous-étape 1a (MR !32) : 9 niveaux de gnubg, analyse de coup et de videau, chance d'un jet, concordance avec gnubg de bureau à 1,64e-3 près.
- Décisions techniques :
- le cache d'évaluation de gnubg est vidé à chaque calcul, pour que les résultats ne dépendent que de leurs arguments ;
- le schéma IndexedDB est un format durable (amendement de l'ADR 0007) ;
- l'image Docker de test est nommée par worktree, ce qui corrige l'instabilité des tests concurrents.
- Plans écrits pour toute l'étape 1, de 1c à 1i. La session « identité » a maquetté tous les écrans (MR !40 à !42).
- Décisions et validations de Pierre (2026-11-09), consignées dans `docs/etape-1.md` :
- « Grosse erreur » plutôt que « gaffe » ;
- pas d'indice en V1 ;
- pas de relecture préalable des leçons ;
- pas de plein écran par défaut ;
- seuils de gnubg ;
- validés : le texte du tutoriel, les maquettes et le cadre des phrases du maître.
- Nouvelles demandes de Pierre, intégrées aux plans :
- une adresse pour chaque écran ;
- la mise à jour forcée de l'app (0e.5) ;
- un coach bienveillant, mis en avant mais discret.
**Reste à faire**
- 0e.5 (MR !44) : appliquer les corrections de recette T1 à T5, puis fusionner et publier.
- Publication bloquée : `RESTART.sh`, un fichier local non suivi par git, fait échouer `make lint`, et donc `make deploy-prod`.
- Étape 1 :
- 1c en cours ;
- puis 1d, 1e, 1f, 1g, 1h et 1i ;
- enfin le plan de test 1j.
@@ -0,0 +1,76 @@
# 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.
+8 -4
View File
@@ -36,10 +36,14 @@ du moteur.
sur demande, lien vers la fiche du glossaire du concept en jeu si le moteur
permet de le déterminer, sinon rien.
3. **Phrases du maître** : gabarits versionnés en français dans une source
unique sous `docs/` (CC BY-SA 4.0), ton encourageant, jamais culpabilisant ;
elles ne citent que des faits du moteur (coup recommandé, rang, pions
frappés, écart) : aucune explication inventée. Première version à faire
valider par Pierre (ton).
unique sous `docs/` (CC BY-SA 4.0). Référence de ton, approuvée par Pierre
le 2026-11-09 : la section « Les phrases du maître » de
`docs/identite/charte/ecrans-etape-1.md` (1re personne et vouvoiement,
faits du moteur uniquement, moins de 140 caractères, ton sobre sans
exclamation, formules qui tournent pour les bons coups, exemples). Un test
vérifie la longueur et l'absence de « ! » sur chaque gabarit rendu. Les
phrases livrées passent par la session « identité » puis par Pierre pour
la relecture finale.
4. **Discret** : rien n'apparaît pendant que le joueur réfléchit ; la ligne
s'efface à l'action suivante ; désactivable (réglage de 1c et menu).
5. **Videau** : même traitement pour doubler, ne pas doubler, prendre, passer.
+1 -1
View File
@@ -18,7 +18,7 @@ Mettre en place les cinq premières leçons numérotées de l'ADR 0011. Chaque l
4. La course et le compte de points.
5. Sortir ses pions sans se faire frapper.
Chaque leçon compte deux à quatre exercices « à vous ». Leurs positions sont données par identifiants gnubg. La réponse attendue est le meilleur coup du moteur à 2 plis, vérifié par un test. Le texte n'affirme rien que le moteur ne confirme. Les contenus sont livrés en brouillon, marqués « à relire par un joueur fort » (cadrage, risque « contenus »), et Pierre les valide.
Chaque leçon compte deux à quatre exercices « à vous ». Leurs positions sont données par identifiants gnubg. La réponse attendue est le meilleur coup du moteur à 2 plis, vérifié par un test. Le texte n'affirme rien que le moteur ne confirme. Décision de Pierre du 2026-11-09 : pas de relecture préalable par un joueur fort ; les leçons sont livrées telles quelles et améliorées sur retours d'experts. Les garde-fous restent : chaque réponse attendue est le meilleur coup du moteur, vérifié par un test, et le texte n'affirme rien que le moteur ne confirme.
2. **Progression.** Elle est enregistrée dans le stockage de 1b, dans le magasin `settings` ou dans un nouveau magasin avec montée de version (ADR 0007 amendé). Chaque leçon a son état : à commencer, « 2 exercices sur 4 », terminée.
3. **Adresses** : `#/lecons` et `#/lecons/<n>/<écran>`, à ajouter au tableau de l'ADR 0013.
4. **Accueil.** Il reçoit la carte « prochaine leçon » prévue par la charte.
+1 -13
View File
@@ -33,15 +33,6 @@ 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/`) ;
@@ -58,7 +49,4 @@ 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), 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 |
| [regler-l-application.feature](regler-l-application.feature) | Régler l'application (menu) | Étape 0e |
@@ -1,42 +0,0 @@
# 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
@@ -1,31 +0,0 @@
# 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
@@ -1,46 +0,0 @@
# 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
+23 -48
View File
@@ -1,22 +1,16 @@
# language: fr
Fonctionnalité: Jouer une partie contre l'ordinateur
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
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
permis par les règles, et l'ordinateur joue et décide du videau tout seul.
@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.
Scénario: Commencer une partie
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
@position:4HPwATDgc/ABMA
Scénario: Comment jouer un coup ?
Scénario: Jouer un coup permis
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é
@@ -30,17 +24,16 @@ Fonctionnalité: Jouer une partie contre l'ordinateur
Alors votre coup est accepté
Et c'est ensuite au tour de l'ordinateur
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.
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.
É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: Je me suis trompé de pion
Scénario: Reprendre un pion avant de valider
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.
@@ -52,27 +45,21 @@ 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é
@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.
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.
É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: 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.
Scénario: L'ordinateur joue
É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: Je ne peux rien jouer avec mes dés
Scénario: Aucun coup possible avec votre jet
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
@@ -85,7 +72,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: Qu'a joué l'ordinateur ?
Scénario: Voir le coup de 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
@@ -97,13 +84,11 @@ Fonctionnalité: Jouer une partie contre l'ordinateur
Et votre pion frappé part sur la barre
Et vous pouvez revoir son coup
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.
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.
Quand vous commencez une nouvelle partie
Alors chaque camp a un compte de points de 167
@@ -111,7 +96,7 @@ Fonctionnalité: Jouer une partie contre l'ordinateur
Quand vous jouez 13/8 13/11
Alors vous menez la course de 7
Scénario: Comment doubler ?
Scénario: Doubler, après confirmation
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.
@@ -124,29 +109,19 @@ Fonctionnalité: Jouer une partie contre l'ordinateur
Et que vous confirmez
Alors l'ordinateur prend ou passe, comme le recommande le moteur
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.
Scénario: Prendre le double de l'ordinateur
É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: 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.
Scénario: Passer le double de l'ordinateur
É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: 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é.
Scénario: Finir la partie
É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
+3 -11
View File
@@ -7,11 +7,7 @@ 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: 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.
Scénario: Reprendre une partie parmi plusieurs
Étant donné que vous avez joué deux parties, l'une après l'autre
Quand vous fermez l'application puis la rouvrez
Alors la dernière partie jouée est à l'écran
@@ -19,7 +15,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: Puis-je rejouer un moment de la partie autrement ?
Scénario: Rejouer d'ici sans perdre l'original
Revenez à un moment passé de la partie et jouez autre chose : une
nouvelle branche commence là, et la partie d'origine reste entière.
@@ -32,11 +28,7 @@ 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: 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.
Scénario: Exporter une partie
Étant donné que vous avez joué plusieurs coups
Et que vous avez rejoué d'ici une autre suite
Quand vous exportez la partie
+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: Comment partager une position ?
Scénario: Partager la position
Pendant la partie, « Copier le lien de la position », dans le menu ou à
côté du dernier coup de l'ordinateur, copie l'adresse de la position à
l'écran. Ouvert ailleurs, le lien montre la même position.
@@ -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: On m'a envoyé le lien d'une position
Scénario: Ouvrir un lien vers une position
Un lien vers une position montre le plateau, sans le rendre jouable :
vous y êtes en bas, avec la main. « Jouer à partir d'ici » commence une
nouvelle partie contre l'ordinateur depuis cette position, et votre
@@ -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: Le lien d'une position ne s'ouvre pas
Scénario: Ouvrir un lien abîmé
Un lien coupé ou mal recopié ne casse rien : un message le dit, et
« Retour à ma partie » reste là.
+4 -23
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: J'ai commencé une nouvelle partie par erreur
Scénario: Commencer une nouvelle partie, puis reprendre l'ancienne
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: Comment couper les vibrations ?
Scénario: 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: Pourquoi l'écran ne se met-il pas en veille ?
Scénario: Garder l'écran allumé pendant la partie
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,27 +41,8 @@ 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: 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.
Scénario: Choisir le thème
É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
@@ -1,65 +0,0 @@
# 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
@@ -1,128 +0,0 @@
# 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.
+4 -35
View File
@@ -2,11 +2,7 @@
# 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/. 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.
# dans build/browser-shots/.
#
# Outils : l'image de Playwright (navigateurs) et le paquet playwright,
# installé une fois par npm dans build/browser-tools/ (réseau nécessaire la
@@ -18,8 +14,6 @@ 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
@@ -39,40 +33,15 @@ 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 tests/browser/accueil.mjs "$tools/"
cp tests/browser/play.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 \
-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
-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
done
exit "$status"
-548
View File
@@ -1,548 +0,0 @@
// 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/` : ""}`);
+8 -33
View File
@@ -677,9 +677,6 @@ 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
@@ -750,19 +747,17 @@ 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", (s) => s.home && !s.game && !s.tutorial],
["#/accueil", notYet],
["#/aide", (s) => s.help && s.play && !s.glossary && !s.about && s.question === null],
[() => `#/aide/${question}`, (s) => s.help && s.play && s.question === `aide-${question}`],
["#/glossaire", (s) => s.help && s.glossary && !s.play && s.term === null],
["#/glossaire/lien-de-position", (s) => s.help && s.glossary && s.term === "glossaire-lien-de-position"],
["#/a-propos", (s) => s.help && s.about && !s.play && !s.glossary],
["#/tutoriel", (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"],
["#/tutoriel", notYet],
["#/tutoriel/2", notYet],
["#/parties", (s) => s.games && s.listed >= 2 && !s.menu],
[() => `#/parties/${gameId}`, (s) => s.games && s.expanded === gameId],
["#/menu", (s) => s.menu && !s.games],
@@ -815,7 +810,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 .term-card.current",
"#help-play .question.open, #help-glossary dt.current",
).getBoundingClientRect();
return { scrolled: window.scrollY, barTop: bar.top, barBottom: bar.bottom, top: target.top };
});
@@ -880,26 +875,8 @@ async function checkAddresses() {
check((await tab.locator(selector).and(tab.getByRole("link")).count()) === 0,
`${selector} : une action de jeu exposée comme un lien`);
}
// 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);
}
await tab.click("#help-button");
await expectScreen(tab, "aide par son lien", (s) => s.hash === "#/aide" && s.help && s.play);
for (const selector of ["#help-back", "#help-tabs a", "#help-play .question h3 a"]) {
check(await isLink(tab, selector), `${selector} : pas des liens`);
}
@@ -1041,9 +1018,7 @@ await context.route("**/wasm/engine.data", async (route) => {
await route.continue();
});
const start = Date.now();
// « #/ » : la partie ; sans adresse d'écran, l'application s'ouvrirait sur
// l'accueil (tests/browser/accueil.mjs).
await page.goto(`${base}#/`);
await page.goto(base);
await page.waitForSelector("#board polygon[data-point]");
{
const loading = await page.evaluate(() => ({
-9
View File
@@ -83,15 +83,6 @@ 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")"
+2 -720
View File
@@ -1293,7 +1293,7 @@ html.rotated .banner {
box-shadow: var(--shadow-raised);
}
.glossary .term-card {
.glossary dt {
padding-top: var(--space-3);
font-weight: 700;
}
@@ -1337,7 +1337,7 @@ html.rotated .banner {
}
.help .question,
.glossary .term-card {
.glossary dt {
scroll-margin-top: calc(var(--help-bar-height) + 12px + env(safe-area-inset-top));
}
@@ -1542,724 +1542,6 @@ 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. */
+2 -35
View File
@@ -26,10 +26,8 @@
<!-- 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">
<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>
<img class="logo-light" src="icons/logo-horizontal-couleur.svg" alt="Ustagammon">
<img class="logo-dark" src="icons/logo-horizontal-sombre.svg" alt="Ustagammon">
</h1>
<!-- Ce qui mène à un autre écran est un lien vers son adresse (ADR 0013,
amendement) ; les actions sont des boutons. -->
@@ -84,11 +82,6 @@
<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. -->
@@ -128,31 +121,12 @@
</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>
@@ -188,13 +162,6 @@
<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>
+4 -15
View File
@@ -10,14 +10,9 @@
//
// 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, 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.
// 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.
/* global VERSION, FILES */
@@ -30,17 +25,11 @@ 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 () => {