Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
715176e144 | ||
|
|
dd18a0cc21 | ||
|
|
86c0ebfc36 | ||
|
|
7c10c688c3 | ||
|
|
d1e234bb11 | ||
|
|
809b5ec832 | ||
|
|
71bff4def1 | ||
|
|
8fd1ba3f4f | ||
|
|
5fdca812d9 | ||
|
|
441bca1bd7 | ||
|
|
3f5b00133d | ||
|
|
f1c589eff1 |
@@ -127,8 +127,9 @@ SITE_ICONS := icone.svg icone-192.png icone-512.png icone-maskable-192.png icone
|
||||
# docs/identite/logo/ (une seule source, ADR 0010) ;
|
||||
# - l'application compilée (build/web/src/) sous app/ ;
|
||||
# - le moteur sous wasm/ ;
|
||||
# - les copies du glossaire et des scénarios sous docs/, lues par l'aide de
|
||||
# l'application (ADR 0008 : jamais commitées ailleurs) ;
|
||||
# - les copies du glossaire, des scénarios et du tutoriel sous docs/, lues par
|
||||
# l'aide et le tutoriel de l'application (ADR 0008 : jamais commitées
|
||||
# ailleurs) ;
|
||||
# - version.json (commit publié) ;
|
||||
# - sw.js, complété par la liste de tous ces fichiers (scripts/service-worker.sh).
|
||||
site: engine-wasm web ## Assemble le site publié dans build/site/
|
||||
@@ -140,7 +141,7 @@ site: engine-wasm web ## Assemble le site publié dans build/site/
|
||||
cp -r $(BUILD_DIR)/web/src $(BUILD_DIR)/site/app
|
||||
cp $(BUILD_DIR)/wasm/engine.mjs $(BUILD_DIR)/wasm/engine.wasm $(BUILD_DIR)/wasm/engine.data \
|
||||
$(BUILD_DIR)/wasm/positions.json $(BUILD_DIR)/site/wasm/
|
||||
cp -r docs/glossaire docs/scenarios $(BUILD_DIR)/site/docs/
|
||||
cp -r docs/glossaire docs/scenarios docs/tutoriel $(BUILD_DIR)/site/docs/
|
||||
printf '{"commit": "%s", "dirty": %s}\n' "$$(git rev-parse HEAD)" \
|
||||
"$$(git diff --quiet HEAD && echo false || echo true)" > $(BUILD_DIR)/site/version.json
|
||||
scripts/service-worker.sh $(BUILD_DIR)/site
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
// Le tutoriel des règles, côté domaine : les positions illustrées et les
|
||||
// écrans « à vous », vérifiés par le moteur.
|
||||
//
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
//
|
||||
// Le texte du tutoriel vit dans docs/tutoriel/ (ADR 0008 : une seule
|
||||
// source) ; il donne pour chaque écran une position par son identifiant
|
||||
// gnubg (ADR 0013), un jet, et les coups attendus en notation de gnubg.
|
||||
// Rien n'est jugé ici : le moteur dit quels coups sont légaux, où mène
|
||||
// chaque demi-coup, et s'il faut prendre un double. Le domaine ne fait que
|
||||
// comparer ce qu'a joué le joueur aux coups attendus que le moteur a
|
||||
// reconnus.
|
||||
|
||||
import { CENTERED_CUBE, type Cube } from "../game/cube.js";
|
||||
import type { BoardResult, Engine, EngineCube, Move } from "../game/engine.js";
|
||||
import { Game, type MoveSteps, type SideBoards, STANDARD_PLY } from "../game/game.js";
|
||||
import type { HalfMove, PositionId } from "../game/position.js";
|
||||
import { fixedDice, type Roll } from "../game/roll.js";
|
||||
import type { Side } from "../game/side.js";
|
||||
|
||||
/** Un plateau du tutoriel, décodé par le moteur, et le résultat si la
|
||||
* partie y est finie (vu du camp « player »). */
|
||||
export interface TutorialBoard {
|
||||
readonly sides: SideBoards;
|
||||
readonly result: { readonly winner: Side; readonly kind: BoardResult["kind"] } | null;
|
||||
}
|
||||
|
||||
/** Le videau vu du camp au trait. */
|
||||
function engineCube(cube: Cube, onRoll: Side): EngineCube {
|
||||
return {
|
||||
value: cube.value,
|
||||
owner: cube.owner === null ? "centered" : cube.owner === onRoll ? "onRoll" : "opponent",
|
||||
};
|
||||
}
|
||||
|
||||
/** Le plateau d'une position vue de onRoll, décodé par le moteur (erreur si
|
||||
* l'identifiant est illisible). */
|
||||
export async function tutorialBoard(
|
||||
engine: Engine,
|
||||
position: PositionId,
|
||||
onRoll: Side = "player",
|
||||
cube: Cube = CENTERED_CUBE,
|
||||
): Promise<TutorialBoard> {
|
||||
const board = await engine.board(position, engineCube(cube, onRoll));
|
||||
const sides =
|
||||
onRoll === "player"
|
||||
? { player: board.onRoll, computer: board.opponent }
|
||||
: { player: board.opponent, computer: board.onRoll };
|
||||
const result =
|
||||
board.result === null
|
||||
? null
|
||||
: {
|
||||
winner:
|
||||
(board.result.winner === "onRoll") === (onRoll === "player")
|
||||
? ("player" as const)
|
||||
: ("computer" as const),
|
||||
kind: board.result.kind,
|
||||
};
|
||||
return { sides, result };
|
||||
}
|
||||
|
||||
/** Les coups du jet que le moteur déclare légaux et dont la notation est
|
||||
* l'une des notations données. Erreur si l'une d'elles n'est pas un coup
|
||||
* légal : le texte du tutoriel ne correspond plus au moteur. */
|
||||
export async function expectedMoves(
|
||||
engine: Engine,
|
||||
position: PositionId,
|
||||
roll: Roll,
|
||||
notations: readonly string[],
|
||||
): Promise<Move[]> {
|
||||
const legal = await engine.legalMoves(position, roll);
|
||||
return notations.map((notation) => {
|
||||
const move = legal.find((m) => m.notation === notation);
|
||||
if (move === undefined) {
|
||||
throw new Error(
|
||||
`${notation} n'est pas un coup légal de ${roll.join("-")} dans ${position}`,
|
||||
);
|
||||
}
|
||||
return move;
|
||||
});
|
||||
}
|
||||
|
||||
/** Le coup illustré d'un écran d'explication, demi-coup par demi-coup :
|
||||
* les plateaux successifs, donnés par le moteur, et le résultat si le coup
|
||||
* finit la partie. */
|
||||
export async function illustratedMove(
|
||||
engine: Engine,
|
||||
position: PositionId,
|
||||
roll: Roll,
|
||||
notation: string,
|
||||
): Promise<{ steps: MoveSteps; after: TutorialBoard }> {
|
||||
const [move] = await expectedMoves(engine, position, roll, [notation]);
|
||||
if (move === undefined) {
|
||||
throw new Error(`coup illustré introuvable : ${notation}`);
|
||||
}
|
||||
const positions = [position];
|
||||
for (let i = 1; i <= move.halfMoves.length; i++) {
|
||||
const check = await engine.checkHalfMoves(position, roll, move.halfMoves.slice(0, i));
|
||||
if (check.position === null) {
|
||||
throw new Error(`demi-coup refusé par le moteur : ${notation}`);
|
||||
}
|
||||
positions.push(check.position);
|
||||
}
|
||||
const boards = await Promise.all(positions.map((p) => tutorialBoard(engine, p)));
|
||||
const hits = move.halfMoves.map(
|
||||
(_, i) => (boards[i + 1]?.sides.computer.bar ?? 0) > (boards[i]?.sides.computer.bar ?? 0),
|
||||
);
|
||||
const after = boards.at(-1) ?? boards[0];
|
||||
if (after === undefined) {
|
||||
throw new Error("aucun plateau");
|
||||
}
|
||||
return {
|
||||
steps: {
|
||||
side: "player",
|
||||
halfMoves: move.halfMoves,
|
||||
boards: boards.map((b) => b.sides),
|
||||
hits,
|
||||
},
|
||||
after,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Les demi-coups saisis peuvent-ils encore mener à l'un des coups attendus ?
|
||||
* Oui s'ils font tous partie (comme multiensemble) des demi-coups de l'un
|
||||
* d'eux, tels que le moteur les donne. Un coup complet est jugé sur sa
|
||||
* position (verdict de MovePractice).
|
||||
*/
|
||||
export function canStillLeadTo(played: readonly HalfMove[], expected: readonly Move[]): boolean {
|
||||
return expected.some((move) => {
|
||||
const left = [...move.halfMoves];
|
||||
return played.every((h) => {
|
||||
const i = left.findIndex((m) => m.from === h.from && m.to === h.to);
|
||||
if (i < 0) {
|
||||
return false;
|
||||
}
|
||||
left.splice(i, 1);
|
||||
return true;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** Ce que devient un écran « à vous » après un demi-coup. */
|
||||
export type PracticeVerdict = "continue" | "right" | "wrong";
|
||||
|
||||
/**
|
||||
* Un écran « à vous » où le joueur joue un coup : une partie d'un seul jet,
|
||||
* commencée à la position de l'écran, jamais enregistrée. Le joueur saisit
|
||||
* ses demi-coups comme en partie (le moteur dit lesquels sont permis) ; le
|
||||
* coup est juste s'il mène à la position de l'un des coups attendus.
|
||||
*/
|
||||
export class MovePractice {
|
||||
readonly #game: Game;
|
||||
readonly #expected: readonly Move[];
|
||||
readonly roll: Roll;
|
||||
|
||||
private constructor(game: Game, expected: readonly Move[], roll: Roll) {
|
||||
this.#game = game;
|
||||
this.#expected = expected;
|
||||
this.roll = roll;
|
||||
}
|
||||
|
||||
/** Prépare l'écran : la position, le jet, et les coups attendus,
|
||||
* reconnus par le moteur (erreur sinon). */
|
||||
static async start(
|
||||
engine: Engine,
|
||||
position: PositionId,
|
||||
roll: Roll,
|
||||
notations: readonly string[],
|
||||
): Promise<MovePractice> {
|
||||
const expected = await expectedMoves(engine, position, roll, notations);
|
||||
const game = Game.start(engine, {
|
||||
setup: { position, firstTurn: "player" },
|
||||
dice: fixedDice(roll),
|
||||
ply: 0,
|
||||
});
|
||||
await game.roll();
|
||||
return new MovePractice(game, expected, roll);
|
||||
}
|
||||
|
||||
/** Le plateau affiché, demi-coups saisis compris. */
|
||||
board(): Promise<SideBoards> {
|
||||
return this.#game.displayedBoard();
|
||||
}
|
||||
|
||||
/** Les demi-coups permis maintenant (le moteur le dit). */
|
||||
nextHalfMoves(): Promise<HalfMove[]> {
|
||||
return this.#game.nextHalfMoves();
|
||||
}
|
||||
|
||||
get pendingHalfMoves(): readonly HalfMove[] {
|
||||
return this.#game.pendingHalfMoves;
|
||||
}
|
||||
|
||||
/** Les dés du jet, et ceux déjà utilisés. */
|
||||
get diceToPlay(): { value: number; used: boolean }[] {
|
||||
return this.#game.diceToPlay;
|
||||
}
|
||||
|
||||
/**
|
||||
* Joue un demi-coup. « wrong » si le moteur le refuse, ou s'il ne peut
|
||||
* plus mener à un coup attendu, ou si le coup complet n'est pas l'un
|
||||
* d'eux : le demi-coup reste affiché, à l'appelant de revenir en
|
||||
* arrière (reset). « right » quand le coup complet est l'un des coups
|
||||
* attendus.
|
||||
*/
|
||||
async play(halfMove: HalfMove): Promise<PracticeVerdict> {
|
||||
if (!(await this.#game.playHalfMove(halfMove))) {
|
||||
return "wrong";
|
||||
}
|
||||
if (this.#game.moveComplete) {
|
||||
const position = this.#game.displayedPosition;
|
||||
return this.#expected.some((m) => m.position === position) ? "right" : "wrong";
|
||||
}
|
||||
return canStillLeadTo(this.#game.pendingHalfMoves, this.#expected) ? "continue" : "wrong";
|
||||
}
|
||||
|
||||
/** Le pion revient : tous les demi-coups saisis sont oubliés. */
|
||||
reset(): void {
|
||||
this.#game.cancelHalfMoves();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Un écran « à vous » de videau : l'ordinateur, au trait dans position (vue
|
||||
* de lui), double. Le moteur dit, au niveau standard de l'ordinateur, si le
|
||||
* joueur doit prendre (vrai) ou passer (faux). Erreur si l'ordinateur ne
|
||||
* peut pas doubler dans cette position.
|
||||
*/
|
||||
export async function shouldTake(engine: Engine, position: PositionId): Promise<boolean> {
|
||||
const decision = await engine.cubeDecision(
|
||||
position,
|
||||
{ value: 1, owner: "centered" },
|
||||
STANDARD_PLY,
|
||||
);
|
||||
if (!decision.available) {
|
||||
throw new Error(`l'ordinateur ne peut pas doubler dans ${position}`);
|
||||
}
|
||||
return decision.take;
|
||||
}
|
||||
+26
-12
@@ -8,12 +8,19 @@
|
||||
import { Game } from "./domain/game/game.js";
|
||||
import { linkOf, readPosition } from "./domain/game/position-link.js";
|
||||
import { exportSgf } from "./domain/game/sgf.js";
|
||||
import {
|
||||
illustratedMove,
|
||||
MovePractice,
|
||||
shouldTake,
|
||||
tutorialBoard,
|
||||
} from "./domain/learning/tutorial.js";
|
||||
import { createWasmEngine } from "./engine/wasm-engine.js";
|
||||
import { startEngineWorker } from "./engine/worker-run.js";
|
||||
import { createGameSession } from "./storage/game-session.js";
|
||||
import { memoryStorage, openGameLibrary } from "./storage/open-library.js";
|
||||
import { createSettingsStore } from "./storage/settings-store.js";
|
||||
import { startApp } from "./ui/app.js";
|
||||
import { startUpdates } from "./ui/updates.js";
|
||||
|
||||
// Ce fichier est servi sous app/ ; le Worker et le service worker sont à la
|
||||
// racine du site.
|
||||
@@ -96,20 +103,27 @@ startApp({
|
||||
saveBoard: (board) => settings.saveBoard(board),
|
||||
loadSettings: () => settings.loadSettings(),
|
||||
saveSettings: (json) => settings.saveSettings(json),
|
||||
// Le tutoriel : positions, coups illustrés et gestes jugés par le
|
||||
// domaine, sur le moteur.
|
||||
tutorial: {
|
||||
board: (position, onRoll) => tutorialBoard(engine, position, onRoll),
|
||||
illustrate: (position, roll, move) => illustratedMove(engine, position, roll, move),
|
||||
practice: (position, roll, expected) =>
|
||||
MovePractice.start(engine, position, roll, expected),
|
||||
shouldTake: (position) => shouldTake(engine, position),
|
||||
},
|
||||
// Hors ligne : le service worker met tout le site en cache. Le navigateur
|
||||
// le refuse hors https (sauf localhost) : l'application marche alors sans.
|
||||
// Il est enregistré une fois le moteur chargé : si le Worker et le service
|
||||
// worker téléchargent les données du moteur (8,7 Mo) en même temps,
|
||||
// Chromium fait échouer l'installation (« Cache.add() encountered a
|
||||
// network error »), et l'application n'est pas disponible hors ligne.
|
||||
// Cela évite aussi de télécharger deux fois ces données à la première
|
||||
// visite.
|
||||
startUpdates: (onAvailable) =>
|
||||
startUpdates(new URL("../sw.js", import.meta.url), worker.ready, onAvailable),
|
||||
});
|
||||
|
||||
// Hors ligne : le service worker met tout le site en cache. Le navigateur le
|
||||
// refuse hors https (sauf localhost) : l'application marche alors sans.
|
||||
// Il est enregistré une fois le moteur chargé : si le Worker et le service
|
||||
// worker téléchargent les données du moteur (8,7 Mo) en même temps, Chromium
|
||||
// fait échouer l'installation (« Cache.add() encountered a network error »),
|
||||
// et l'application n'est pas disponible hors ligne. Cela évite aussi de
|
||||
// télécharger deux fois ces données à la première visite.
|
||||
if ("serviceWorker" in navigator) {
|
||||
const sw = new URL("../sw.js", import.meta.url);
|
||||
worker.ready.then(() => navigator.serviceWorker.register(sw)).catch(() => {});
|
||||
}
|
||||
|
||||
/** La valeur, ou undefined si l'obtenir lève une exception. */
|
||||
function attempt<T>(get: () => T): T | undefined {
|
||||
try {
|
||||
|
||||
+328
-18
@@ -52,11 +52,19 @@ import { createGamesView, type GamesView } from "./games-view.js";
|
||||
import { boardGeometry, type Geometry, type Layout, PORTRAIT_ASPECT } from "./geometry.js";
|
||||
import { createHaptics, type Vibrate } from "./haptics.js";
|
||||
import { createHelpView, loadGlossaryEntry } from "./help.js";
|
||||
import { type HelpMoment, helpHash } from "./help-context.js";
|
||||
import { dismissInstall, homeModel, installMenuShown, launchHash, welcomed } from "./home-model.js";
|
||||
import { HOME_TEXTS } from "./home-texts.js";
|
||||
import { createHomeView } from "./home-view.js";
|
||||
import { icon } from "./icons.js";
|
||||
import { installPlatform, startInstaller } from "./install.js";
|
||||
import { replacingLink, startNavigation } from "./navigation.js";
|
||||
import { isHelpScreen, type Route, routeHash } from "./router.js";
|
||||
import { longPrompts, nextTheme, parseSettings, type Settings } from "./settings.js";
|
||||
import { isFirstLaunch, longPrompts, nextTheme, parseSettings, type Settings } from "./settings.js";
|
||||
import { type PromptText, TEXTS } from "./texts.js";
|
||||
import { seenAfter } from "./tutorial-nav.js";
|
||||
import { createTutorialView, type TutorialAccess } from "./tutorial-view.js";
|
||||
import { betweenTurns, type Updates, updateBannerShown } from "./updates.js";
|
||||
|
||||
/** Glissement d'un pion de l'ordinateur, et pause entre deux pions
|
||||
* (interaction.md : on suit le coup sans l'attendre). */
|
||||
@@ -95,6 +103,11 @@ export interface AppOptions {
|
||||
/** Les réglages, en JSON (null : jamais enregistrés). */
|
||||
readonly loadSettings: () => string | null;
|
||||
readonly saveSettings: (json: string) => void;
|
||||
/** Le tutoriel : plateaux, coups illustrés, gestes jugés par le moteur. */
|
||||
readonly tutorial: TutorialAccess;
|
||||
/** Les mises à jour de l'application (service worker) : onAvailable est
|
||||
* appelé quand une nouvelle version attend. */
|
||||
readonly startUpdates: (onAvailable: () => void) => Updates;
|
||||
}
|
||||
|
||||
/** Ce que « Mes parties » demande au stockage. */
|
||||
@@ -222,6 +235,21 @@ export function startApp(options: AppOptions): void {
|
||||
banner: byId("banner"),
|
||||
bannerText: byId("banner-text"),
|
||||
resumeOld: byId<HTMLButtonElement>("resume-old"),
|
||||
home: byId("home"),
|
||||
tutorial: byId("tutorial"),
|
||||
menuHome: byId<HTMLAnchorElement>("menu-home"),
|
||||
menuTutorial: byId<HTMLAnchorElement>("menu-tutorial"),
|
||||
menuInstall: byId<HTMLButtonElement>("menu-install"),
|
||||
menuUpdate: byId<HTMLButtonElement>("menu-update"),
|
||||
installVeil: byId("install-veil"),
|
||||
installTitle: byId("install-title"),
|
||||
installClose: byId<HTMLButtonElement>("install-close"),
|
||||
installLead: byId("install-lead"),
|
||||
installSteps: byId("install-steps"),
|
||||
installDone: byId<HTMLButtonElement>("install-done"),
|
||||
updateBanner: byId("update-banner"),
|
||||
updateText: byId("update-text"),
|
||||
updateApply: byId<HTMLButtonElement>("update-apply"),
|
||||
};
|
||||
|
||||
// ------------------------------------------------------ Réglages
|
||||
@@ -304,6 +332,28 @@ export function startApp(options: AppOptions): void {
|
||||
child(ui.menuScreenOn, ".item-label").textContent = TEXTS.keepScreenOn;
|
||||
ui.bannerText.textContent = TEXTS.newGameStarted;
|
||||
ui.resumeOld.textContent = TEXTS.resumeOld;
|
||||
label(ui.menuHome, HOME_TEXTS.homeLink, "home");
|
||||
label(ui.menuTutorial, HOME_TEXTS.tutorialMenu, "rules");
|
||||
label(ui.menuInstall, HOME_TEXTS.installMenu, "install");
|
||||
label(ui.menuUpdate, HOME_TEXTS.updateCheck, "replay");
|
||||
ui.installTitle.textContent = HOME_TEXTS.installTitle;
|
||||
ui.installClose.append(icon("close"));
|
||||
ui.installClose.setAttribute("aria-label", TEXTS.close);
|
||||
ui.installLead.textContent = HOME_TEXTS.installLead;
|
||||
ui.installDone.textContent = HOME_TEXTS.installUnderstood;
|
||||
ui.updateApply.textContent = HOME_TEXTS.updateApply;
|
||||
for (const [i, text] of HOME_TEXTS.installSteps[
|
||||
installPlatform(navigator.userAgent)
|
||||
].entries()) {
|
||||
const item = document.createElement("li");
|
||||
const number = document.createElement("span");
|
||||
number.className = "step-number";
|
||||
number.textContent = String(i + 1);
|
||||
const words = document.createElement("span");
|
||||
words.textContent = text;
|
||||
item.append(number, words);
|
||||
ui.installSteps.append(item);
|
||||
}
|
||||
const glossaryLink = child<HTMLAnchorElement>(ui.bubble, ".link-button");
|
||||
glossaryLink.textContent = TEXTS.seeGlossary;
|
||||
glossaryLink.href = routeHash({
|
||||
@@ -352,6 +402,23 @@ export function startApp(options: AppOptions): void {
|
||||
// La position partagée affichée (#/position/...), une fois lue.
|
||||
let shownPosition: SharedPosition | null = null;
|
||||
let positionRequest = 0;
|
||||
// L'installation (invite du navigateur, application installée) et les
|
||||
// mises à jour (service worker).
|
||||
const installer = startInstaller((installedNow) => {
|
||||
if (installedNow) {
|
||||
saveSettings({ installed: true });
|
||||
} else {
|
||||
render();
|
||||
}
|
||||
});
|
||||
let updates: Updates | null = null;
|
||||
// Un message éphémère dans le bandeau de mise à jour (« Vous avez la
|
||||
// dernière version. »), sans bouton.
|
||||
let updateMessage: string | null = null;
|
||||
let updateMessageTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
// Les parties jouées et gagnées, lues dans « Mes parties » pour
|
||||
// l'accueil.
|
||||
let homeProgress: { played: number; won: number } | null = null;
|
||||
|
||||
const landscapeQuery = window.matchMedia("(orientation: landscape) and (max-height: 40rem)");
|
||||
const portraitQuery = window.matchMedia("(orientation: portrait)");
|
||||
@@ -515,6 +582,7 @@ export function startApp(options: AppOptions): void {
|
||||
ui.cancel.disabled = busy || game.pendingHalfMoves.length === 0;
|
||||
renderPanel(game, landscape, doubled);
|
||||
renderMenu();
|
||||
renderChrome();
|
||||
ui.resumeOld.disabled = busy;
|
||||
// Pour le style et pour les tests dans un navigateur : l'écran
|
||||
// attend le moteur.
|
||||
@@ -563,6 +631,7 @@ export function startApp(options: AppOptions): void {
|
||||
ui.info.textContent = notice;
|
||||
}
|
||||
renderMenu();
|
||||
renderChrome();
|
||||
}
|
||||
|
||||
/** Les commandes de la colonne de gauche, en paysage. */
|
||||
@@ -641,6 +710,52 @@ export function startApp(options: AppOptions): void {
|
||||
element.hidden = !visible;
|
||||
}
|
||||
|
||||
/** Ce qui entoure les écrans : l'aide du moment derrière le bouton
|
||||
* « Aide », la ligne « Installer » du menu, le bandeau de mise à jour,
|
||||
* et l'accueil s'il est affiché. */
|
||||
function renderChrome(): void {
|
||||
const helpLink = helpHash(helpMoment());
|
||||
ui.helpButton.href = helpLink;
|
||||
ui.menuHelp.href = helpLink;
|
||||
ui.menuInstall.hidden = !installMenuShown(settings, installer.installed);
|
||||
// Le bandeau de mise à jour attend la fin du tour, même hors de
|
||||
// l'écran de jeu : la partie enregistrée reprend alors sans perte.
|
||||
const quiet =
|
||||
!busy &&
|
||||
(game === null || betweenTurns(game.state.phase, game.pendingHalfMoves.length));
|
||||
if (updateMessage !== null) {
|
||||
ui.updateBanner.hidden = false;
|
||||
ui.updateText.textContent = updateMessage;
|
||||
ui.updateApply.hidden = true;
|
||||
} else {
|
||||
const shown = updateBannerShown(updates?.available ?? false, quiet, !ui.banner.hidden);
|
||||
ui.updateBanner.hidden = !shown;
|
||||
ui.updateText.textContent = HOME_TEXTS.updateAvailable;
|
||||
ui.updateApply.hidden = false;
|
||||
}
|
||||
if (navigation.route.screen === "home") {
|
||||
renderHome();
|
||||
}
|
||||
}
|
||||
|
||||
/** Le moment de la partie, pour ouvrir l'aide sur la bonne question. */
|
||||
function helpMoment(): HelpMoment | null {
|
||||
if (game === null) {
|
||||
return null;
|
||||
}
|
||||
const state = game.state;
|
||||
if (state.phase === "over") {
|
||||
return "over";
|
||||
}
|
||||
if (state.turn === "computer" && state.phase === "doubled") {
|
||||
return "doubled";
|
||||
}
|
||||
if (state.turn === "player" && state.phase === "moving") {
|
||||
return stuck ? "stuck" : "moving";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// --------------------------------------------- Vibrations, écran allumé
|
||||
|
||||
/** Une seule vibration par fin de partie. */
|
||||
@@ -801,10 +916,18 @@ export function startApp(options: AppOptions): void {
|
||||
notice = null;
|
||||
ui.bubble.hidden = true;
|
||||
render();
|
||||
const before = game;
|
||||
const wasOver = game.state.phase === "over";
|
||||
try {
|
||||
await action(game);
|
||||
save();
|
||||
await computerTurns();
|
||||
// Une partie vient de se terminer (pas une partie finie
|
||||
// reprise) : elle compte pour l'invitation à installer.
|
||||
if (game === before && !wasOver && game.state.phase === "over") {
|
||||
saveSettings({ gamesFinished: settings.gamesFinished + 1 });
|
||||
homeProgress = null;
|
||||
}
|
||||
} catch (error) {
|
||||
info = TEXTS.actionFailed(error instanceof Error ? error.message : String(error));
|
||||
} finally {
|
||||
@@ -888,7 +1011,8 @@ export function startApp(options: AppOptions): void {
|
||||
|
||||
// L'adresse de la page dit quel écran montrer (applyRoute, plus bas).
|
||||
const navigation = startNavigation((route) => applyRoute(route));
|
||||
const isSheet = (route: Route) => route.screen === "menu" || route.screen === "games";
|
||||
const isSheet = (route: Route) =>
|
||||
route.screen === "menu" || route.screen === "games" || route.screen === "install";
|
||||
|
||||
// « Mes parties » : reprendre une partie (ou une branche) en change à
|
||||
// l'écran, sans recharger la page.
|
||||
@@ -1018,7 +1142,7 @@ export function startApp(options: AppOptions): void {
|
||||
},
|
||||
help: () => {
|
||||
closeTransient();
|
||||
navigation.go("#/aide");
|
||||
navigation.go(helpHash(helpMoment()));
|
||||
},
|
||||
fullscreen: () => {
|
||||
closeOverlays();
|
||||
@@ -1037,6 +1161,8 @@ export function startApp(options: AppOptions): void {
|
||||
navigation.leave(isHelpScreen);
|
||||
} else if (route.screen === "position") {
|
||||
navigation.leave((r) => r.screen === "position");
|
||||
} else if (route.screen === "tutorial" || route.screen === "tutorialEnd") {
|
||||
navigation.leave((r) => r.screen === "tutorial" || r.screen === "tutorialEnd");
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -1075,6 +1201,7 @@ export function startApp(options: AppOptions): void {
|
||||
function leaveSheet(): void {
|
||||
const menuWasOpen = !ui.menuVeil.hidden;
|
||||
ui.menuVeil.hidden = true;
|
||||
ui.installVeil.hidden = true;
|
||||
gamesView?.hide();
|
||||
if (menuWasOpen) {
|
||||
(menuReturnFocus ?? ui.menuButton).focus();
|
||||
@@ -1125,6 +1252,8 @@ export function startApp(options: AppOptions): void {
|
||||
// téléphone ne rouvre pas le menu.
|
||||
replacingLink(ui.menuGames, navigation);
|
||||
replacingLink(ui.menuHelp, navigation);
|
||||
replacingLink(ui.menuHome, navigation);
|
||||
replacingLink(ui.menuTutorial, navigation);
|
||||
ui.menuClose.onclick = () => closeOverlays();
|
||||
ui.menuNewGame.onclick = () => newGame();
|
||||
ui.menuCopyLink.onclick = () => {
|
||||
@@ -1159,19 +1288,56 @@ export function startApp(options: AppOptions): void {
|
||||
// ----------------------------------------------------------- Adresses
|
||||
|
||||
let menuReturnFocus: HTMLElement | null = null;
|
||||
// L'écran sous la feuille d'installation : le dernier écran affiché.
|
||||
let underSheet: Route = { screen: "game" };
|
||||
|
||||
/** Dessine l'écran que désigne l'adresse (ADR 0013, amendement). */
|
||||
function applyRoute(route: Route): void {
|
||||
const help = isHelpScreen(route);
|
||||
const position = route.screen === "position";
|
||||
// La feuille d'installation s'ouvre par-dessus l'écran d'où l'on
|
||||
// vient (l'accueil, le plus souvent), qui reste dessous.
|
||||
if (route.screen !== "install") {
|
||||
underSheet = route;
|
||||
}
|
||||
const below = underSheet;
|
||||
const help = isHelpScreen(below);
|
||||
const position = below.screen === "position";
|
||||
const home = below.screen === "home";
|
||||
const tutorial = below.screen === "tutorial" || below.screen === "tutorialEnd";
|
||||
const page = help || position || home || tutorial;
|
||||
const wasPage = ui.game.hidden;
|
||||
ui.game.hidden = help || position;
|
||||
const wasScreen = ui.root.dataset.screen;
|
||||
ui.game.hidden = page;
|
||||
ui.help.hidden = !help;
|
||||
ui.position.hidden = !position;
|
||||
ui.root.classList.toggle("page-open", help || position);
|
||||
if (wasPage && !ui.game.hidden) {
|
||||
ui.home.hidden = !home;
|
||||
ui.tutorial.hidden = !tutorial;
|
||||
ui.root.classList.toggle("page-open", page);
|
||||
// Le tutoriel et le premier lancement ont leur propre en-tête.
|
||||
ui.root.classList.toggle("bare", tutorial || (home && isFirstLaunch(settings)));
|
||||
ui.root.dataset.screen = below.screen;
|
||||
if ((wasPage && !ui.game.hidden) || (page && wasScreen !== below.screen)) {
|
||||
window.scrollTo(0, 0);
|
||||
}
|
||||
if (tutorial) {
|
||||
if (route.screen !== "install") {
|
||||
void tutorialView.show(below);
|
||||
}
|
||||
} else {
|
||||
tutorialView.hide();
|
||||
}
|
||||
if (home) {
|
||||
homeProgress = null;
|
||||
}
|
||||
|
||||
// La marche à suivre pour installer : une feuille, comme le menu.
|
||||
if (route.screen === "install" && ui.installVeil.hidden) {
|
||||
closeTransient();
|
||||
ui.menuVeil.hidden = true;
|
||||
ui.installVeil.hidden = false;
|
||||
ui.installDone.focus();
|
||||
} else if (route.screen !== "install") {
|
||||
ui.installVeil.hidden = true;
|
||||
}
|
||||
|
||||
// Feuilles : le menu et « Mes parties ».
|
||||
const menuOpen = route.screen === "menu";
|
||||
@@ -1204,26 +1370,161 @@ export function startApp(options: AppOptions): void {
|
||||
gamesView?.hide();
|
||||
}
|
||||
|
||||
if (help) {
|
||||
void helpView.show(route);
|
||||
const sheet = route.screen === "install";
|
||||
if (isHelpScreen(below) && !sheet) {
|
||||
void helpView.show(below);
|
||||
}
|
||||
if (position) {
|
||||
void showPosition(route);
|
||||
if (below.screen === "position") {
|
||||
if (!sheet) {
|
||||
void showPosition(below);
|
||||
}
|
||||
} else {
|
||||
positionRequest++;
|
||||
shownPosition = null;
|
||||
}
|
||||
|
||||
// Écrans à venir (0e.5) et adresses inconnues : la partie, avec un
|
||||
// message, à l'adresse de la partie.
|
||||
if (route.screen === "home" || route.screen === "tutorial" || route.screen === "unknown") {
|
||||
notice = route.screen === "unknown" ? TEXTS.unknownAddress : TEXTS.screenNotYet;
|
||||
// Une adresse inconnue : la partie, avec un message, à l'adresse de
|
||||
// la partie.
|
||||
if (route.screen === "unknown") {
|
||||
notice = TEXTS.unknownAddress;
|
||||
navigation.replace("#/");
|
||||
return;
|
||||
}
|
||||
render();
|
||||
}
|
||||
|
||||
// ----------------------------------------------- Accueil et tutoriel
|
||||
|
||||
const homeView = createHomeView({
|
||||
section: ui.home,
|
||||
newGame: () => {
|
||||
if (busy || game === null) {
|
||||
return;
|
||||
}
|
||||
navigation.go("#/");
|
||||
newGame();
|
||||
},
|
||||
install: () => void install(false),
|
||||
dismissInstall: () => saveSettings(dismissInstall(settings)),
|
||||
welcomed: () => saveSettings(welcomed()),
|
||||
});
|
||||
|
||||
let progressLoading = false;
|
||||
function renderHome(): void {
|
||||
const playing = game === null ? null : game.state.phase !== "over";
|
||||
homeView.render({
|
||||
model: homeModel(settings, playing, installer.installed),
|
||||
sides: sides ?? options.cachedBoard()?.sides ?? null,
|
||||
tutorialSeen: settings.tutorialSeen,
|
||||
progress: homeProgress,
|
||||
busy: busy || game === null,
|
||||
});
|
||||
if (homeProgress === null && !progressLoading) {
|
||||
progressLoading = true;
|
||||
// La liste vient du stockage, ouvert avec le moteur.
|
||||
options.engineReady
|
||||
.then(() => options.games.list())
|
||||
.then((list) => {
|
||||
const over = list.filter((summary) => summary.status === "over");
|
||||
homeProgress = {
|
||||
played: over.length,
|
||||
won: over.filter((summary) => summary.result?.winner === "player").length,
|
||||
};
|
||||
if (navigation.route.screen === "home") {
|
||||
renderHome();
|
||||
}
|
||||
})
|
||||
.catch(() => undefined)
|
||||
.finally(() => {
|
||||
progressLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const tutorialView = createTutorialView({
|
||||
section: ui.tutorial,
|
||||
access: options.tutorial,
|
||||
engineReady: options.engineReady,
|
||||
seen: () => settings.tutorialSeen,
|
||||
visit: (place) => {
|
||||
const seen = seenAfter(settings.tutorialSeen, place);
|
||||
if (seen !== settings.tutorialSeen || !settings.welcomed) {
|
||||
saveSettings({ tutorialSeen: seen, ...welcomed() });
|
||||
}
|
||||
},
|
||||
placed: () => haptic("checkerPlaced"),
|
||||
reducedMotion,
|
||||
landscape: () => layout() === "landscape",
|
||||
replace: (hash) => navigation.replace(hash),
|
||||
});
|
||||
|
||||
// ------------------------------------------------------- Installation
|
||||
|
||||
/** « Installer » : l'invite du navigateur s'il en a donné une, sinon la
|
||||
* marche à suivre (#/installer). Depuis le menu, l'adresse du menu est
|
||||
* remplacée : le retour du téléphone ne le rouvre pas. */
|
||||
async function install(fromMenu: boolean): Promise<void> {
|
||||
if (installer.canPrompt) {
|
||||
if (fromMenu) {
|
||||
closeOverlays();
|
||||
}
|
||||
await installer.offer();
|
||||
render();
|
||||
return;
|
||||
}
|
||||
if (fromMenu) {
|
||||
ui.menuVeil.hidden = true;
|
||||
navigation.replace("#/installer");
|
||||
} else {
|
||||
navigation.go("#/installer");
|
||||
}
|
||||
}
|
||||
ui.menuInstall.onclick = () => void install(true);
|
||||
const leaveInstall = () => {
|
||||
ui.installVeil.hidden = true;
|
||||
navigation.leave((route) => route.screen === "install");
|
||||
};
|
||||
ui.installClose.onclick = leaveInstall;
|
||||
ui.installDone.onclick = leaveInstall;
|
||||
ui.installVeil.addEventListener("click", (event) => {
|
||||
if (event.target === ui.installVeil) {
|
||||
leaveInstall();
|
||||
}
|
||||
});
|
||||
|
||||
// ------------------------------------------------------- Mise à jour
|
||||
|
||||
updates = options.startUpdates(() => render());
|
||||
ui.updateApply.onclick = () => {
|
||||
ui.updateApply.disabled = true;
|
||||
updates?.apply();
|
||||
};
|
||||
function showUpdateMessage(message: string | null, lasting: boolean): void {
|
||||
if (updateMessageTimer !== null) {
|
||||
clearTimeout(updateMessageTimer);
|
||||
updateMessageTimer = null;
|
||||
}
|
||||
updateMessage = message;
|
||||
if (message !== null && !lasting) {
|
||||
updateMessageTimer = setTimeout(() => showUpdateMessage(null, true), 4000);
|
||||
}
|
||||
render();
|
||||
}
|
||||
ui.menuUpdate.onclick = () => {
|
||||
closeOverlays();
|
||||
showUpdateMessage(HOME_TEXTS.updateChecking, true);
|
||||
void (updates?.check() ?? Promise.resolve("unavailable" as const)).then((found) => {
|
||||
showUpdateMessage(
|
||||
found === "found"
|
||||
? null
|
||||
: found === "latest"
|
||||
? HOME_TEXTS.updateLatest
|
||||
: HOME_TEXTS.updateUnavailable,
|
||||
found === "found",
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
// -------------------------------------------- Vue d'une position
|
||||
|
||||
/** #/position/... : lit la position par le moteur, puis la montre. */
|
||||
@@ -1418,6 +1719,9 @@ export function startApp(options: AppOptions): void {
|
||||
if (shownPosition !== null) {
|
||||
renderPosition({ kind: "shown", shared: shownPosition });
|
||||
}
|
||||
if (!ui.tutorial.hidden) {
|
||||
tutorialView.relayout();
|
||||
}
|
||||
});
|
||||
};
|
||||
window.addEventListener("resize", relayout);
|
||||
@@ -1560,8 +1864,14 @@ export function startApp(options: AppOptions): void {
|
||||
// ---------------------------------------------------------- Démarrage
|
||||
|
||||
// Réponse immédiate : le plateau est dessiné avant que le moteur arrive,
|
||||
// ou l'écran que désigne l'adresse.
|
||||
applyRoute(navigation.route);
|
||||
// ou l'écran que désigne l'adresse. Lancée sans adresse d'écran (icône,
|
||||
// adresse du site), l'application s'ouvre sur l'accueil.
|
||||
const launch = launchHash(location.hash);
|
||||
if (launch !== null) {
|
||||
navigation.replace(launch);
|
||||
} else {
|
||||
applyRoute(navigation.route);
|
||||
}
|
||||
options.engineReady.then(
|
||||
async () => {
|
||||
game = options.savedGame();
|
||||
|
||||
@@ -166,6 +166,7 @@ export function renderBoard(
|
||||
// Les pions, dessinés à part puis posés par-dessus les flèches du coup
|
||||
// de l'ordinateur : une flèche ne cache jamais un pion.
|
||||
const pieces = document.createElementNS(SVG_NS, "g");
|
||||
pieces.setAttribute("class", "pieces");
|
||||
for (const point of board.points) {
|
||||
drawPoint(svg, pieces, g, hits, board, point.number, decor);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
// La question de l'aide qui correspond au moment de la partie : le bouton
|
||||
// « Aide » de l'écran de jeu l'ouvre directement (charte : « fermée par
|
||||
// défaut sauf celle qui correspond à l'écran d'où l'on vient »).
|
||||
//
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
//
|
||||
// Les questions sont les titres des scénarios de docs/scenarios/ (ADR 0008) ;
|
||||
// leur adresse en est tirée par slug. Un test (app/tests/ui.test.ts) vérifie
|
||||
// que chaque titre ci-dessous est bien celui d'un scénario : changer un
|
||||
// titre dans un .feature demande de le changer ici.
|
||||
|
||||
import { routeHash, slug } from "./router.js";
|
||||
|
||||
/** Le moment de la partie, vu de l'aide. */
|
||||
export type HelpMoment = "moving" | "stuck" | "doubled" | "over";
|
||||
|
||||
export const HELP_QUESTIONS: Readonly<Record<HelpMoment, string>> = {
|
||||
moving: "Comment jouer un coup ?",
|
||||
stuck: "Je ne peux rien jouer avec mes dés",
|
||||
doubled: "L'ordinateur double : que se passe-t-il si je prends ?",
|
||||
over: "Comment se termine une partie ?",
|
||||
};
|
||||
|
||||
/** L'adresse de l'aide à ouvrir à ce moment (l'onglet « Jouer » sans
|
||||
* question particulière : null). */
|
||||
export function helpHash(moment: HelpMoment | null): string {
|
||||
return routeHash({
|
||||
screen: "help",
|
||||
question: moment === null ? null : slug(HELP_QUESTIONS[moment]),
|
||||
});
|
||||
}
|
||||
+54
-6
@@ -14,10 +14,16 @@ export interface HelpFeature {
|
||||
}
|
||||
|
||||
export interface HelpScenario {
|
||||
/** Le titre, formulé comme la question du joueur. */
|
||||
readonly title: string;
|
||||
/** La réponse : le paragraphe sous le titre. */
|
||||
readonly description: readonly string[];
|
||||
/** Les étapes, mot-clé compris : « Quand vous prenez ». */
|
||||
/** Les étapes, mot-clé compris : « Quand vous prenez ». Elles ne
|
||||
* s'affichent pas dans l'aide : elles prouvent la réponse. */
|
||||
readonly steps: readonly string[];
|
||||
/** La position de l'exemple, donnée par l'étiquette
|
||||
* @position:<Position ID> du scénario (null sans étiquette). */
|
||||
readonly position: string | null;
|
||||
}
|
||||
|
||||
const STEP = /^(Étant donnée?s?|Etant donnée?s?|Soit|Quand|Lorsque|Alors|Donc|Et|Mais)\s/;
|
||||
@@ -27,13 +33,20 @@ const FEATURE = /^Fonctionnalité\s*:\s*(.*)$/;
|
||||
/**
|
||||
* Lit un fichier Gherkin en français (fichiers .feature). Les lignes de texte
|
||||
* libre consécutives forment un paragraphe ; les commentaires (#) sont
|
||||
* ignorés.
|
||||
* ignorés ; une étiquette @position:<Position ID> donne la position de
|
||||
* l'exemple du scénario qui la suit.
|
||||
*/
|
||||
export function parseFeature(text: string): HelpFeature {
|
||||
let title = "";
|
||||
const description: string[] = [];
|
||||
const scenarios: { title: string; description: string[]; steps: string[] }[] = [];
|
||||
const scenarios: {
|
||||
title: string;
|
||||
description: string[];
|
||||
steps: string[];
|
||||
position: string | null;
|
||||
}[] = [];
|
||||
let paragraph: string[] = [];
|
||||
let tags: string[] = [];
|
||||
|
||||
// Le paragraphe en cours rejoint la description de la fonctionnalité ou
|
||||
// du dernier scénario.
|
||||
@@ -50,12 +63,23 @@ export function parseFeature(text: string): HelpFeature {
|
||||
const scenario = SCENARIO.exec(line);
|
||||
if (line === "" || line.startsWith("#")) {
|
||||
endParagraph();
|
||||
} else if (line.startsWith("@")) {
|
||||
endParagraph();
|
||||
tags.push(...line.split(/\s+/));
|
||||
} else if (feature) {
|
||||
endParagraph();
|
||||
title = feature[1] ?? "";
|
||||
tags = [];
|
||||
} else if (scenario) {
|
||||
endParagraph();
|
||||
scenarios.push({ title: scenario[2] ?? "", description: [], steps: [] });
|
||||
const position = tags.find((tag) => tag.startsWith("@position:"));
|
||||
scenarios.push({
|
||||
title: scenario[2] ?? "",
|
||||
description: [],
|
||||
steps: [],
|
||||
position: position === undefined ? null : position.slice("@position:".length),
|
||||
});
|
||||
tags = [];
|
||||
} else if (STEP.test(line) && scenarios.length > 0) {
|
||||
endParagraph();
|
||||
scenarios.at(-1)?.steps.push(line);
|
||||
@@ -74,18 +98,42 @@ export function parseGlossaryIndex(readme: string): string[] {
|
||||
return [...section.matchAll(/\]\(([a-z0-9-]+\.md)\)/g)].map((match) => match[1] ?? "");
|
||||
}
|
||||
|
||||
/** Une fiche du glossaire : le terme et sa phrase « En bref ». */
|
||||
/** Une fiche du glossaire : le terme, son nom anglais (celui du code), sa
|
||||
* phrase « En bref », et ses renvois « Voir aussi ». */
|
||||
export interface GlossaryEntry {
|
||||
readonly term: string;
|
||||
readonly english: string;
|
||||
readonly brief: string;
|
||||
/** Les fiches citées par « Voir aussi » : le mot, et la fiche sans .md. */
|
||||
readonly seeAlso: readonly { readonly term: string; readonly name: string }[];
|
||||
}
|
||||
|
||||
/** Lit une fiche du glossaire ; null si elle n'a pas la forme attendue. */
|
||||
export function parseGlossaryEntry(text: string): GlossaryEntry | null {
|
||||
const term = /^term: (.+)$/m.exec(text)?.[1]?.trim();
|
||||
const english = /^english: (.+)$/m.exec(text)?.[1]?.trim() ?? "";
|
||||
const brief = /^\*\*En bref\*\* : (.+)$/m.exec(text)?.[1]?.trim();
|
||||
if (term === undefined || brief === undefined) {
|
||||
return null;
|
||||
}
|
||||
return { term, brief: brief.replaceAll("**", "") };
|
||||
const seeLine = /^Voir aussi : (.+)$/m.exec(text)?.[1] ?? "";
|
||||
const seeAlso = [...seeLine.matchAll(/\[([^\]]+)\]\(([a-z0-9-]+)\.md\)/g)].map((m) => ({
|
||||
term: m[1] ?? "",
|
||||
name: m[2] ?? "",
|
||||
}));
|
||||
return { term, english, brief: brief.replaceAll("**", ""), seeAlso };
|
||||
}
|
||||
|
||||
/** Un texte sans accents ni majuscules, pour la recherche dans le
|
||||
* glossaire. */
|
||||
export function searchable(text: string): string {
|
||||
return text.normalize("NFD").replace(/[̀-ͯ]/g, "").toLowerCase();
|
||||
}
|
||||
|
||||
/** La fiche correspond-elle à la recherche (le terme, son nom anglais ou sa
|
||||
* phrase « En bref » contiennent chacun des mots cherchés) ? */
|
||||
export function matchesSearch(entry: GlossaryEntry, query: string): boolean {
|
||||
const words = searchable(query).split(/\s+/).filter(Boolean);
|
||||
const haystack = searchable(`${entry.term} ${entry.english} ${entry.brief}`);
|
||||
return words.every((word) => haystack.includes(word));
|
||||
}
|
||||
|
||||
+112
-21
@@ -8,25 +8,33 @@
|
||||
//
|
||||
// Chaque onglet, chaque question et chaque terme a son adresse (ADR 0013,
|
||||
// amendement) : on les ouvre par de vrais liens, et l'adresse dit ce qui
|
||||
// est ouvert (show).
|
||||
// est ouvert (show). Le titre d'un scénario est la question, sa
|
||||
// description la réponse ; ses étapes, la preuve que l'application fait ce
|
||||
// que dit la réponse, ne s'affichent pas
|
||||
// (docs/identite/charte/accueil-tutoriel-aide.md, « Aide »).
|
||||
|
||||
import {
|
||||
type GlossaryEntry,
|
||||
type HelpFeature,
|
||||
type HelpScenario,
|
||||
matchesSearch,
|
||||
parseFeature,
|
||||
parseGlossaryEntry,
|
||||
parseGlossaryIndex,
|
||||
} from "./help-text.js";
|
||||
import { HOME_TEXTS } from "./home-texts.js";
|
||||
import { type HelpRoute, routeHash, slug } from "./router.js";
|
||||
import { TEXTS } from "./texts.js";
|
||||
|
||||
/** Les scénarios montrés dans l'aide, dans l'ordre. */
|
||||
const FEATURES = [
|
||||
export const FEATURES = [
|
||||
"docs/scenarios/jouer-une-partie.feature",
|
||||
"docs/scenarios/apprendre-les-regles.feature",
|
||||
"docs/scenarios/mes-parties.feature",
|
||||
"docs/scenarios/partager-une-position.feature",
|
||||
"docs/scenarios/regler-l-application.feature",
|
||||
"docs/scenarios/installer-l-application.feature",
|
||||
"docs/scenarios/decouvrir-l-application.feature",
|
||||
];
|
||||
const GLOSSARY = "docs/glossaire/";
|
||||
|
||||
@@ -59,6 +67,18 @@ export async function loadGlossaryEntry(file: string): Promise<GlossaryEntry | n
|
||||
return parseGlossaryEntry(await fetchText(GLOSSARY + file));
|
||||
}
|
||||
|
||||
/** Le commit publié (version.json, écrit par make site), en abrégé. */
|
||||
async function publishedVersion(): Promise<string | null> {
|
||||
try {
|
||||
const version = JSON.parse(await fetchText("version.json")) as { commit?: unknown };
|
||||
return typeof version.commit === "string" && version.commit !== ""
|
||||
? version.commit.slice(0, 12)
|
||||
: null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function element<K extends keyof HTMLElementTagNameMap>(
|
||||
parent: HTMLElement,
|
||||
tag: K,
|
||||
@@ -101,7 +121,7 @@ export function createHelpView(container: HTMLElement, tabs: HTMLElement): HelpV
|
||||
notice.className = "help-notice";
|
||||
notice.setAttribute("role", "status");
|
||||
const questions: Question[] = [];
|
||||
const terms = new Map<string, HTMLElement>();
|
||||
const terms = new Map<string, { dt: HTMLElement; card: HTMLElement; entry: GlossaryCard }>();
|
||||
let loading: Promise<boolean> | null = null;
|
||||
// La question ouverte au dernier affichage : refermée, elle reste en vue.
|
||||
let lastOpen: Question | null = null;
|
||||
@@ -126,10 +146,14 @@ export function createHelpView(container: HTMLElement, tabs: HTMLElement): HelpV
|
||||
for (const paragraph of scenario.description) {
|
||||
element(answer, "p", paragraph);
|
||||
}
|
||||
const steps = element(answer, "ul");
|
||||
steps.className = "steps";
|
||||
for (const step of scenario.steps) {
|
||||
element(steps, "li", step);
|
||||
if (scenario.position !== null) {
|
||||
const board = element(answer, "a", HOME_TEXTS.seeOnBoard);
|
||||
board.className = "see-board";
|
||||
board.href = routeHash({
|
||||
screen: "position",
|
||||
position: scenario.position,
|
||||
match: null,
|
||||
});
|
||||
}
|
||||
questions.push({ id, section, link, answer });
|
||||
}
|
||||
@@ -137,7 +161,7 @@ export function createHelpView(container: HTMLElement, tabs: HTMLElement): HelpV
|
||||
function renderFeature(feature: HelpFeature): void {
|
||||
element(play, "h2", feature.title);
|
||||
for (const paragraph of feature.description) {
|
||||
element(play, "p", paragraph);
|
||||
element(play, "p", paragraph).className = "feature-intro";
|
||||
}
|
||||
for (const scenario of feature.scenarios) {
|
||||
renderScenario(play, scenario);
|
||||
@@ -146,38 +170,103 @@ export function createHelpView(container: HTMLElement, tabs: HTMLElement): HelpV
|
||||
|
||||
function renderGlossary(entries: readonly GlossaryCard[]): void {
|
||||
element(glossary, "h2", TEXTS.glossaryTitle);
|
||||
const label = element(glossary, "label");
|
||||
label.className = "glossary-search";
|
||||
element(label, "span", HOME_TEXTS.glossarySearch).className = "search-label";
|
||||
const search = element(label, "input");
|
||||
search.id = "glossary-search";
|
||||
search.type = "search";
|
||||
search.autocomplete = "off";
|
||||
search.spellcheck = false;
|
||||
const none = element(glossary, "p", HOME_TEXTS.glossaryNone);
|
||||
none.className = "glossary-none muted";
|
||||
none.hidden = true;
|
||||
const list = element(glossary, "dl");
|
||||
list.className = "glossary";
|
||||
for (const entry of entries) {
|
||||
const term = element(list, "dt");
|
||||
term.id = `glossaire-${entry.name}`;
|
||||
const link = element(term, "a", entry.term);
|
||||
const sorted = [...entries].sort((a, b) => a.term.localeCompare(b.term, "fr"));
|
||||
for (const entry of sorted) {
|
||||
const card = element(list, "div");
|
||||
card.className = "term-card";
|
||||
const dt = element(card, "dt");
|
||||
dt.id = `glossaire-${entry.name}`;
|
||||
const link = element(dt, "a", entry.term);
|
||||
link.href = routeHash({ screen: "glossary", term: entry.name });
|
||||
element(list, "dd", entry.brief);
|
||||
terms.set(entry.name, term);
|
||||
if (entry.english !== "") {
|
||||
const english = element(dt, "span", entry.english);
|
||||
english.className = "english";
|
||||
english.lang = "en";
|
||||
}
|
||||
const dd = element(card, "dd");
|
||||
element(dd, "p", entry.brief);
|
||||
if (entry.seeAlso.length > 0) {
|
||||
const see = element(dd, "p");
|
||||
see.className = "see-also";
|
||||
see.append(`${HOME_TEXTS.glossarySeeAlso} `);
|
||||
entry.seeAlso.forEach((other, i) => {
|
||||
if (i > 0) {
|
||||
see.append(", ");
|
||||
}
|
||||
const a = element(see, "a", other.term);
|
||||
a.href = routeHash({ screen: "glossary", term: other.name });
|
||||
});
|
||||
}
|
||||
terms.set(entry.name, { dt, card, entry });
|
||||
}
|
||||
search.addEventListener("input", () => {
|
||||
let shown = 0;
|
||||
for (const { card, entry } of terms.values()) {
|
||||
const match = matchesSearch(entry, search.value);
|
||||
card.hidden = !match;
|
||||
shown += match ? 1 : 0;
|
||||
}
|
||||
none.hidden = shown > 0;
|
||||
});
|
||||
}
|
||||
|
||||
function renderAbout(): void {
|
||||
function renderAbout(version: string | null): void {
|
||||
element(about, "h2", TEXTS.aboutTitle);
|
||||
const text = element(about, "p", TEXTS.about);
|
||||
text.className = "about";
|
||||
for (const paragraph of HOME_TEXTS.aboutParagraphs) {
|
||||
element(about, "p", paragraph).className = "about";
|
||||
}
|
||||
const line = element(
|
||||
about,
|
||||
"p",
|
||||
version === null ? HOME_TEXTS.aboutVersionUnknown : HOME_TEXTS.aboutVersion(version),
|
||||
);
|
||||
line.id = "about-version";
|
||||
line.className = "about";
|
||||
}
|
||||
|
||||
function renderTutorialCard(): void {
|
||||
const card = element(container, "a");
|
||||
card.id = "help-tutorial";
|
||||
card.className = "card help-tutorial";
|
||||
card.href = "#/tutoriel/1";
|
||||
const text = element(card, "span");
|
||||
text.className = "help-tutorial-text";
|
||||
element(text, "strong", HOME_TEXTS.helpTutorialTitle);
|
||||
element(text, "span", HOME_TEXTS.helpTutorialText).className = "muted";
|
||||
element(card, "span", HOME_TEXTS.helpTutorialLink).className = "pill";
|
||||
}
|
||||
|
||||
async function load(): Promise<boolean> {
|
||||
container.replaceChildren(notice);
|
||||
notice.textContent = TEXTS.helpLoading;
|
||||
try {
|
||||
const [features, entries] = await Promise.all([
|
||||
const [features, entries, version] = await Promise.all([
|
||||
Promise.all(FEATURES.map(async (url) => parseFeature(await fetchText(url)))),
|
||||
loadGlossary(),
|
||||
publishedVersion(),
|
||||
]);
|
||||
const intro = element(play, "p", HOME_TEXTS.helpIntro);
|
||||
intro.className = "help-intro muted";
|
||||
for (const feature of features) {
|
||||
renderFeature(feature);
|
||||
}
|
||||
renderGlossary(entries);
|
||||
renderAbout();
|
||||
renderAbout(version);
|
||||
notice.textContent = "";
|
||||
renderTutorialCard();
|
||||
container.append(play, glossary, about);
|
||||
container.dataset.loaded = "true";
|
||||
return true;
|
||||
@@ -215,10 +304,12 @@ export function createHelpView(container: HTMLElement, tabs: HTMLElement): HelpV
|
||||
q.section.classList.toggle("open", open);
|
||||
}
|
||||
const term = route.screen === "glossary" ? route.term : null;
|
||||
for (const [name, dt] of terms) {
|
||||
for (const [name, { dt, card }] of terms) {
|
||||
dt.classList.toggle("current", name === term);
|
||||
card.classList.toggle("current", name === term);
|
||||
if (name === term) {
|
||||
dt.setAttribute("aria-current", "true");
|
||||
card.hidden = false;
|
||||
} else {
|
||||
dt.removeAttribute("aria-current");
|
||||
}
|
||||
@@ -233,7 +324,7 @@ export function createHelpView(container: HTMLElement, tabs: HTMLElement): HelpV
|
||||
lastOpen = opened;
|
||||
// La barre haute de l'aide reste en vue (app.css) : ce qu'on
|
||||
// amène en haut s'arrête 12 px sous elle (scroll-margin-top).
|
||||
const target = opened?.section ?? (term === null ? undefined : terms.get(term));
|
||||
const target = opened?.section ?? (term === null ? undefined : terms.get(term)?.card);
|
||||
if (target !== undefined) {
|
||||
target.scrollIntoView({ block: "start" });
|
||||
} else if (closed !== null) {
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
// Ce que montre l'accueil, et où s'ouvre l'application : fonctions pures,
|
||||
// testées sous Node (docs/identite/charte/accueil-tutoriel-aide.md).
|
||||
//
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
|
||||
import { installCardShown, isFirstLaunch, type Settings } from "./settings.js";
|
||||
|
||||
/** Les deux choix du premier lancement : « Je découvre » mène au
|
||||
* tutoriel, « Je connais les règles » à la partie. */
|
||||
export const WELCOME_LINKS = { discover: "#/tutoriel/1", play: "#/" } as const;
|
||||
|
||||
/** Les réglages après l'un des deux choix du premier lancement. */
|
||||
export function welcomed(): Partial<Settings> {
|
||||
return { welcomed: true };
|
||||
}
|
||||
|
||||
/** L'adresse où ouvrir l'application lancée sans adresse d'écran (icône de
|
||||
* l'écran d'accueil, adresse du site) : l'accueil. Une adresse donnée est
|
||||
* gardée (null). */
|
||||
export function launchHash(hash: string): string | null {
|
||||
return hash === "" || hash === "#" ? "#/accueil" : null;
|
||||
}
|
||||
|
||||
/** L'accueil, au premier lancement ou ensuite. */
|
||||
export interface HomeModel {
|
||||
/** Premier lancement : le choix « Je découvre » / « Je connais les
|
||||
* règles », et rien d'autre. */
|
||||
readonly welcome: boolean;
|
||||
/** La carte de la partie : « Reprendre la partie » si une partie est en
|
||||
* cours, sinon « Nouvelle partie » en action principale. */
|
||||
readonly resume: boolean;
|
||||
/** L'invitation à installer. */
|
||||
readonly installCard: boolean;
|
||||
}
|
||||
|
||||
/** Ce que montre l'accueil. playing : une partie est en cours (null : pas
|
||||
* encore connu, le moteur se charge). */
|
||||
export function homeModel(
|
||||
settings: Settings,
|
||||
playing: boolean | null,
|
||||
installed: boolean,
|
||||
): HomeModel {
|
||||
const welcome = isFirstLaunch(settings);
|
||||
return {
|
||||
welcome,
|
||||
resume: playing !== false,
|
||||
installCard: !welcome && installCardShown(settings, installed),
|
||||
};
|
||||
}
|
||||
|
||||
/** La ligne « Installer l'application » du menu : tant que l'application
|
||||
* n'est pas installée. */
|
||||
export function installMenuShown(settings: Settings, installed: boolean): boolean {
|
||||
return !installed && !settings.installed;
|
||||
}
|
||||
|
||||
/** Les réglages après « Plus tard » sur l'invitation à installer. */
|
||||
export function dismissInstall(settings: Settings): Partial<Settings> {
|
||||
return {
|
||||
installDismissals: settings.installDismissals + 1,
|
||||
installDismissedAt: settings.gamesFinished,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
// Les textes de l'accueil, du tutoriel, de l'installation et de la mise à
|
||||
// jour (ADR 0007, point 5 ; docs/identite/charte/accueil-tutoriel-aide.md).
|
||||
// Première version, à valider par Pierre. Le texte des étapes du tutoriel
|
||||
// n'est pas ici : il vit dans docs/tutoriel/regles.md.
|
||||
//
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
|
||||
import type { ResultKind } from "../domain/game/engine.js";
|
||||
import type { InstallPlatform } from "./install.js";
|
||||
|
||||
function plural(n: number, one: string, many: string): string {
|
||||
return `${n} ${n > 1 ? many : one}`;
|
||||
}
|
||||
|
||||
export const HOME_TEXTS = {
|
||||
// Premier lancement
|
||||
welcomeTitle: "Bienvenue à la table",
|
||||
welcomeText:
|
||||
"Ustagammon vous apprend le backgammon pas à pas, et vous fait jouer contre l'ordinateur, même sans réseau.",
|
||||
discover: "Je découvre",
|
||||
discoverDetail: "Les règles en huit petites étapes, plateau à l'appui",
|
||||
knowRules: "Je connais les règles",
|
||||
knowRulesDetail: "Une partie tout de suite",
|
||||
tutorialInMenu: "Le tutoriel reste dans le menu, à tout moment.",
|
||||
|
||||
// Accueil
|
||||
homeLink: "Accueil",
|
||||
homeTitle: "À vous de jouer",
|
||||
homeLead: "Reprenez votre partie contre l'ordinateur, ou revoyez les règles.",
|
||||
gameOverline: "Partie en cours",
|
||||
noGameOverline: "Pas de partie en cours",
|
||||
againstComputer: "Contre l'ordinateur",
|
||||
homePips: (player: number, computer: number) =>
|
||||
`Compte de points : vous ${player}, l'ordinateur ${computer}`,
|
||||
homeLoading: "Chargement…",
|
||||
resumeGame: "Reprendre la partie",
|
||||
rulesTitle: "Les règles du jeu",
|
||||
rulesDetail: "Le tutoriel, en huit étapes, plateau à l'appui",
|
||||
rulesStart: "Commencer",
|
||||
rulesContinue: (step: number) => `Reprendre à l'étape ${step}`,
|
||||
rulesAgain: "Revoir",
|
||||
progress: (played: number, won: number) =>
|
||||
`${plural(played, "partie jouée", "parties jouées")} · ${plural(won, "gagnée", "gagnées")}`,
|
||||
footer: "Né à l'Atelier du Huit, à Cugnaux. Logiciel libre, sans publicité ni mise.",
|
||||
|
||||
// Installation
|
||||
installTitle: "Installer Ustagammon",
|
||||
installCardText: "Sur l'écran d'accueil, en plein écran, sans réseau.",
|
||||
install: "Installer",
|
||||
installLater: "Plus tard",
|
||||
installMenu: "Installer l'application",
|
||||
installLead:
|
||||
"Une icône sur votre écran d'accueil, le jeu en plein écran, et tout marche sans réseau.",
|
||||
installSteps: {
|
||||
firefox: [
|
||||
"Touchez le menu du navigateur ⋮, en bas ou en haut de l'écran.",
|
||||
"Choisissez « Installer » ou « Ajouter à l'écran d'accueil ».",
|
||||
"Confirmez. Ensuite, ouvrez Ustagammon depuis son icône.",
|
||||
],
|
||||
ios: [
|
||||
"Touchez le bouton de partage, le carré d'où sort une flèche, en bas de Safari.",
|
||||
"Choisissez « Sur l'écran d'accueil ».",
|
||||
"Touchez « Ajouter ». Ensuite, ouvrez Ustagammon depuis son icône.",
|
||||
],
|
||||
} satisfies Record<InstallPlatform, readonly string[]>,
|
||||
installUnderstood: "J'ai compris",
|
||||
installed: "Ustagammon est installé : ouvrez-le depuis son icône.",
|
||||
|
||||
// Mise à jour
|
||||
updateAvailable: "Nouvelle version prête.",
|
||||
updateApply: "Mettre à jour",
|
||||
updateCheck: "Rechercher une mise à jour",
|
||||
updateChecking: "Recherche d'une mise à jour…",
|
||||
updateLatest: "Vous avez la dernière version.",
|
||||
updateUnavailable:
|
||||
"Impossible de chercher une mise à jour : pas de réseau, ou navigateur qui ne le permet pas.",
|
||||
|
||||
// Tutoriel
|
||||
tutorialMenu: "Les règles (tutoriel)",
|
||||
tutorialProgress: "Étapes du tutoriel",
|
||||
tutorialStepLink: (n: number, title: string) => `Étape ${n} : ${title}`,
|
||||
tutorialSkip: "Quitter",
|
||||
tutorialSkipLabel: "Quitter le tutoriel",
|
||||
tutorialOverline: (n: number, total: number, practice: boolean) =>
|
||||
`Les règles · ${n} sur ${total}${practice ? " · à vous" : ""}`,
|
||||
tutorialEndOverline: "Les règles · fin",
|
||||
tutorialEndTitle: "Vous connaissez les règles",
|
||||
back: "Retour",
|
||||
next: "Continuer",
|
||||
replay: "Revoir",
|
||||
replayLabel: "Revoir le coup",
|
||||
take: "Prendre",
|
||||
drop: "Passer",
|
||||
tutorialLoading: "Chargement du tutoriel…",
|
||||
tutorialFailed: "Le tutoriel n'a pas pu se charger.",
|
||||
pathLabel: "Le sens du jeu de vos pions, en pointillés",
|
||||
/** Le résultat d'une partie finie par le coup illustré (étape 7). */
|
||||
illustratedEnd: (kind: ResultKind) =>
|
||||
kind === "single"
|
||||
? "Partie finie : l'ordinateur avait sorti des pions, c'est une partie simple."
|
||||
: kind === "gammon"
|
||||
? "Partie finie : l'ordinateur n'a sorti aucun pion, c'est un gammon."
|
||||
: "Partie finie : il reste un pion de l'ordinateur dans votre camp, c'est un backgammon.",
|
||||
computerDoubles: "L'ordinateur double.",
|
||||
|
||||
// Aide
|
||||
helpTutorialTitle: "Nouveau au backgammon ?",
|
||||
helpTutorialText: "Le tutoriel des règles, en huit étapes, plateau à l'appui.",
|
||||
helpTutorialLink: "Apprendre les règles",
|
||||
helpIntro:
|
||||
"Une question en partie ? Elle a sûrement sa réponse ici. Touchez une question pour lire la réponse.",
|
||||
seeOnBoard: "Voir sur le plateau",
|
||||
glossarySearch: "Chercher un mot",
|
||||
glossaryNone: "Aucun mot ne correspond.",
|
||||
glossarySeeAlso: "Voir aussi :",
|
||||
aboutVersion: (commit: string) => `Version publiée : ${commit}.`,
|
||||
aboutVersionUnknown: "Version publiée : inconnue (lancée depuis les sources).",
|
||||
aboutParagraphs: [
|
||||
"Ustagammon est un logiciel libre, sous licence GNU GPL version 3 ou ultérieure : vous pouvez l'utiliser, l'étudier, le modifier et le partager.",
|
||||
"Les textes du tutoriel, de l'aide et du glossaire sont sous licence Creative Commons BY-SA 4.0.",
|
||||
"L'ordinateur joue et juge les coups avec GNU Backgammon (GPLv3), compilé pour le navigateur. Police Alegreya Sans, sous licence SIL Open Font License.",
|
||||
"Né à l'Atelier du Huit, café associatif à Cugnaux. Sans publicité, sans compte, sans mise : tout reste sur votre téléphone.",
|
||||
],
|
||||
} as const;
|
||||
@@ -0,0 +1,242 @@
|
||||
// L'accueil (#/accueil) : au premier lancement, le choix « Je découvre » /
|
||||
// « Je connais les règles » ; ensuite, la partie en cours, l'invitation à
|
||||
// installer, les règles et la progression résumée
|
||||
// (docs/identite/charte/accueil-tutoriel-aide.md, « Accueil »).
|
||||
//
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
//
|
||||
// Les leçons (1g) et la progression détaillée (1h) n'existent pas encore :
|
||||
// l'accueil n'affiche que ce qui existe.
|
||||
|
||||
import type { SideBoards } from "../domain/game/game.js";
|
||||
import { boardModel, OPENING_BOARDS } from "./board-model.js";
|
||||
import { NO_MARKS, renderBoard } from "./board-view.js";
|
||||
import { boardGeometry, PORTRAIT_ASPECT } from "./geometry.js";
|
||||
import { type HomeModel, WELCOME_LINKS } from "./home-model.js";
|
||||
import { HOME_TEXTS } from "./home-texts.js";
|
||||
import { icon } from "./icons.js";
|
||||
import { TUTORIAL_DONE } from "./settings.js";
|
||||
import { TEXTS } from "./texts.js";
|
||||
|
||||
export interface HomeViewOptions {
|
||||
readonly section: HTMLElement;
|
||||
/** « Nouvelle partie » (bouton : une action, pas un écran). */
|
||||
readonly newGame: () => void;
|
||||
readonly install: () => void;
|
||||
readonly dismissInstall: () => void;
|
||||
/** Un des deux choix du premier lancement a été suivi. */
|
||||
readonly welcomed: () => void;
|
||||
}
|
||||
|
||||
export interface HomeState {
|
||||
readonly model: HomeModel;
|
||||
/** Le plateau de la partie en cours ou de la dernière partie (null :
|
||||
* jamais joué). */
|
||||
readonly sides: SideBoards | null;
|
||||
/** La dernière étape du tutoriel vue. */
|
||||
readonly tutorialSeen: number;
|
||||
/** Parties jouées et gagnées (null : pas encore lues). */
|
||||
readonly progress: { readonly played: number; readonly won: number } | null;
|
||||
/** Le moteur se charge, ou l'écran est occupé. */
|
||||
readonly busy: boolean;
|
||||
}
|
||||
|
||||
export interface HomeView {
|
||||
render(state: HomeState): void;
|
||||
}
|
||||
|
||||
const SVG_NS = "http://www.w3.org/2000/svg";
|
||||
|
||||
function element<K extends keyof HTMLElementTagNameMap>(
|
||||
parent: HTMLElement,
|
||||
tag: K,
|
||||
options: { id?: string; className?: string; text?: string } = {},
|
||||
): HTMLElementTagNameMap[K] {
|
||||
const child = document.createElement(tag);
|
||||
if (options.id !== undefined) {
|
||||
child.id = options.id;
|
||||
}
|
||||
if (options.className !== undefined) {
|
||||
child.className = options.className;
|
||||
}
|
||||
if (options.text !== undefined) {
|
||||
child.textContent = options.text;
|
||||
}
|
||||
parent.append(child);
|
||||
return child;
|
||||
}
|
||||
|
||||
function boardSvg(parent: HTMLElement, id: string, className: string): SVGSVGElement {
|
||||
const svg = document.createElementNS(SVG_NS, "svg");
|
||||
svg.id = id;
|
||||
svg.setAttribute("class", `board ${className}`);
|
||||
svg.setAttribute("aria-hidden", "true");
|
||||
parent.append(svg);
|
||||
return svg;
|
||||
}
|
||||
|
||||
function logo(parent: HTMLElement): void {
|
||||
for (const [variant, file] of [
|
||||
["logo-light", "logo-horizontal-couleur.svg"],
|
||||
["logo-dark", "logo-horizontal-sombre.svg"],
|
||||
] as const) {
|
||||
const img = element(parent, "img", { className: variant });
|
||||
img.src = `icons/${file}`;
|
||||
img.alt = "Ustagammon";
|
||||
}
|
||||
}
|
||||
|
||||
function drawBoard(svg: SVGSVGElement, sides: SideBoards, width: number): void {
|
||||
renderBoard(svg, boardGeometry("portrait", PORTRAIT_ASPECT, width / 640), boardModel(sides), {
|
||||
...NO_MARKS,
|
||||
dice: [],
|
||||
cube: { value: 1, owner: null },
|
||||
band: null,
|
||||
controls: null,
|
||||
});
|
||||
}
|
||||
|
||||
/** Une grande carte-lien du premier lancement : le choix et son détail. */
|
||||
function choice(parent: HTMLElement, id: string, href: string, label: string, detail: string) {
|
||||
const link = element(parent, "a", { id, className: "choice" });
|
||||
link.href = href;
|
||||
element(link, "strong", { text: label });
|
||||
element(link, "span", { text: detail });
|
||||
return link;
|
||||
}
|
||||
|
||||
export function createHomeView(options: HomeViewOptions): HomeView {
|
||||
const { section } = options;
|
||||
section.replaceChildren();
|
||||
|
||||
// Premier lancement.
|
||||
const welcome = element(section, "div", { id: "home-welcome", className: "welcome" });
|
||||
const welcomeLogo = element(welcome, "h1", { className: "welcome-logo" });
|
||||
logo(welcomeLogo);
|
||||
const welcomeBoard = boardSvg(welcome, "welcome-board", "welcome-board");
|
||||
const welcomeText = element(welcome, "div", { className: "welcome-text" });
|
||||
element(welcomeText, "h2", { text: HOME_TEXTS.welcomeTitle });
|
||||
element(welcomeText, "p", { text: HOME_TEXTS.welcomeText });
|
||||
const choices = element(welcome, "div", { className: "welcome-choices" });
|
||||
const discover = choice(
|
||||
choices,
|
||||
"welcome-discover",
|
||||
WELCOME_LINKS.discover,
|
||||
HOME_TEXTS.discover,
|
||||
HOME_TEXTS.discoverDetail,
|
||||
);
|
||||
discover.classList.add("primary");
|
||||
const play = choice(
|
||||
choices,
|
||||
"welcome-play",
|
||||
WELCOME_LINKS.play,
|
||||
HOME_TEXTS.knowRules,
|
||||
HOME_TEXTS.knowRulesDetail,
|
||||
);
|
||||
element(choices, "p", { className: "caption", text: HOME_TEXTS.tutorialInMenu });
|
||||
for (const link of [discover, play]) {
|
||||
link.addEventListener("click", () => options.welcomed());
|
||||
}
|
||||
|
||||
// Ensuite.
|
||||
const main = element(section, "div", { id: "home-main", className: "home-main" });
|
||||
const intro = element(main, "div", { className: "home-intro" });
|
||||
element(intro, "h2", { text: HOME_TEXTS.homeTitle });
|
||||
element(intro, "p", { className: "muted", text: HOME_TEXTS.homeLead });
|
||||
|
||||
const card = element(main, "article", { id: "home-game", className: "card home-game" });
|
||||
const cardTop = element(card, "div", { className: "home-game-top" });
|
||||
const miniBox = element(cardTop, "div", { className: "home-mini" });
|
||||
const mini = boardSvg(miniBox, "home-board", "home-board");
|
||||
const facts = element(cardTop, "div", { className: "home-facts" });
|
||||
const gameOverline = element(facts, "span", { className: "overline" });
|
||||
element(facts, "strong", { text: HOME_TEXTS.againstComputer });
|
||||
const pips = element(facts, "span", { id: "home-pips", className: "home-pips" });
|
||||
const actions = element(card, "div", { className: "home-actions" });
|
||||
const resume = element(actions, "a", {
|
||||
id: "home-resume",
|
||||
className: "button",
|
||||
text: HOME_TEXTS.resumeGame,
|
||||
});
|
||||
resume.href = "#/";
|
||||
const fresh = element(actions, "button", { id: "home-new", text: TEXTS.newGame });
|
||||
fresh.type = "button";
|
||||
fresh.addEventListener("click", () => options.newGame());
|
||||
|
||||
const install = element(main, "aside", { id: "install-card", className: "install-card" });
|
||||
install.setAttribute("aria-label", HOME_TEXTS.installTitle);
|
||||
const symbol = element(install, "img", { className: "install-symbol" });
|
||||
symbol.src = "icons/symbole-couleur.svg";
|
||||
symbol.alt = "";
|
||||
const installText = element(install, "span", { className: "install-text" });
|
||||
element(installText, "strong", { text: HOME_TEXTS.installTitle });
|
||||
element(installText, "span", { className: "muted", text: HOME_TEXTS.installCardText });
|
||||
const installButton = element(install, "button", {
|
||||
id: "install-card-button",
|
||||
className: "secondary",
|
||||
text: HOME_TEXTS.install,
|
||||
});
|
||||
installButton.type = "button";
|
||||
installButton.addEventListener("click", () => options.install());
|
||||
const later = element(install, "button", { id: "install-later", className: "icon-button" });
|
||||
later.type = "button";
|
||||
later.append(icon("close"));
|
||||
later.setAttribute("aria-label", HOME_TEXTS.installLater);
|
||||
later.title = HOME_TEXTS.installLater;
|
||||
later.addEventListener("click", () => options.dismissInstall());
|
||||
|
||||
const rules = element(main, "a", { id: "home-rules", className: "card home-rules" });
|
||||
const rulesText = element(rules, "span", { className: "home-rules-text" });
|
||||
element(rulesText, "strong", { text: HOME_TEXTS.rulesTitle });
|
||||
element(rulesText, "span", { className: "muted", text: HOME_TEXTS.rulesDetail });
|
||||
const rulesAction = element(rules, "span", { className: "pill" });
|
||||
|
||||
const progress = element(main, "p", {
|
||||
id: "home-progress",
|
||||
className: "caption home-progress",
|
||||
});
|
||||
const footer = element(main, "footer", { className: "home-footer" });
|
||||
const footerSymbol = element(footer, "img");
|
||||
footerSymbol.src = "icons/symbole-couleur.svg";
|
||||
footerSymbol.alt = "";
|
||||
element(footer, "span", { text: HOME_TEXTS.footer });
|
||||
|
||||
return {
|
||||
render(state) {
|
||||
const { model } = state;
|
||||
welcome.hidden = !model.welcome;
|
||||
main.hidden = model.welcome;
|
||||
if (model.welcome) {
|
||||
drawBoard(welcomeBoard, OPENING_BOARDS, welcomeBoard.clientWidth || 360);
|
||||
return;
|
||||
}
|
||||
const sides = state.sides ?? OPENING_BOARDS;
|
||||
drawBoard(mini, sides, miniBox.clientWidth || 132);
|
||||
gameOverline.textContent = model.resume
|
||||
? HOME_TEXTS.gameOverline
|
||||
: HOME_TEXTS.noGameOverline;
|
||||
pips.textContent = HOME_TEXTS.homePips(sides.player.pipCount, sides.computer.pipCount);
|
||||
// Une seule action principale : « Reprendre la partie », ou
|
||||
// « Nouvelle partie » quand il n'y a rien à reprendre.
|
||||
resume.hidden = !model.resume;
|
||||
fresh.textContent = TEXTS.newGame;
|
||||
fresh.classList.toggle("secondary", model.resume);
|
||||
fresh.disabled = state.busy;
|
||||
install.hidden = !model.installCard;
|
||||
|
||||
const seen = state.tutorialSeen;
|
||||
rules.href = seen > 0 && seen < TUTORIAL_DONE ? `#/tutoriel/${seen}` : "#/tutoriel/1";
|
||||
rulesAction.textContent =
|
||||
seen === 0
|
||||
? HOME_TEXTS.rulesStart
|
||||
: seen >= TUTORIAL_DONE
|
||||
? HOME_TEXTS.rulesAgain
|
||||
: HOME_TEXTS.rulesContinue(seen);
|
||||
progress.textContent =
|
||||
state.progress === null || state.progress.played === 0
|
||||
? ""
|
||||
: HOME_TEXTS.progress(state.progress.played, state.progress.won);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -22,6 +22,9 @@ export const ICON_PATHS = {
|
||||
vibrate: "M9 5h6v14H9zM5 8v8M19 8v8M2 10v4M22 10v4",
|
||||
theme: "M12 21a9 9 0 1 0 0-18zM12 3a9 9 0 0 1 0 18",
|
||||
screenOn: "M7 3h10v18H7zM11 18h2",
|
||||
home: "M4 11l8-7 8 7M6 9.5V20h12V9.5M10 20v-6h4v6",
|
||||
rules: "M4 5h6a2 2 0 0 1 2 2v13a2 2 0 0 0-2-2H4zM20 5h-6a2 2 0 0 0-2 2v13a2 2 0 0 1 2-2h6z",
|
||||
install: "M12 4v11M7 10l5 5 5-5M5 20h14",
|
||||
verdictGood: "M12 21a9 9 0 1 0 0-18a9 9 0 0 0 0 18zM8 12.5l3 3 5-6",
|
||||
verdictDoubtful:
|
||||
"M12 21a9 9 0 1 0 0-18a9 9 0 0 0 0 18zM9.5 9.3a2.6 2.6 0 1 1 3.6 2.4c-.7.3-1.1.9-1.1 1.6v.6M12 17.2v.1",
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
// L'installation de l'application (docs/identite/charte/accueil-tutoriel-
|
||||
// aide.md, « Installation ») : l'invite du système quand le navigateur en
|
||||
// donne une (Chrome, événement beforeinstallprompt), sinon une marche à
|
||||
// suivre en trois étapes (Firefox, iPhone). Plus rien une fois
|
||||
// l'application installée.
|
||||
//
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
|
||||
/** La marche à suivre à montrer quand le navigateur n'a pas d'invite. */
|
||||
export type InstallPlatform = "ios" | "firefox";
|
||||
|
||||
/** D'après le navigateur : l'iPhone (et l'iPad) a sa propre marche à
|
||||
* suivre ; les autres suivent celle de Firefox (menu ⋮, « Installer »). */
|
||||
export function installPlatform(userAgent: string): InstallPlatform {
|
||||
return /iPhone|iPad|iPod/.test(userAgent) ? "ios" : "firefox";
|
||||
}
|
||||
|
||||
/** L'événement beforeinstallprompt de Chrome (absent des types du DOM). */
|
||||
interface InstallPromptEvent extends Event {
|
||||
readonly prompt: () => Promise<void>;
|
||||
readonly userChoice: Promise<{ outcome: "accepted" | "dismissed" }>;
|
||||
}
|
||||
|
||||
export interface Installer {
|
||||
/** L'application tourne installée, ou vient de l'être. */
|
||||
readonly installed: boolean;
|
||||
/** Le navigateur a donné son invite d'installation. */
|
||||
readonly canPrompt: boolean;
|
||||
/** Ouvre l'invite du système ; « unavailable » s'il n'y en a pas. */
|
||||
offer(): Promise<"accepted" | "dismissed" | "unavailable">;
|
||||
}
|
||||
|
||||
/**
|
||||
* Écoute le navigateur : garde l'invite reçue avec beforeinstallprompt (et
|
||||
* empêche le navigateur de l'afficher de lui-même), et note l'installation
|
||||
* (display-mode: standalone, événement appinstalled). onChange est appelé à
|
||||
* chaque changement.
|
||||
*/
|
||||
export function startInstaller(onChange: (installedNow: boolean) => void): Installer {
|
||||
const standalone = window.matchMedia("(display-mode: standalone)");
|
||||
let installedNow = false;
|
||||
let deferred: InstallPromptEvent | null = null;
|
||||
|
||||
window.addEventListener("beforeinstallprompt", (event) => {
|
||||
event.preventDefault();
|
||||
deferred = event as InstallPromptEvent;
|
||||
onChange(false);
|
||||
});
|
||||
window.addEventListener("appinstalled", () => {
|
||||
installedNow = true;
|
||||
deferred = null;
|
||||
onChange(true);
|
||||
});
|
||||
standalone.addEventListener("change", () => onChange(false));
|
||||
|
||||
return {
|
||||
get installed() {
|
||||
return installedNow || standalone.matches;
|
||||
},
|
||||
get canPrompt() {
|
||||
return deferred !== null;
|
||||
},
|
||||
async offer() {
|
||||
const event = deferred;
|
||||
if (event === null) {
|
||||
return "unavailable";
|
||||
}
|
||||
// Une invite ne sert qu'une fois.
|
||||
deferred = null;
|
||||
try {
|
||||
await event.prompt();
|
||||
const choice = await event.userChoice;
|
||||
return choice.outcome;
|
||||
} catch {
|
||||
return "unavailable";
|
||||
} finally {
|
||||
onChange(false);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
+38
-11
@@ -10,7 +10,7 @@
|
||||
export type Route =
|
||||
/** #/ : la partie en cours. */
|
||||
| { readonly screen: "game" }
|
||||
/** #/accueil (écran à venir, étape 0e.5). */
|
||||
/** #/accueil. */
|
||||
| { readonly screen: "home" }
|
||||
/** #/aide, et une question ouverte : #/aide/<identifiant>. */
|
||||
| { readonly screen: "help"; readonly question: string | null }
|
||||
@@ -18,8 +18,13 @@ export type Route =
|
||||
| { readonly screen: "glossary"; readonly term: string | null }
|
||||
/** #/a-propos. */
|
||||
| { readonly screen: "about" }
|
||||
/** #/tutoriel, et une étape : #/tutoriel/<n> (écran à venir). */
|
||||
| { readonly screen: "tutorial"; readonly step: number | null }
|
||||
/** #/tutoriel, une étape : #/tutoriel/<n>, et son écran « à vous » :
|
||||
* #/tutoriel/<n>/a-vous. */
|
||||
| { readonly screen: "tutorial"; readonly step: number | null; readonly practice: boolean }
|
||||
/** #/tutoriel/fin : la fin du tutoriel. */
|
||||
| { readonly screen: "tutorialEnd" }
|
||||
/** #/installer : la marche à suivre pour installer l'application. */
|
||||
| { readonly screen: "install" }
|
||||
/** #/parties, et une partie : #/parties/<identifiant>. */
|
||||
| { readonly screen: "games"; readonly game: string | null }
|
||||
/** #/menu. */
|
||||
@@ -58,6 +63,9 @@ export function parseRoute(hash: string): Route {
|
||||
if (head === "position") {
|
||||
return positionRoute(rest.join("/")) ?? unknown;
|
||||
}
|
||||
if (head === "tutoriel") {
|
||||
return tutorialRoute(rest) ?? unknown;
|
||||
}
|
||||
if (rest.length > 1) {
|
||||
return unknown;
|
||||
}
|
||||
@@ -80,18 +88,31 @@ export function parseRoute(hash: string): Route {
|
||||
return name === null ? { screen: "menu" } : unknown;
|
||||
case "parties":
|
||||
return { screen: "games", game: name };
|
||||
case "tutoriel": {
|
||||
if (name === null) {
|
||||
return { screen: "tutorial", step: null };
|
||||
}
|
||||
const step = Number(name);
|
||||
return /^[1-9][0-9]?$/.test(name) ? { screen: "tutorial", step } : unknown;
|
||||
}
|
||||
case "installer":
|
||||
return name === null ? { screen: "install" } : unknown;
|
||||
default:
|
||||
return unknown;
|
||||
}
|
||||
}
|
||||
|
||||
/** #/tutoriel, #/tutoriel/<n>, #/tutoriel/<n>/a-vous, #/tutoriel/fin. */
|
||||
function tutorialRoute(rest: readonly string[]): Route | null {
|
||||
const [name, part, ...more] = rest;
|
||||
if (name === undefined) {
|
||||
return { screen: "tutorial", step: null, practice: false };
|
||||
}
|
||||
if (more.length > 0) {
|
||||
return null;
|
||||
}
|
||||
if (name === "fin") {
|
||||
return part === undefined ? { screen: "tutorialEnd" } : null;
|
||||
}
|
||||
if (!/^[1-9][0-9]?$/.test(name) || (part !== undefined && part !== "a-vous")) {
|
||||
return null;
|
||||
}
|
||||
return { screen: "tutorial", step: Number(name), practice: part === "a-vous" };
|
||||
}
|
||||
|
||||
/**
|
||||
* #/position/... : le Position ID (14 caractères), puis, après une barre, le
|
||||
* Match ID. Chaque identifiant est encodé avec encodeURIComponent, mais une
|
||||
@@ -134,7 +155,13 @@ export function routeHash(route: Route): string {
|
||||
case "about":
|
||||
return "#/a-propos";
|
||||
case "tutorial":
|
||||
return route.step === null ? "#/tutoriel" : `#/tutoriel/${route.step}`;
|
||||
return route.step === null
|
||||
? "#/tutoriel"
|
||||
: `#/tutoriel/${route.step}${route.practice ? "/a-vous" : ""}`;
|
||||
case "tutorialEnd":
|
||||
return "#/tutoriel/fin";
|
||||
case "install":
|
||||
return "#/installer";
|
||||
case "games":
|
||||
return route.game === null ? "#/parties" : `#/parties/${route.game}`;
|
||||
case "menu":
|
||||
|
||||
+87
-4
@@ -3,6 +3,10 @@
|
||||
//
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
//
|
||||
// Format durable (ADR 0007, amendement du 2026-11-03) : un champ ajouté a
|
||||
// toujours une valeur par défaut, et un réglage enregistré par une version
|
||||
// précédente se relit sans perte.
|
||||
|
||||
export type Theme = "auto" | "light" | "dark";
|
||||
|
||||
@@ -15,6 +19,21 @@ export interface Settings {
|
||||
/** Parties commencées : la consigne est longue pendant les trois
|
||||
* premières, courte ensuite. */
|
||||
readonly gamesStarted: number;
|
||||
/** Parties terminées (l'invitation à installer vient après la
|
||||
* première). */
|
||||
readonly gamesFinished: number;
|
||||
/** Le choix du premier lancement (« Je découvre », « Je connais les
|
||||
* règles ») a été fait : il ne revient plus. */
|
||||
readonly welcomed: boolean;
|
||||
/** La dernière étape du tutoriel déjà vue (0 : aucune ; TUTORIAL_DONE :
|
||||
* la fin). La barre de progression ne mène qu'aux étapes vues. */
|
||||
readonly tutorialSeen: number;
|
||||
/** Combien de fois « Plus tard » a masqué l'invitation à installer, et
|
||||
* le nombre de parties terminées à la dernière fois. */
|
||||
readonly installDismissals: number;
|
||||
readonly installDismissedAt: number;
|
||||
/** L'application a été installée (événement appinstalled). */
|
||||
readonly installed: boolean;
|
||||
}
|
||||
|
||||
export const DEFAULT_SETTINGS: Settings = {
|
||||
@@ -22,11 +41,24 @@ export const DEFAULT_SETTINGS: Settings = {
|
||||
keepScreenOn: true,
|
||||
theme: "auto",
|
||||
gamesStarted: 0,
|
||||
gamesFinished: 0,
|
||||
welcomed: false,
|
||||
tutorialSeen: 0,
|
||||
installDismissals: 0,
|
||||
installDismissedAt: 0,
|
||||
installed: false,
|
||||
};
|
||||
|
||||
/** Nombre de parties pendant lesquelles la consigne garde sa forme longue. */
|
||||
export const LONG_PROMPT_GAMES = 3;
|
||||
|
||||
/** tutorialSeen quand la fin du tutoriel a été vue. */
|
||||
export const TUTORIAL_DONE = 99;
|
||||
|
||||
/** Parties terminées de plus avant que l'invitation à installer, masquée
|
||||
* par « Plus tard », revienne (une seule fois). */
|
||||
export const INSTALL_AGAIN_AFTER = 3;
|
||||
|
||||
/** Les réglages lus (JSON), complétés par les valeurs par défaut ; une
|
||||
* valeur illisible garde sa valeur par défaut. */
|
||||
export function parseSettings(text: string | null): Settings {
|
||||
@@ -39,16 +71,33 @@ export function parseSettings(text: string | null): Settings {
|
||||
} catch {
|
||||
// Réglages illisibles : valeurs par défaut.
|
||||
}
|
||||
const bool = (key: "vibrations" | "keepScreenOn") =>
|
||||
const bool = (key: "vibrations" | "keepScreenOn" | "welcomed" | "installed") =>
|
||||
typeof raw[key] === "boolean" ? (raw[key] as boolean) : DEFAULT_SETTINGS[key];
|
||||
const count = (
|
||||
key:
|
||||
| "gamesStarted"
|
||||
| "gamesFinished"
|
||||
| "tutorialSeen"
|
||||
| "installDismissals"
|
||||
| "installDismissedAt",
|
||||
) => {
|
||||
const value = raw[key];
|
||||
return typeof value === "number" && Number.isInteger(value) && value >= 0
|
||||
? value
|
||||
: DEFAULT_SETTINGS[key];
|
||||
};
|
||||
const theme = raw.theme === "light" || raw.theme === "dark" ? raw.theme : "auto";
|
||||
const games = raw.gamesStarted;
|
||||
return {
|
||||
vibrations: bool("vibrations"),
|
||||
keepScreenOn: bool("keepScreenOn"),
|
||||
theme,
|
||||
gamesStarted:
|
||||
typeof games === "number" && Number.isInteger(games) && games >= 0 ? games : 0,
|
||||
gamesStarted: count("gamesStarted"),
|
||||
gamesFinished: count("gamesFinished"),
|
||||
welcomed: bool("welcomed"),
|
||||
tutorialSeen: count("tutorialSeen"),
|
||||
installDismissals: count("installDismissals"),
|
||||
installDismissedAt: count("installDismissedAt"),
|
||||
installed: bool("installed"),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -61,3 +110,37 @@ export function nextTheme(theme: Theme): Theme {
|
||||
export function longPrompts(settings: Settings): boolean {
|
||||
return settings.gamesStarted <= LONG_PROMPT_GAMES;
|
||||
}
|
||||
|
||||
/**
|
||||
* Premier lancement : rien n'a encore été fait dans l'application (le choix
|
||||
* d'accueil, une partie terminée, une étape du tutoriel). La partie
|
||||
* commencée d'office au chargement compte pour une : jusqu'à une partie
|
||||
* commencée, c'est encore le premier lancement.
|
||||
*/
|
||||
export function isFirstLaunch(settings: Settings): boolean {
|
||||
return (
|
||||
!settings.welcomed &&
|
||||
settings.gamesStarted <= 1 &&
|
||||
settings.gamesFinished === 0 &&
|
||||
settings.tutorialSeen === 0
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* L'accueil montre-t-il l'invitation à installer ? Jamais une fois
|
||||
* l'application installée ; à partir de la première partie terminée ;
|
||||
* « Plus tard » la masque, et elle ne revient qu'une fois, après
|
||||
* INSTALL_AGAIN_AFTER parties terminées de plus.
|
||||
*/
|
||||
export function installCardShown(settings: Settings, installed: boolean): boolean {
|
||||
if (installed || settings.installed || settings.gamesFinished < 1) {
|
||||
return false;
|
||||
}
|
||||
if (settings.installDismissals === 0) {
|
||||
return true;
|
||||
}
|
||||
return (
|
||||
settings.installDismissals === 1 &&
|
||||
settings.gamesFinished >= settings.installDismissedAt + INSTALL_AGAIN_AFTER
|
||||
);
|
||||
}
|
||||
|
||||
@@ -164,7 +164,6 @@ export const TEXTS = {
|
||||
|
||||
// Adresses (ADR 0013)
|
||||
unknownAddress: "Cette adresse ne mène à aucun écran : voici votre partie.",
|
||||
screenNotYet: "Cet écran n'existe pas encore : voici votre partie.",
|
||||
|
||||
// Lien vers une position (ADR 0013)
|
||||
copyPositionLink: "Copier le lien de la position",
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
// Le parcours du tutoriel : l'écran d'avant, l'écran d'après, et la barre
|
||||
// de progression (charte : « les segments sont des liens vers les étapes
|
||||
// déjà vues, pas vers les suivantes »). Fonctions pures, testées sous Node.
|
||||
//
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
|
||||
import type { Route } from "./router.js";
|
||||
import { TUTORIAL_DONE } from "./settings.js";
|
||||
import type { Tutorial } from "./tutorial-text.js";
|
||||
|
||||
/** Un écran du tutoriel : une étape (explication ou « à vous »), ou la fin. */
|
||||
export type TutorialPlace = { readonly step: number; readonly practice: boolean } | "end";
|
||||
|
||||
/** L'écran que désigne une adresse, ramené à un écran qui existe : une
|
||||
* étape hors du tutoriel mène à la première, un « à vous » absent à
|
||||
* l'explication de son étape. */
|
||||
export function placeOf(tutorial: Tutorial, route: Route): TutorialPlace {
|
||||
if (route.screen === "tutorialEnd") {
|
||||
return "end";
|
||||
}
|
||||
if (route.screen !== "tutorial" || route.step === null) {
|
||||
return { step: 1, practice: false };
|
||||
}
|
||||
const step = tutorial.steps[route.step - 1];
|
||||
if (step === undefined) {
|
||||
return { step: 1, practice: false };
|
||||
}
|
||||
return { step: step.number, practice: route.practice && step.practice !== null };
|
||||
}
|
||||
|
||||
/** L'adresse d'un écran du tutoriel. */
|
||||
export function placeRoute(place: TutorialPlace): Route {
|
||||
return place === "end"
|
||||
? { screen: "tutorialEnd" }
|
||||
: { screen: "tutorial", step: place.step, practice: place.practice };
|
||||
}
|
||||
|
||||
/** L'écran suivant (la fin après la dernière étape). */
|
||||
export function nextPlace(tutorial: Tutorial, place: TutorialPlace): TutorialPlace {
|
||||
if (place === "end") {
|
||||
return "end";
|
||||
}
|
||||
const step = tutorial.steps[place.step - 1];
|
||||
if (!place.practice && step?.practice != null) {
|
||||
return { step: place.step, practice: true };
|
||||
}
|
||||
return place.step >= tutorial.steps.length ? "end" : { step: place.step + 1, practice: false };
|
||||
}
|
||||
|
||||
/** L'écran précédent (null avant la première étape). */
|
||||
export function previousPlace(tutorial: Tutorial, place: TutorialPlace): TutorialPlace | null {
|
||||
if (place === "end") {
|
||||
const last = tutorial.steps.at(-1);
|
||||
return last === undefined ? null : { step: last.number, practice: last.practice !== null };
|
||||
}
|
||||
if (place.practice) {
|
||||
return { step: place.step, practice: false };
|
||||
}
|
||||
const before = tutorial.steps[place.step - 2];
|
||||
return before === undefined
|
||||
? null
|
||||
: { step: before.number, practice: before.practice !== null };
|
||||
}
|
||||
|
||||
/** Ce que retient la progression quand on arrive sur un écran : la
|
||||
* dernière étape vue (TUTORIAL_DONE à la fin). */
|
||||
export function seenAfter(seen: number, place: TutorialPlace): number {
|
||||
return place === "end" ? TUTORIAL_DONE : Math.max(seen, place.step);
|
||||
}
|
||||
|
||||
/** Un segment de la barre de progression. */
|
||||
export interface ProgressSegment {
|
||||
readonly step: number;
|
||||
/** Étape faite ou en cours : le segment est en brique. */
|
||||
readonly done: boolean;
|
||||
/** Étape déjà vue : le segment est un lien vers elle. */
|
||||
readonly link: boolean;
|
||||
readonly current: boolean;
|
||||
}
|
||||
|
||||
/** La barre de progression, sur l'écran place, quand les étapes jusqu'à
|
||||
* seen ont déjà été vues. */
|
||||
export function progress(total: number, place: TutorialPlace, seen: number): ProgressSegment[] {
|
||||
const current = place === "end" ? total + 1 : place.step;
|
||||
const reached = Math.max(seen, current);
|
||||
return Array.from({ length: total }, (_, i) => {
|
||||
const step = i + 1;
|
||||
return {
|
||||
step,
|
||||
done: step <= current,
|
||||
link: step <= reached && step !== current,
|
||||
current: step === current,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
// Lecture du texte du tutoriel (docs/tutoriel/regles.md, format décrit dans
|
||||
// docs/tutoriel/README.md), copié tel quel dans le site par make site
|
||||
// (ADR 0008 : une seule source). Fonction pure, testée sous Node.
|
||||
//
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
|
||||
/** Un morceau de texte : simple, en gras, ou lien vers une fiche du
|
||||
* glossaire (nom de fichier sans .md). */
|
||||
export interface Inline {
|
||||
readonly text: string;
|
||||
readonly strong?: boolean;
|
||||
readonly term?: string;
|
||||
}
|
||||
|
||||
export type Paragraph = readonly Inline[];
|
||||
|
||||
/** Un écran du tutoriel : explication, ou « à vous ». */
|
||||
export interface TutorialScreen {
|
||||
/** Identifiant de position de gnubg, vu du camp au trait. */
|
||||
readonly position: string;
|
||||
readonly onRoll: "player" | "computer";
|
||||
/** Le jet du coup illustré (explication) ou à jouer (« à vous »). */
|
||||
readonly roll: readonly [number, number] | null;
|
||||
/** Explication : le coup illustré, en notation de gnubg. */
|
||||
readonly move: string | null;
|
||||
/** Explication : le sens du jeu dessiné en pointillés. */
|
||||
readonly path: boolean;
|
||||
/** « À vous » : les coups justes, en notation de gnubg. */
|
||||
readonly expected: readonly string[];
|
||||
/** « À vous » de videau : la bonne réponse au double. */
|
||||
readonly answer: "take" | "pass" | null;
|
||||
readonly right: Paragraph;
|
||||
readonly wrong: Paragraph;
|
||||
readonly paragraphs: readonly Paragraph[];
|
||||
}
|
||||
|
||||
export interface TutorialStep {
|
||||
readonly number: number;
|
||||
readonly title: string;
|
||||
readonly explain: TutorialScreen;
|
||||
readonly practice: TutorialScreen | null;
|
||||
}
|
||||
|
||||
export interface Tutorial {
|
||||
readonly steps: readonly TutorialStep[];
|
||||
readonly end: { readonly action: string; readonly paragraphs: readonly Paragraph[] };
|
||||
}
|
||||
|
||||
/** Lit les liens [mot](fiche.md) et le **gras** d'une ligne de texte. */
|
||||
export function parseInline(text: string): Paragraph {
|
||||
const parts: Inline[] = [];
|
||||
const pattern = /\[([^\]]+)\]\(([a-z0-9-]+)\.md\)|\*\*([^*]+)\*\*/g;
|
||||
let last = 0;
|
||||
for (const match of text.matchAll(pattern)) {
|
||||
const index = match.index ?? 0;
|
||||
if (index > last) {
|
||||
parts.push({ text: text.slice(last, index) });
|
||||
}
|
||||
if (match[1] !== undefined && match[2] !== undefined) {
|
||||
parts.push({ text: match[1], term: match[2] });
|
||||
} else {
|
||||
parts.push({ text: match[3] ?? "", strong: true });
|
||||
}
|
||||
last = index + match[0].length;
|
||||
}
|
||||
if (last < text.length) {
|
||||
parts.push({ text: text.slice(last) });
|
||||
}
|
||||
return parts;
|
||||
}
|
||||
|
||||
interface RawScreen {
|
||||
keys: Map<string, string[]>;
|
||||
paragraphs: string[];
|
||||
}
|
||||
|
||||
function emptyScreen(): RawScreen {
|
||||
return { keys: new Map(), paragraphs: [] };
|
||||
}
|
||||
|
||||
function rollOf(text: string | undefined, where: string): readonly [number, number] | null {
|
||||
if (text === undefined) {
|
||||
return null;
|
||||
}
|
||||
const match = /^([1-6])-([1-6])$/.exec(text);
|
||||
if (match === null) {
|
||||
throw new Error(`${where} : jet illisible « ${text} »`);
|
||||
}
|
||||
return [Number(match[1]), Number(match[2])];
|
||||
}
|
||||
|
||||
function screenOf(raw: RawScreen, where: string): TutorialScreen {
|
||||
const one = (key: string) => raw.keys.get(key)?.[0];
|
||||
const position = one("position");
|
||||
if (position === undefined) {
|
||||
throw new Error(`${where} : position absente`);
|
||||
}
|
||||
const trait = one("trait");
|
||||
if (trait !== undefined && trait !== "ordinateur" && trait !== "joueur") {
|
||||
throw new Error(`${where} : trait inconnu « ${trait} »`);
|
||||
}
|
||||
const answer = one("réponse");
|
||||
if (answer !== undefined && answer !== "prendre" && answer !== "passer") {
|
||||
throw new Error(`${where} : réponse inconnue « ${answer} »`);
|
||||
}
|
||||
return {
|
||||
position,
|
||||
onRoll: trait === "ordinateur" ? "computer" : "player",
|
||||
roll: rollOf(one("jet"), where),
|
||||
move: one("coup") ?? null,
|
||||
path: one("chemin") === "oui",
|
||||
expected: raw.keys.get("attendu") ?? [],
|
||||
answer: answer === undefined ? null : answer === "prendre" ? "take" : "pass",
|
||||
right: parseInline(one("juste") ?? ""),
|
||||
wrong: parseInline(one("refus") ?? ""),
|
||||
paragraphs: raw.paragraphs.map(parseInline),
|
||||
};
|
||||
}
|
||||
|
||||
/** Lit le texte du tutoriel. Erreur si un écran est incomplet ou si les
|
||||
* étapes ne se suivent pas. */
|
||||
export function parseTutorial(text: string): Tutorial {
|
||||
const steps: {
|
||||
number: number;
|
||||
title: string;
|
||||
explain: RawScreen;
|
||||
practice: RawScreen | null;
|
||||
}[] = [];
|
||||
let end: RawScreen | null = null;
|
||||
let current: RawScreen | null = null;
|
||||
let paragraph: string[] = [];
|
||||
|
||||
const endParagraph = () => {
|
||||
if (paragraph.length > 0 && current !== null) {
|
||||
current.paragraphs.push(paragraph.join(" "));
|
||||
}
|
||||
paragraph = [];
|
||||
};
|
||||
|
||||
for (const raw of text.split("\n")) {
|
||||
const line = raw.trim();
|
||||
const step = /^## (\d+)\. (.+)$/.exec(line);
|
||||
const key = /^- ([a-zéè]+): (.+)$/.exec(line);
|
||||
if (line === "") {
|
||||
endParagraph();
|
||||
} else if (step !== null) {
|
||||
endParagraph();
|
||||
current = emptyScreen();
|
||||
steps.push({
|
||||
number: Number(step[1]),
|
||||
title: step[2] ?? "",
|
||||
explain: current,
|
||||
practice: null,
|
||||
});
|
||||
} else if (line === "## Fin") {
|
||||
endParagraph();
|
||||
current = emptyScreen();
|
||||
end = current;
|
||||
} else if (line === "### À vous") {
|
||||
endParagraph();
|
||||
const last = steps.at(-1);
|
||||
if (last === undefined) {
|
||||
throw new Error("« À vous » hors d'une étape");
|
||||
}
|
||||
current = emptyScreen();
|
||||
last.practice = current;
|
||||
} else if (current === null) {
|
||||
// Notice avant la première étape : non affichée.
|
||||
} else if (key !== null && paragraph.length === 0 && current.paragraphs.length === 0) {
|
||||
const values = current.keys.get(key[1] ?? "") ?? [];
|
||||
values.push((key[2] ?? "").trim());
|
||||
current.keys.set(key[1] ?? "", values);
|
||||
} else {
|
||||
paragraph.push(line);
|
||||
}
|
||||
}
|
||||
endParagraph();
|
||||
|
||||
steps.forEach((step, i) => {
|
||||
if (step.number !== i + 1) {
|
||||
throw new Error(`étape ${step.number} à la place de l'étape ${i + 1}`);
|
||||
}
|
||||
});
|
||||
if (end === null) {
|
||||
throw new Error("écran de fin absent");
|
||||
}
|
||||
const finish: RawScreen = end;
|
||||
return {
|
||||
steps: steps.map((step) => ({
|
||||
number: step.number,
|
||||
title: step.title,
|
||||
explain: screenOf(step.explain, `étape ${step.number}`),
|
||||
practice:
|
||||
step.practice === null
|
||||
? null
|
||||
: screenOf(step.practice, `étape ${step.number}, à vous`),
|
||||
})),
|
||||
end: {
|
||||
action: finish.keys.get("action")?.[0] ?? "",
|
||||
paragraphs: finish.paragraphs.map(parseInline),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,734 @@
|
||||
// L'écran du tutoriel (#/tutoriel/<n>, #/tutoriel/<n>/a-vous,
|
||||
// #/tutoriel/fin) : la barre de progression, le titre, le plateau, le texte,
|
||||
// et les écrans « à vous » où le joueur fait le geste sur le vrai plateau
|
||||
// (docs/identite/charte/accueil-tutoriel-aide.md, « Tutoriel des règles »).
|
||||
//
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
//
|
||||
// Le texte vient de docs/tutoriel/regles.md (copie du site) ; les plateaux,
|
||||
// les coups illustrés et le jugement des gestes viennent du domaine, branché
|
||||
// sur le moteur (TutorialAccess, assemblé dans main.ts). Aucune règle n'est
|
||||
// écrite ici.
|
||||
|
||||
import type { MoveSteps } from "../domain/game/game.js";
|
||||
import type { HalfMove } from "../domain/game/position.js";
|
||||
import type { Roll } from "../domain/game/roll.js";
|
||||
import type { Side } from "../domain/game/side.js";
|
||||
import type { PracticeVerdict, TutorialBoard } from "../domain/learning/tutorial.js";
|
||||
import {
|
||||
boardModel,
|
||||
moveTargets,
|
||||
moveTrace,
|
||||
onlyHalfMove,
|
||||
type Place,
|
||||
placeOf as placeOfChecker,
|
||||
} from "./board-model.js";
|
||||
import {
|
||||
type BoardDecor,
|
||||
checkerSpot,
|
||||
NO_MARKS,
|
||||
renderBoard,
|
||||
type ShownDie,
|
||||
slideChecker,
|
||||
} from "./board-view.js";
|
||||
import { boardGeometry, columnLeft, type Geometry, PORTRAIT_ASPECT } from "./geometry.js";
|
||||
import { HOME_TEXTS } from "./home-texts.js";
|
||||
import { icon } from "./icons.js";
|
||||
import { type Route, routeHash } from "./router.js";
|
||||
import {
|
||||
nextPlace,
|
||||
placeOf,
|
||||
placeRoute,
|
||||
previousPlace,
|
||||
progress,
|
||||
type TutorialPlace,
|
||||
} from "./tutorial-nav.js";
|
||||
import {
|
||||
type Paragraph,
|
||||
parseTutorial,
|
||||
type Tutorial,
|
||||
type TutorialScreen,
|
||||
} from "./tutorial-text.js";
|
||||
|
||||
/** Le coup illustré se joue tout seul après une seconde. */
|
||||
const AUTOPLAY_MS = 1000;
|
||||
const SLIDE_MS = 160;
|
||||
const PAUSE_MS = 120;
|
||||
/** Un pion refusé reste un instant à sa place avant de revenir. */
|
||||
const REFUSED_MS = 450;
|
||||
|
||||
const SOURCE = "docs/tutoriel/regles.md";
|
||||
|
||||
/** Un écran « à vous » où l'on joue un coup (MovePractice du domaine). */
|
||||
export interface PracticeSession {
|
||||
board(): Promise<{
|
||||
player: TutorialBoard["sides"]["player"];
|
||||
computer: TutorialBoard["sides"]["computer"];
|
||||
}>;
|
||||
nextHalfMoves(): Promise<HalfMove[]>;
|
||||
play(halfMove: HalfMove): Promise<PracticeVerdict>;
|
||||
reset(): void;
|
||||
readonly diceToPlay: readonly { value: number; used: boolean }[];
|
||||
}
|
||||
|
||||
/** Ce que le tutoriel demande au domaine et au moteur. */
|
||||
export interface TutorialAccess {
|
||||
board(position: string, onRoll: Side): Promise<TutorialBoard>;
|
||||
illustrate(
|
||||
position: string,
|
||||
roll: Roll,
|
||||
move: string,
|
||||
): Promise<{ steps: MoveSteps; after: TutorialBoard }>;
|
||||
practice(position: string, roll: Roll, expected: readonly string[]): Promise<PracticeSession>;
|
||||
shouldTake(position: string): Promise<boolean>;
|
||||
}
|
||||
|
||||
export interface TutorialViewOptions {
|
||||
readonly section: HTMLElement;
|
||||
readonly access: TutorialAccess;
|
||||
readonly engineReady: Promise<unknown>;
|
||||
/** La dernière étape vue, et son enregistrement à l'arrivée sur un
|
||||
* écran. */
|
||||
readonly seen: () => number;
|
||||
readonly visit: (place: TutorialPlace) => void;
|
||||
/** Un geste juste : la vibration « pion posé ». */
|
||||
readonly placed: () => void;
|
||||
readonly reducedMotion: MediaQueryList;
|
||||
/** L'écran est-il en paysage (plateau à gauche, texte à droite) ? */
|
||||
readonly landscape: () => boolean;
|
||||
/** L'adresse a désigné un écran qui n'existe pas : la remplacer. */
|
||||
readonly replace: (hash: string) => void;
|
||||
}
|
||||
|
||||
export interface TutorialView {
|
||||
show(route: Route): Promise<void>;
|
||||
/** La taille de l'écran a changé : redessiner le plateau. */
|
||||
relayout(): void;
|
||||
/** L'écran est quitté : arrêter le coup illustré. */
|
||||
hide(): void;
|
||||
}
|
||||
|
||||
function element<K extends keyof HTMLElementTagNameMap>(
|
||||
parent: HTMLElement,
|
||||
tag: K,
|
||||
options: { id?: string; className?: string; text?: string } = {},
|
||||
): HTMLElementTagNameMap[K] {
|
||||
const child = document.createElement(tag);
|
||||
if (options.id !== undefined) {
|
||||
child.id = options.id;
|
||||
}
|
||||
if (options.className !== undefined) {
|
||||
child.className = options.className;
|
||||
}
|
||||
if (options.text !== undefined) {
|
||||
child.textContent = options.text;
|
||||
}
|
||||
parent.append(child);
|
||||
return child;
|
||||
}
|
||||
|
||||
/** Un paragraphe du tutoriel : texte, gras, liens vers le glossaire. */
|
||||
export function fillParagraph(target: HTMLElement, paragraph: Paragraph): void {
|
||||
target.replaceChildren();
|
||||
for (const part of paragraph) {
|
||||
if (part.term !== undefined) {
|
||||
const link = document.createElement("a");
|
||||
link.href = routeHash({ screen: "glossary", term: part.term });
|
||||
link.textContent = part.text;
|
||||
target.append(link);
|
||||
} else if (part.strong === true) {
|
||||
const strong = document.createElement("strong");
|
||||
strong.textContent = part.text;
|
||||
target.append(strong);
|
||||
} else {
|
||||
target.append(part.text);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const SVG_NS = "http://www.w3.org/2000/svg";
|
||||
|
||||
/** Le sens du jeu des pions du joueur, en pointillés : du haut à droite
|
||||
* (flèche 24) vers la gauche, puis en bas jusqu'à la flèche 1 et la sortie.
|
||||
* Tracé sous les pions et sous les numéros des flèches, qu'il ne cache
|
||||
* jamais. pxPerUnit : pixels à l'écran par unité du dessin (le liseré
|
||||
* ivoire fait 8 px, les points 4 px, quelle que soit la taille). */
|
||||
function drawPath(svg: SVGSVGElement, g: Geometry, pxPerUnit: number): void {
|
||||
const center = (column: number) => columnLeft(g, column) + g.pointWidth / 2;
|
||||
const top = g.height * 0.3;
|
||||
const bottom = g.height * 0.7;
|
||||
const right = center(11);
|
||||
const left = center(0) - g.pointWidth * 0.25;
|
||||
const turn = g.pointWidth * 0.6;
|
||||
const end = g.trayLeft + g.tray * 0.25;
|
||||
const d =
|
||||
`M ${right} ${top} L ${left + turn} ${top} Q ${left} ${top} ${left} ${top + turn} ` +
|
||||
`L ${left} ${bottom - turn} Q ${left} ${bottom} ${left + turn} ${bottom} L ${end} ${bottom}`;
|
||||
const group = document.createElementNS(SVG_NS, "g");
|
||||
group.setAttribute("class", "tutorial-path");
|
||||
group.setAttribute("role", "img");
|
||||
group.setAttribute("aria-label", HOME_TEXTS.pathLabel);
|
||||
const px = (n: number) => n / pxPerUnit;
|
||||
const width = px(4);
|
||||
for (const [className, stroke] of [
|
||||
["path-edge", px(8)],
|
||||
["path-dots", width],
|
||||
] as const) {
|
||||
const path = document.createElementNS(SVG_NS, "path");
|
||||
path.setAttribute("class", className);
|
||||
path.setAttribute("d", d);
|
||||
path.setAttribute("stroke-width", String(stroke));
|
||||
if (className === "path-dots") {
|
||||
path.setAttribute("stroke-dasharray", `0.1 ${px(10)}`);
|
||||
}
|
||||
group.append(path);
|
||||
}
|
||||
const head = document.createElementNS(SVG_NS, "path");
|
||||
head.setAttribute("class", "path-head");
|
||||
const size = px(7);
|
||||
head.setAttribute(
|
||||
"d",
|
||||
`M ${end - size} ${bottom - size} L ${end} ${bottom} L ${end - size} ${bottom + size}`,
|
||||
);
|
||||
head.setAttribute("stroke-width", String(px(3)));
|
||||
group.append(head);
|
||||
// Sous les pions, et sous les numéros des flèches, remontés au-dessus
|
||||
// du tracé ; les zones à toucher restent devant tout.
|
||||
const pieces = svg.querySelector(":scope > g.pieces");
|
||||
svg.insertBefore(group, pieces);
|
||||
for (const number of svg.querySelectorAll(":scope > .point-number")) {
|
||||
svg.insertBefore(number, pieces);
|
||||
}
|
||||
}
|
||||
|
||||
/** Crée l'écran du tutoriel dans section (vide dans index.html). */
|
||||
export function createTutorialView(options: TutorialViewOptions): TutorialView {
|
||||
const { section, access } = options;
|
||||
section.replaceChildren();
|
||||
const head = element(section, "header", { className: "tutorial-head" });
|
||||
const bar = element(head, "nav", { id: "tutorial-progress", className: "tutorial-progress" });
|
||||
bar.setAttribute("aria-label", HOME_TEXTS.tutorialProgress);
|
||||
const skip = element(head, "a", { id: "tutorial-skip", className: "tutorial-skip" });
|
||||
skip.href = "#/";
|
||||
skip.textContent = HOME_TEXTS.tutorialSkip;
|
||||
skip.setAttribute("aria-label", HOME_TEXTS.tutorialSkipLabel);
|
||||
const overline = element(section, "p", { id: "tutorial-overline", className: "overline" });
|
||||
const title = element(section, "h2", { id: "tutorial-title", className: "tutorial-title" });
|
||||
const boardBox = element(section, "div", { className: "tutorial-board-box" });
|
||||
const svg = document.createElementNS(SVG_NS, "svg");
|
||||
svg.id = "tutorial-board";
|
||||
svg.setAttribute("class", "board tutorial-board");
|
||||
svg.setAttribute("role", "img");
|
||||
boardBox.append(svg);
|
||||
const replay = element(boardBox, "button", { id: "tutorial-replay", className: "icon-button" });
|
||||
replay.type = "button";
|
||||
replay.append(icon("replay"));
|
||||
replay.setAttribute("aria-label", HOME_TEXTS.replayLabel);
|
||||
replay.title = HOME_TEXTS.replayLabel;
|
||||
// Le texte, la consigne « à vous » et la réponse : en paysage, la seule
|
||||
// partie qui défile si l'écran est trop bas (app.css).
|
||||
const body = element(section, "div", { className: "tutorial-body" });
|
||||
const text = element(body, "div", { id: "tutorial-text", className: "tutorial-text" });
|
||||
const task = element(body, "p", { id: "tutorial-task", className: "tutorial-task" });
|
||||
const feedback = element(body, "p", {
|
||||
id: "tutorial-feedback",
|
||||
className: "tutorial-feedback",
|
||||
});
|
||||
feedback.setAttribute("role", "status");
|
||||
const cube = element(section, "div", { id: "tutorial-cube", className: "tutorial-cube" });
|
||||
const drop = element(cube, "button", {
|
||||
id: "tutorial-drop",
|
||||
className: "secondary",
|
||||
text: HOME_TEXTS.drop,
|
||||
});
|
||||
drop.type = "button";
|
||||
const take = element(cube, "button", {
|
||||
id: "tutorial-take",
|
||||
className: "secondary",
|
||||
text: HOME_TEXTS.take,
|
||||
});
|
||||
take.type = "button";
|
||||
const nav = element(section, "div", { className: "tutorial-nav" });
|
||||
const back = element(nav, "a", {
|
||||
id: "tutorial-back",
|
||||
className: "button secondary",
|
||||
text: HOME_TEXTS.back,
|
||||
});
|
||||
const next = element(nav, "a", {
|
||||
id: "tutorial-next",
|
||||
className: "button",
|
||||
text: HOME_TEXTS.next,
|
||||
});
|
||||
|
||||
let tutorial: Promise<Tutorial> | null = null;
|
||||
// Chaque affichage a son numéro : un affichage dépassé ne touche plus
|
||||
// à l'écran (le joueur est déjà ailleurs).
|
||||
let shown = 0;
|
||||
let autoplay: ReturnType<typeof setTimeout> | null = null;
|
||||
// Un coup illustré se joue, ou un geste est jugé : les touchers
|
||||
// attendent (data-busy, pour les tests dans un navigateur).
|
||||
let busy = false;
|
||||
const setBusy = (value: boolean) => {
|
||||
busy = value;
|
||||
section.toggleAttribute("data-busy", value);
|
||||
};
|
||||
|
||||
// L'écran affiché.
|
||||
let screen: TutorialScreen | null = null;
|
||||
let place: TutorialPlace | null = null;
|
||||
let boards: TutorialBoard | null = null;
|
||||
let illustration: { steps: MoveSteps; after: TutorialBoard } | null = null;
|
||||
let illustrated = false;
|
||||
let practice: PracticeSession | null = null;
|
||||
let targets = new Map<Place, Map<Place, HalfMove>>();
|
||||
let selected: Place | null = null;
|
||||
let solved = false;
|
||||
|
||||
const load = () => {
|
||||
tutorial ??= fetch(SOURCE)
|
||||
.then((response) => {
|
||||
if (!response.ok) {
|
||||
throw new Error(`${SOURCE} : ${response.status}`);
|
||||
}
|
||||
return response.text();
|
||||
})
|
||||
.then(parseTutorial)
|
||||
.catch((error: unknown) => {
|
||||
tutorial = null;
|
||||
throw error;
|
||||
});
|
||||
return tutorial;
|
||||
};
|
||||
|
||||
/** En paysage, le plateau occupe toute la hauteur, à gauche de la
|
||||
* colonne du texte (app.css), avec la géométrie du jeu en paysage. */
|
||||
function geometry(): Geometry {
|
||||
const box = svg.getBoundingClientRect();
|
||||
if (options.landscape()) {
|
||||
const width = box.width || window.innerWidth;
|
||||
const height = box.height || window.innerHeight;
|
||||
return boardGeometry("landscape", width / height, height / 520);
|
||||
}
|
||||
const width = box.width || Math.min(window.innerWidth, 640);
|
||||
return boardGeometry("portrait", PORTRAIT_ASPECT, width / 640);
|
||||
}
|
||||
|
||||
function shownDice(): ShownDie[] {
|
||||
if (practice !== null) {
|
||||
return practice.diceToPlay.map((die) => ({
|
||||
value: die.value,
|
||||
side: "player",
|
||||
blocked: false,
|
||||
used: die.used,
|
||||
}));
|
||||
}
|
||||
const roll = screen?.move != null ? screen.roll : null;
|
||||
if (roll === null || roll === undefined) {
|
||||
return [];
|
||||
}
|
||||
const [a, b] = roll;
|
||||
return [a, b].map((value) => ({
|
||||
value,
|
||||
side: "player" as const,
|
||||
blocked: false,
|
||||
used: false,
|
||||
}));
|
||||
}
|
||||
|
||||
/** Redessine le plateau dans son état courant. */
|
||||
function draw(sides: TutorialBoard["sides"] | null = null): void {
|
||||
const current = sides ?? (illustrated ? illustration?.after.sides : null) ?? boards?.sides;
|
||||
if (current === undefined || current === null) {
|
||||
return;
|
||||
}
|
||||
const g = geometry();
|
||||
const decor: BoardDecor = {
|
||||
...NO_MARKS,
|
||||
dice: shownDice(),
|
||||
cube: { value: 1, owner: null },
|
||||
band: null,
|
||||
controls: null,
|
||||
movable: practice !== null && !solved && !busy ? new Set(targets.keys()) : new Set(),
|
||||
selected,
|
||||
targets: new Set(selected === null ? [] : (targets.get(selected)?.keys() ?? [])),
|
||||
trace:
|
||||
illustrated && illustration !== null
|
||||
? moveTrace("player", illustration.steps.halfMoves, illustration.steps.hits)
|
||||
: null,
|
||||
};
|
||||
renderBoard(svg, g, boardModel(current), decor);
|
||||
if (screen?.path === true) {
|
||||
drawPath(svg, g, (svg.getBoundingClientRect().width || g.width) / g.width);
|
||||
}
|
||||
}
|
||||
|
||||
function clearTimer(): void {
|
||||
if (autoplay !== null) {
|
||||
clearTimeout(autoplay);
|
||||
autoplay = null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Le coup illustré, pion par pion (160 ms par pion, 120 ms entre deux),
|
||||
* puis ses traces ; sans mouvement si le système le demande. */
|
||||
async function playIllustration(id: number): Promise<void> {
|
||||
const shownMove = illustration;
|
||||
if (shownMove === null || id !== shown) {
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
replay.disabled = true;
|
||||
illustrated = false;
|
||||
try {
|
||||
if (!options.reducedMotion.matches) {
|
||||
const { steps } = shownMove;
|
||||
for (const [i, halfMove] of steps.halfMoves.entries()) {
|
||||
if (id !== shown) {
|
||||
return;
|
||||
}
|
||||
if (i > 0) {
|
||||
await new Promise((resolve) => setTimeout(resolve, PAUSE_MS));
|
||||
}
|
||||
const from = placeOfChecker("player", halfMove.from);
|
||||
const to = placeOfChecker("player", halfMove.to);
|
||||
draw(steps.boards[i] ?? null);
|
||||
const start = checkerSpot(svg, from);
|
||||
const victim = steps.hits[i] === true ? checkerSpot(svg, to) : null;
|
||||
draw(steps.boards[i + 1] ?? null);
|
||||
await Promise.all([
|
||||
start && slideChecker(svg, to, start, SLIDE_MS),
|
||||
victim && slideChecker(svg, "bar-computer", victim, SLIDE_MS),
|
||||
]);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
if (id === shown) {
|
||||
setBusy(false);
|
||||
illustrated = true;
|
||||
replay.disabled = false;
|
||||
draw();
|
||||
const result = shownMove.after.result;
|
||||
feedback.textContent =
|
||||
result === null ? "" : HOME_TEXTS.illustratedEnd(result.kind);
|
||||
feedback.dataset.verdict = result === null ? "" : "info";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshTargets(): Promise<void> {
|
||||
targets =
|
||||
practice === null || solved ? new Map() : moveTargets(await practice.nextHalfMoves());
|
||||
if (selected !== null && !targets.has(selected)) {
|
||||
selected = null;
|
||||
}
|
||||
}
|
||||
|
||||
function setVerdict(verdict: "right" | "wrong", words: Paragraph): void {
|
||||
feedback.dataset.verdict = verdict;
|
||||
fillParagraph(feedback, words);
|
||||
if (verdict === "right") {
|
||||
feedback.prepend(icon("verdictGood"));
|
||||
}
|
||||
}
|
||||
|
||||
function markSolved(): void {
|
||||
solved = true;
|
||||
section.toggleAttribute("data-solved", true);
|
||||
next.classList.remove("secondary");
|
||||
take.disabled = true;
|
||||
drop.disabled = true;
|
||||
options.placed();
|
||||
}
|
||||
|
||||
async function playHalfMove(halfMove: HalfMove): Promise<void> {
|
||||
const session = practice;
|
||||
const current = screen;
|
||||
if (session === null || current === null || busy || solved) {
|
||||
return;
|
||||
}
|
||||
const id = shown;
|
||||
setBusy(true);
|
||||
selected = null;
|
||||
try {
|
||||
const verdict = await session.play(halfMove);
|
||||
if (id !== shown) {
|
||||
return;
|
||||
}
|
||||
draw(await session.board());
|
||||
if (verdict === "right") {
|
||||
markSolved();
|
||||
setVerdict("right", current.right);
|
||||
} else if (verdict === "wrong") {
|
||||
// Le pion revient, et une phrase dit pourquoi.
|
||||
await new Promise((resolve) => setTimeout(resolve, REFUSED_MS));
|
||||
if (id !== shown) {
|
||||
return;
|
||||
}
|
||||
session.reset();
|
||||
setVerdict("wrong", current.wrong);
|
||||
} else {
|
||||
feedback.textContent = "";
|
||||
feedback.dataset.verdict = "";
|
||||
}
|
||||
await refreshTargets();
|
||||
} finally {
|
||||
if (id === shown) {
|
||||
setBusy(false);
|
||||
draw(practice === null ? null : await practice.board());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
svg.addEventListener("click", (event) => {
|
||||
if (practice === null || busy || solved) {
|
||||
return;
|
||||
}
|
||||
const target = event.target as Element | null;
|
||||
const placeName = target
|
||||
?.closest("[data-place]")
|
||||
?.getAttribute("data-place") as Place | null;
|
||||
if (placeName == null) {
|
||||
return;
|
||||
}
|
||||
const halfMove = selected === null ? undefined : targets.get(selected)?.get(placeName);
|
||||
if (halfMove !== undefined) {
|
||||
void playHalfMove(halfMove);
|
||||
return;
|
||||
}
|
||||
const only = placeName === selected ? null : onlyHalfMove(targets, placeName);
|
||||
if (only !== null) {
|
||||
void playHalfMove(only);
|
||||
return;
|
||||
}
|
||||
selected = targets.has(placeName) && placeName !== selected ? placeName : null;
|
||||
void practice.board().then((sides) => draw(sides));
|
||||
});
|
||||
|
||||
replay.addEventListener("click", () => {
|
||||
clearTimer();
|
||||
void playIllustration(shown);
|
||||
});
|
||||
|
||||
async function answer(takes: boolean): Promise<void> {
|
||||
const current = screen;
|
||||
if (current === null || solved || busy) {
|
||||
return;
|
||||
}
|
||||
const id = shown;
|
||||
setBusy(true);
|
||||
try {
|
||||
const right = (await access.shouldTake(current.position)) === takes;
|
||||
if (id !== shown) {
|
||||
return;
|
||||
}
|
||||
if (right) {
|
||||
markSolved();
|
||||
}
|
||||
setVerdict(right ? "right" : "wrong", right ? current.right : current.wrong);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
take.addEventListener("click", () => void answer(true));
|
||||
drop.addEventListener("click", () => void answer(false));
|
||||
|
||||
function renderProgress(total: number, at: TutorialPlace, steps: Tutorial["steps"]): void {
|
||||
bar.replaceChildren();
|
||||
for (const segment of progress(total, at, options.seen())) {
|
||||
const stepTitle = steps[segment.step - 1]?.title ?? "";
|
||||
const label = HOME_TEXTS.tutorialStepLink(segment.step, stepTitle);
|
||||
const item = segment.link
|
||||
? document.createElement("a")
|
||||
: document.createElement("span");
|
||||
item.className = `segment${segment.done ? " done" : ""}`;
|
||||
if (item instanceof HTMLAnchorElement) {
|
||||
item.href = routeHash({ screen: "tutorial", step: segment.step, practice: false });
|
||||
item.setAttribute("aria-label", label);
|
||||
} else {
|
||||
item.setAttribute("aria-hidden", "true");
|
||||
}
|
||||
if (segment.current) {
|
||||
item.setAttribute("aria-current", "step");
|
||||
}
|
||||
bar.append(item);
|
||||
}
|
||||
bar.setAttribute(
|
||||
"aria-description",
|
||||
at === "end"
|
||||
? HOME_TEXTS.tutorialEndOverline
|
||||
: HOME_TEXTS.tutorialOverline(at.step, total, at.practice),
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
async show(route) {
|
||||
const id = ++shown;
|
||||
// Prêt (pour les tests dans un navigateur) une fois ce plateau dessiné.
|
||||
delete section.dataset.ready;
|
||||
clearTimer();
|
||||
setBusy(false);
|
||||
practice = null;
|
||||
illustration = null;
|
||||
illustrated = false;
|
||||
screen = null;
|
||||
boards = null;
|
||||
selected = null;
|
||||
targets = new Map();
|
||||
solved = false;
|
||||
section.toggleAttribute("data-solved", false);
|
||||
feedback.textContent = "";
|
||||
feedback.dataset.verdict = "";
|
||||
task.hidden = true;
|
||||
cube.hidden = true;
|
||||
replay.hidden = true;
|
||||
take.disabled = false;
|
||||
drop.disabled = false;
|
||||
let loaded: Tutorial;
|
||||
try {
|
||||
if (tutorial === null) {
|
||||
title.textContent = HOME_TEXTS.tutorialLoading;
|
||||
text.replaceChildren();
|
||||
svg.replaceChildren();
|
||||
}
|
||||
loaded = await load();
|
||||
} catch {
|
||||
if (id === shown) {
|
||||
title.textContent = HOME_TEXTS.tutorialFailed;
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (id !== shown) {
|
||||
return;
|
||||
}
|
||||
const at = placeOf(loaded, route);
|
||||
const wanted = routeHash(placeRoute(at));
|
||||
if (routeHash(route) !== wanted) {
|
||||
options.replace(wanted);
|
||||
return;
|
||||
}
|
||||
place = at;
|
||||
options.visit(at);
|
||||
const total = loaded.steps.length;
|
||||
renderProgress(total, at, loaded.steps);
|
||||
const before = previousPlace(loaded, at);
|
||||
back.href = before === null ? "#/accueil" : routeHash(placeRoute(before));
|
||||
if (at === "end") {
|
||||
overline.textContent = HOME_TEXTS.tutorialEndOverline;
|
||||
title.textContent = HOME_TEXTS.tutorialEndTitle;
|
||||
text.replaceChildren();
|
||||
for (const paragraph of loaded.end.paragraphs) {
|
||||
fillParagraph(element(text, "p"), paragraph);
|
||||
}
|
||||
next.textContent = loaded.end.action;
|
||||
next.href = "#/";
|
||||
next.classList.remove("secondary");
|
||||
screen = loaded.steps[0]?.explain ?? null;
|
||||
} else {
|
||||
const step = loaded.steps[at.step - 1];
|
||||
const current = at.practice ? step?.practice : step?.explain;
|
||||
if (step === undefined || current == null) {
|
||||
return;
|
||||
}
|
||||
screen = { ...current, path: current.path && !at.practice };
|
||||
overline.textContent = HOME_TEXTS.tutorialOverline(at.step, total, at.practice);
|
||||
title.textContent = step.title;
|
||||
text.replaceChildren();
|
||||
const paragraphs = at.practice ? [] : current.paragraphs;
|
||||
for (const paragraph of paragraphs) {
|
||||
fillParagraph(element(text, "p"), paragraph);
|
||||
}
|
||||
if (at.practice) {
|
||||
task.hidden = false;
|
||||
task.replaceChildren();
|
||||
for (const [i, paragraph] of current.paragraphs.entries()) {
|
||||
if (i > 0) {
|
||||
task.append(" ");
|
||||
}
|
||||
const span = document.createElement("span");
|
||||
fillParagraph(span, paragraph);
|
||||
task.append(span);
|
||||
}
|
||||
}
|
||||
const after = nextPlace(loaded, at);
|
||||
next.textContent = HOME_TEXTS.next;
|
||||
next.href = routeHash(placeRoute(after));
|
||||
next.classList.toggle("secondary", at.practice);
|
||||
}
|
||||
const currentScreen = screen;
|
||||
if (currentScreen === null) {
|
||||
return;
|
||||
}
|
||||
// Le plateau : la position, puis le coup illustré ou le geste à
|
||||
// faire, tous lus par le moteur.
|
||||
try {
|
||||
await options.engineReady;
|
||||
const shownBoard = await access.board(currentScreen.position, currentScreen.onRoll);
|
||||
if (id !== shown) {
|
||||
return;
|
||||
}
|
||||
boards = shownBoard;
|
||||
if (
|
||||
at !== "end" &&
|
||||
at.practice &&
|
||||
currentScreen.roll !== null &&
|
||||
currentScreen.expected.length > 0
|
||||
) {
|
||||
practice = await access.practice(
|
||||
currentScreen.position,
|
||||
currentScreen.roll,
|
||||
currentScreen.expected,
|
||||
);
|
||||
if (id !== shown) {
|
||||
return;
|
||||
}
|
||||
await refreshTargets();
|
||||
} else if (at !== "end" && at.practice && currentScreen.answer !== null) {
|
||||
cube.hidden = false;
|
||||
feedback.textContent = HOME_TEXTS.computerDoubles;
|
||||
feedback.dataset.verdict = "info";
|
||||
} else if (
|
||||
at !== "end" &&
|
||||
currentScreen.move !== null &&
|
||||
currentScreen.roll !== null
|
||||
) {
|
||||
illustration = await access.illustrate(
|
||||
currentScreen.position,
|
||||
currentScreen.roll,
|
||||
currentScreen.move,
|
||||
);
|
||||
if (id !== shown) {
|
||||
return;
|
||||
}
|
||||
replay.hidden = false;
|
||||
replay.disabled = true;
|
||||
autoplay = setTimeout(() => void playIllustration(id), AUTOPLAY_MS);
|
||||
}
|
||||
if (id !== shown) {
|
||||
return;
|
||||
}
|
||||
draw(practice === null ? null : await practice.board());
|
||||
section.dataset.ready = String(id);
|
||||
} catch (error) {
|
||||
if (id === shown) {
|
||||
feedback.textContent = `${HOME_TEXTS.tutorialFailed} ${error instanceof Error ? error.message : ""}`;
|
||||
}
|
||||
}
|
||||
},
|
||||
relayout() {
|
||||
if (place === null || busy) {
|
||||
return;
|
||||
}
|
||||
if (practice !== null) {
|
||||
void practice.board().then((sides) => draw(sides));
|
||||
} else {
|
||||
draw();
|
||||
}
|
||||
},
|
||||
hide() {
|
||||
shown++;
|
||||
clearTimer();
|
||||
setBusy(false);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
// La mise à jour de l'application : le service worker (web/sw.js) installe
|
||||
// chaque nouvelle version publiée dans son propre cache, puis attend
|
||||
// l'accord du joueur. L'application affiche alors, entre deux tours, le
|
||||
// bandeau « Nouvelle version prête. » en bas de l'écran ; « Mettre à jour »
|
||||
// active la nouvelle version et recharge la page, qui reprend la partie
|
||||
// enregistrée.
|
||||
//
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
|
||||
export interface Updates {
|
||||
/** Une nouvelle version est installée et attend l'accord du joueur. */
|
||||
readonly available: boolean;
|
||||
/** « Rechercher une mise à jour » : demande au navigateur de relire le
|
||||
* service worker publié. found : une nouvelle version attend (elle a pu
|
||||
* demander de télécharger tout le site) ; latest : rien de nouveau ;
|
||||
* unavailable : pas de service worker (http hors localhost, navigateur
|
||||
* qui le refuse) ou réseau absent. */
|
||||
check(): Promise<"found" | "latest" | "unavailable">;
|
||||
/** Active la nouvelle version : la page se recharge dès qu'elle a pris
|
||||
* la main. */
|
||||
apply(): void;
|
||||
}
|
||||
|
||||
/** Message au service worker en attente : prendre la main (sw.js). */
|
||||
export const SKIP_WAITING = "skip-waiting";
|
||||
|
||||
/**
|
||||
* Est-on entre deux tours ? Oui quand la partie attend un lancer de dés
|
||||
* (aucun pion déplacé) ou qu'elle est finie ; non dès que des dés sont à
|
||||
* jouer, qu'un double attend sa réponse, ou au jet d'ouverture. Fonction
|
||||
* pure, testée sous Node.
|
||||
*/
|
||||
export function betweenTurns(phase: string, pendingHalfMoves: number): boolean {
|
||||
return phase === "over" || (phase === "rolling" && pendingHalfMoves === 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Le bandeau « Nouvelle version prête. » est-il montré ? Quand une version
|
||||
* attend, seulement entre deux tours (jamais pendant un coup en cours, ni
|
||||
* quand l'ordinateur joue), et jamais par-dessus le bandeau « Reprendre
|
||||
* l'ancienne », qui occupe la même place. Fonction pure, testée sous Node.
|
||||
*/
|
||||
export function updateBannerShown(
|
||||
available: boolean,
|
||||
betweenTwoTurns: boolean,
|
||||
otherBanner: boolean,
|
||||
): boolean {
|
||||
return available && betweenTwoTurns && !otherBanner;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enregistre le service worker une fois ready résolue (le moteur chargé :
|
||||
* voir main.ts), et suit ses mises à jour. onAvailable est appelé quand une
|
||||
* nouvelle version attend.
|
||||
*/
|
||||
export function startUpdates(
|
||||
script: URL,
|
||||
ready: Promise<unknown>,
|
||||
onAvailable: () => void,
|
||||
): Updates {
|
||||
const container = "serviceWorker" in navigator ? navigator.serviceWorker : null;
|
||||
let registration: ServiceWorkerRegistration | null = null;
|
||||
let waiting: ServiceWorker | null = null;
|
||||
|
||||
/** Une version installée attend, et une version plus ancienne a la
|
||||
* main : c'est une mise à jour (à la toute première installation,
|
||||
* aucune page n'est encore contrôlée, et rien n'attend). */
|
||||
const notice = (worker: ServiceWorker | null) => {
|
||||
if (worker !== null && worker.state === "installed" && container?.controller) {
|
||||
waiting = worker;
|
||||
onAvailable();
|
||||
}
|
||||
};
|
||||
|
||||
/** Suit une version en cours d'installation ; résolue quand elle est
|
||||
* installée (vrai) ou abandonnée (faux). */
|
||||
const follow = (worker: ServiceWorker): Promise<boolean> =>
|
||||
new Promise((resolve) => {
|
||||
const settle = () => {
|
||||
if (worker.state === "installed" || worker.state === "activated") {
|
||||
notice(worker);
|
||||
resolve(true);
|
||||
} else if (worker.state === "redundant") {
|
||||
resolve(false);
|
||||
}
|
||||
};
|
||||
worker.addEventListener("statechange", settle);
|
||||
settle();
|
||||
});
|
||||
|
||||
if (container !== null) {
|
||||
ready
|
||||
.then(() => container.register(script))
|
||||
.then((reg) => {
|
||||
registration = reg;
|
||||
notice(reg.waiting);
|
||||
if (reg.installing !== null) {
|
||||
void follow(reg.installing);
|
||||
}
|
||||
reg.addEventListener("updatefound", () => {
|
||||
if (reg.installing !== null) {
|
||||
void follow(reg.installing);
|
||||
}
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
// Refusé (http hors localhost) : l'application marche sans.
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
get available() {
|
||||
return waiting !== null;
|
||||
},
|
||||
async check() {
|
||||
const reg = registration;
|
||||
if (reg === null) {
|
||||
return "unavailable";
|
||||
}
|
||||
try {
|
||||
await reg.update();
|
||||
} catch {
|
||||
return "unavailable";
|
||||
}
|
||||
if (reg.waiting !== null) {
|
||||
notice(reg.waiting);
|
||||
return "found";
|
||||
}
|
||||
if (reg.installing !== null) {
|
||||
return (await follow(reg.installing)) ? "found" : "latest";
|
||||
}
|
||||
return "latest";
|
||||
},
|
||||
apply() {
|
||||
const worker = waiting ?? registration?.waiting ?? null;
|
||||
if (container === null || worker === null) {
|
||||
return;
|
||||
}
|
||||
let reloading = false;
|
||||
container.addEventListener("controllerchange", () => {
|
||||
if (!reloading) {
|
||||
reloading = true;
|
||||
location.reload();
|
||||
}
|
||||
});
|
||||
worker.postMessage({ type: SKIP_WAITING });
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
// Tests de l'accueil et de l'invitation à installer, sans navigateur :
|
||||
// premier lancement, règles d'apparition de la carte d'installation,
|
||||
// réglages enregistrés par une version précédente.
|
||||
//
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { test } from "node:test";
|
||||
import { installPlatform } from "../src/ui/install.js";
|
||||
import {
|
||||
DEFAULT_SETTINGS,
|
||||
INSTALL_AGAIN_AFTER,
|
||||
installCardShown,
|
||||
isFirstLaunch,
|
||||
parseSettings,
|
||||
type Settings,
|
||||
TUTORIAL_DONE,
|
||||
} from "../src/ui/settings.js";
|
||||
import { nextPlace, placeOf, previousPlace, progress, seenAfter } from "../src/ui/tutorial-nav.js";
|
||||
import { parseTutorial } from "../src/ui/tutorial-text.js";
|
||||
|
||||
const root = new URL("../../../", import.meta.url);
|
||||
|
||||
test("réglages d'une version précédente : les nouveaux champs prennent leur valeur par défaut", () => {
|
||||
const old = parseSettings('{"vibrations": false, "theme": "dark", "gamesStarted": 4}');
|
||||
assert.equal(old.welcomed, false);
|
||||
assert.equal(old.gamesFinished, 0);
|
||||
assert.equal(old.tutorialSeen, 0);
|
||||
assert.equal(old.installed, false);
|
||||
const saved = parseSettings(
|
||||
'{"welcomed": true, "gamesFinished": 2, "tutorialSeen": 5, "installDismissals": 1, ' +
|
||||
'"installDismissedAt": 2, "installed": true}',
|
||||
);
|
||||
assert.deepEqual(saved, {
|
||||
...DEFAULT_SETTINGS,
|
||||
welcomed: true,
|
||||
gamesFinished: 2,
|
||||
tutorialSeen: 5,
|
||||
installDismissals: 1,
|
||||
installDismissedAt: 2,
|
||||
installed: true,
|
||||
});
|
||||
assert.equal(parseSettings('{"tutorialSeen": -3, "welcomed": "oui"}').tutorialSeen, 0);
|
||||
});
|
||||
|
||||
test("premier lancement : tant que rien n'a été fait, une seule fois", () => {
|
||||
assert.ok(isFirstLaunch(DEFAULT_SETTINGS));
|
||||
// La partie commencée d'office au chargement ne compte pas.
|
||||
assert.ok(isFirstLaunch({ ...DEFAULT_SETTINGS, gamesStarted: 1 }));
|
||||
assert.ok(!isFirstLaunch({ ...DEFAULT_SETTINGS, welcomed: true }));
|
||||
assert.ok(!isFirstLaunch({ ...DEFAULT_SETTINGS, gamesStarted: 2 }));
|
||||
assert.ok(!isFirstLaunch({ ...DEFAULT_SETTINGS, gamesFinished: 1 }));
|
||||
assert.ok(!isFirstLaunch({ ...DEFAULT_SETTINGS, tutorialSeen: 1 }));
|
||||
});
|
||||
|
||||
test("invitation à installer : après la première partie terminée, une seule relance", () => {
|
||||
const at = (changes: Partial<Settings>, installed = false) =>
|
||||
installCardShown({ ...DEFAULT_SETTINGS, ...changes }, installed);
|
||||
assert.ok(!at({}), "jamais avant la première partie terminée");
|
||||
assert.ok(at({ gamesFinished: 1 }));
|
||||
assert.ok(!at({ gamesFinished: 1 }, true), "jamais une fois installée");
|
||||
assert.ok(!at({ gamesFinished: 1, installed: true }));
|
||||
// « Plus tard » : masquée, puis de retour après trois parties de plus.
|
||||
const later = { installDismissals: 1, installDismissedAt: 1 };
|
||||
assert.equal(INSTALL_AGAIN_AFTER, 3);
|
||||
assert.ok(!at({ ...later, gamesFinished: 1 }));
|
||||
assert.ok(!at({ ...later, gamesFinished: 3 }));
|
||||
assert.ok(at({ ...later, gamesFinished: 4 }));
|
||||
// Une seconde fois « Plus tard » : plus jamais sur l'accueil.
|
||||
assert.ok(!at({ installDismissals: 2, installDismissedAt: 4, gamesFinished: 40 }));
|
||||
});
|
||||
|
||||
test("tutoriel : écran suivant, précédent, adresse ramenée à un écran qui existe", () => {
|
||||
const tutorial = parseTutorial(readFileSync(new URL("docs/tutoriel/regles.md", root), "utf8"));
|
||||
const explain = (step: number) => ({ step, practice: false });
|
||||
const practice = (step: number) => ({ step, practice: true });
|
||||
assert.deepEqual(nextPlace(tutorial, explain(2)), explain(3));
|
||||
assert.deepEqual(nextPlace(tutorial, explain(3)), practice(3));
|
||||
assert.deepEqual(nextPlace(tutorial, practice(3)), explain(4));
|
||||
assert.equal(nextPlace(tutorial, practice(8)), "end");
|
||||
assert.equal(previousPlace(tutorial, explain(1)), null);
|
||||
assert.deepEqual(previousPlace(tutorial, explain(4)), practice(3));
|
||||
assert.deepEqual(previousPlace(tutorial, explain(3)), explain(2));
|
||||
assert.deepEqual(previousPlace(tutorial, "end"), practice(8));
|
||||
assert.deepEqual(
|
||||
placeOf(tutorial, { screen: "tutorial", step: null, practice: false }),
|
||||
explain(1),
|
||||
);
|
||||
assert.deepEqual(
|
||||
placeOf(tutorial, { screen: "tutorial", step: 2, practice: true }),
|
||||
explain(2),
|
||||
);
|
||||
assert.deepEqual(
|
||||
placeOf(tutorial, { screen: "tutorial", step: 42, practice: false }),
|
||||
explain(1),
|
||||
);
|
||||
assert.equal(placeOf(tutorial, { screen: "tutorialEnd" }), "end");
|
||||
assert.equal(seenAfter(5, explain(3)), 5);
|
||||
assert.equal(seenAfter(0, "end"), TUTORIAL_DONE);
|
||||
// La barre : des liens vers les étapes vues seulement, pas l'actuelle.
|
||||
const bar = progress(8, explain(3), 5);
|
||||
assert.deepEqual(
|
||||
bar.filter((s) => s.link).map((s) => s.step),
|
||||
[1, 2, 4, 5],
|
||||
);
|
||||
assert.deepEqual(
|
||||
bar.filter((s) => s.done).map((s) => s.step),
|
||||
[1, 2, 3],
|
||||
);
|
||||
assert.deepEqual(
|
||||
bar.filter((s) => s.current).map((s) => s.step),
|
||||
[3],
|
||||
);
|
||||
});
|
||||
|
||||
test("la marche à suivre pour installer dépend du navigateur", () => {
|
||||
const iphone =
|
||||
"Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 " +
|
||||
"(KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1";
|
||||
const ipad = "Mozilla/5.0 (iPad; CPU OS 16_0 like Mac OS X) AppleWebKit/605.1.15";
|
||||
const firefox = "Mozilla/5.0 (Android 14; Mobile; rv:131.0) Gecko/131.0 Firefox/131.0";
|
||||
const chrome =
|
||||
"Mozilla/5.0 (Linux; Android 14) AppleWebKit/537.36 (KHTML, like Gecko) " +
|
||||
"Chrome/130.0 Mobile Safari/537.36";
|
||||
assert.equal(installPlatform(iphone), "ios");
|
||||
assert.equal(installPlatform(ipad), "ios");
|
||||
assert.equal(installPlatform(firefox), "firefox");
|
||||
// Sans invite du système, Chrome suit la marche à suivre de Firefox
|
||||
// (menu ⋮, « Installer »), la plus proche de la sienne.
|
||||
assert.equal(installPlatform(chrome), "firefox");
|
||||
});
|
||||
@@ -218,14 +218,18 @@ function sources(dir: string): string[] {
|
||||
}
|
||||
|
||||
test("aucune boîte native du navigateur (confirm, alert, prompt) dans l'application", () => {
|
||||
// Une méthode d'un autre objet ne compte pas : event.prompt() est l'invite
|
||||
// d'installation de Chrome (install.ts), pas une boîte de dialogue.
|
||||
for (const path of sources("app/src/")) {
|
||||
const text = read(path);
|
||||
assert.doesNotMatch(
|
||||
text,
|
||||
/\b(window\.)?(confirm|alert|prompt)\s*\(/,
|
||||
/(^|[^.\w])(window\.)?(confirm|alert|prompt)\s*\(/m,
|
||||
`${path} appelle une boîte native`,
|
||||
);
|
||||
}
|
||||
assert.match("window.alert(1)", /(^|[^.\w])(window\.)?(confirm|alert|prompt)\s*\(/m);
|
||||
assert.match("x = confirm('?')", /(^|[^.\w])(window\.)?(confirm|alert|prompt)\s*\(/m);
|
||||
});
|
||||
|
||||
test("les deux blocs du thème sombre de app.css sont identiques", () => {
|
||||
|
||||
@@ -28,8 +28,11 @@ const TABLE: [string, Route][] = [
|
||||
["#/glossaire", { screen: "glossary", term: null }],
|
||||
["#/glossaire/videau", { screen: "glossary", term: "videau" }],
|
||||
["#/a-propos", { screen: "about" }],
|
||||
["#/tutoriel", { screen: "tutorial", step: null }],
|
||||
["#/tutoriel/3", { screen: "tutorial", step: 3 }],
|
||||
["#/tutoriel", { screen: "tutorial", step: null, practice: false }],
|
||||
["#/tutoriel/3", { screen: "tutorial", step: 3, practice: false }],
|
||||
["#/tutoriel/3/a-vous", { screen: "tutorial", step: 3, practice: true }],
|
||||
["#/tutoriel/fin", { screen: "tutorialEnd" }],
|
||||
["#/installer", { screen: "install" }],
|
||||
["#/parties", { screen: "games", game: null }],
|
||||
["#/parties/mh2k3x-a1b2c3", { screen: "games", game: "mh2k3x-a1b2c3" }],
|
||||
["#/menu", { screen: "menu" }],
|
||||
@@ -63,6 +66,10 @@ test("une adresse inconnue est reconnue comme telle", () => {
|
||||
"#/menu/plus",
|
||||
"#/tutoriel/0",
|
||||
"#/tutoriel/x",
|
||||
"#/tutoriel/3/b",
|
||||
"#/tutoriel/3/a-vous/x",
|
||||
"#/tutoriel/fin/a-vous",
|
||||
"#/installer/x",
|
||||
"#/a-propos/x",
|
||||
"#/position",
|
||||
"#/position/%E0%A4%A",
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
// Étapes des scénarios docs/scenarios/decouvrir-l-application.feature et
|
||||
// installer-l-application.feature (ADR 0008) : l'accueil et l'invitation à
|
||||
// installer passent par les modules de l'interface qui les décident
|
||||
// (home-model.ts, settings.ts, install.ts), la partie par le domaine et le
|
||||
// vrai moteur, le tutoriel par son texte source.
|
||||
//
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { BeforeAll, Given, Then, When } from "@cucumber/cucumber";
|
||||
import type { Engine } from "../../src/domain/game/engine.js";
|
||||
import { Game } from "../../src/domain/game/game.js";
|
||||
import {
|
||||
dismissInstall,
|
||||
homeModel,
|
||||
installMenuShown,
|
||||
launchHash,
|
||||
WELCOME_LINKS,
|
||||
welcomed,
|
||||
} from "../../src/ui/home-model.js";
|
||||
import { HOME_TEXTS } from "../../src/ui/home-texts.js";
|
||||
import { installPlatform } from "../../src/ui/install.js";
|
||||
import { parseRoute } from "../../src/ui/router.js";
|
||||
import { parseSettings, type Settings } from "../../src/ui/settings.js";
|
||||
import { placeOf } from "../../src/ui/tutorial-nav.js";
|
||||
import { parseTutorial } from "../../src/ui/tutorial-text.js";
|
||||
import { loadNodeEngine } from "../support/node-engine.js";
|
||||
import { seededDice, step } from "../support/play.js";
|
||||
|
||||
const root = new URL("../../../../", import.meta.url);
|
||||
|
||||
let engine: Engine;
|
||||
let settings: Settings;
|
||||
let game: Game | null = null;
|
||||
let installed = false;
|
||||
let route = "";
|
||||
let installSteps: readonly string[] = [];
|
||||
|
||||
BeforeAll(async () => {
|
||||
engine = await loadNodeEngine();
|
||||
});
|
||||
|
||||
/** Ce que montre l'accueil, la partie étant celle du scénario. */
|
||||
function home() {
|
||||
const playing = game === null ? false : game.state.phase !== "over";
|
||||
return homeModel(settings, playing, installed);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------- Premier lancement
|
||||
|
||||
Given("vous ouvrez Ustagammon pour la toute première fois", () => {
|
||||
settings = parseSettings(null);
|
||||
game = null;
|
||||
installed = false;
|
||||
});
|
||||
|
||||
Then("l'accueil vous propose de découvrir les règles ou de jouer tout de suite", () => {
|
||||
assert.equal(launchHash(""), "#/accueil");
|
||||
assert.equal(parseRoute(launchHash("") ?? "").screen, "home");
|
||||
assert.ok(home().welcome);
|
||||
assert.equal(parseRoute(WELCOME_LINKS.play).screen, "game");
|
||||
});
|
||||
|
||||
When("vous choisissez de découvrir les règles", () => {
|
||||
settings = { ...settings, ...welcomed() };
|
||||
route = WELCOME_LINKS.discover;
|
||||
});
|
||||
|
||||
Then("le tutoriel s'ouvre à sa première étape", () => {
|
||||
const tutorial = parseTutorial(readFileSync(new URL("docs/tutoriel/regles.md", root), "utf8"));
|
||||
assert.deepEqual(placeOf(tutorial, parseRoute(route)), { step: 1, practice: false });
|
||||
});
|
||||
|
||||
Then("ce choix ne vous est plus proposé", () => {
|
||||
assert.ok(!home().welcome);
|
||||
});
|
||||
|
||||
Given("vous avez choisi de jouer tout de suite", () => {
|
||||
settings = { ...parseSettings(null), ...welcomed() };
|
||||
installed = false;
|
||||
});
|
||||
|
||||
Given("une partie est en cours", () => {
|
||||
game = Game.start(engine, { dice: seededDice(11), ply: 0 });
|
||||
settings = { ...settings, gamesStarted: settings.gamesStarted + 1 };
|
||||
});
|
||||
|
||||
When("vous rouvrez Ustagammon", () => {
|
||||
route = launchHash("") ?? "";
|
||||
});
|
||||
|
||||
Then("l'accueil vous propose de reprendre votre partie", () => {
|
||||
assert.equal(parseRoute(route).screen, "home");
|
||||
assert.ok(home().resume);
|
||||
});
|
||||
|
||||
When("la partie se termine", async () => {
|
||||
assert.ok(game !== null);
|
||||
for (let i = 0; game.state.phase !== "over"; i++) {
|
||||
assert.ok(i < 2000, "la partie ne finit pas");
|
||||
await step(game, engine);
|
||||
}
|
||||
});
|
||||
|
||||
Then("l'accueil vous propose une nouvelle partie", () => {
|
||||
assert.equal(parseRoute(route).screen, "home");
|
||||
assert.ok(!home().resume);
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------- Installation
|
||||
|
||||
Given("votre navigateur ne propose pas lui-même l'installation", () => {
|
||||
installSteps = [];
|
||||
});
|
||||
|
||||
When("vous demandez à installer l'application avec Firefox", () => {
|
||||
const firefox = "Mozilla/5.0 (Android 14; Mobile; rv:131.0) Gecko/131.0 Firefox/131.0";
|
||||
installSteps = HOME_TEXTS.installSteps[installPlatform(firefox)];
|
||||
});
|
||||
|
||||
When("vous demandez à installer l'application sur iPhone", () => {
|
||||
const iphone =
|
||||
"Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 " +
|
||||
"(KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1";
|
||||
installSteps = HOME_TEXTS.installSteps[installPlatform(iphone)];
|
||||
});
|
||||
|
||||
Then("la marche à suivre compte trois étapes, en commençant par le menu du navigateur", () => {
|
||||
assert.equal(installSteps.length, 3);
|
||||
assert.match(installSteps[0] ?? "", /menu du navigateur/);
|
||||
});
|
||||
|
||||
Then("la marche à suivre compte trois étapes, en commençant par le bouton de partage", () => {
|
||||
assert.equal(installSteps.length, 3);
|
||||
assert.match(installSteps[0] ?? "", /bouton de partage/);
|
||||
});
|
||||
|
||||
Then("l'accueil ne vous propose pas d'installer l'application", () => {
|
||||
assert.ok(!home().installCard);
|
||||
});
|
||||
|
||||
Then("l'accueil vous propose d'installer l'application", () => {
|
||||
assert.ok(home().installCard);
|
||||
});
|
||||
|
||||
When("vous terminez une partie", () => {
|
||||
settings = { ...settings, gamesFinished: settings.gamesFinished + 1 };
|
||||
});
|
||||
|
||||
When("vous terminez {int} parties de plus", (n: number) => {
|
||||
settings = { ...settings, gamesFinished: settings.gamesFinished + n };
|
||||
});
|
||||
|
||||
When("vous choisissez « Plus tard »", () => {
|
||||
settings = { ...settings, ...dismissInstall(settings) };
|
||||
});
|
||||
|
||||
Then("le menu vous le propose toujours", () => {
|
||||
assert.ok(installMenuShown(settings, installed));
|
||||
});
|
||||
|
||||
Given("vous avez terminé une partie", () => {
|
||||
settings = { ...parseSettings(null), welcomed: true, gamesFinished: 1 };
|
||||
installed = false;
|
||||
assert.ok(home().installCard);
|
||||
});
|
||||
|
||||
When("vous installez l'application", () => {
|
||||
// L'événement appinstalled du navigateur, retenu dans les réglages.
|
||||
installed = true;
|
||||
settings = { ...settings, installed: true };
|
||||
});
|
||||
|
||||
Then("le menu non plus", () => {
|
||||
assert.ok(!installMenuShown(settings, installed));
|
||||
// Même dans un onglet ouvert plus tard, hors de l'application installée.
|
||||
assert.ok(!installMenuShown(settings, false));
|
||||
assert.ok(!homeModel(settings, true, false).installCard);
|
||||
});
|
||||
@@ -0,0 +1,132 @@
|
||||
// Étapes du scénario docs/scenarios/apprendre-les-regles.feature (ADR 0008) :
|
||||
// le texte du tutoriel est lu dans sa source (docs/tutoriel/regles.md), et
|
||||
// chaque geste est jugé par le domaine branché sur le vrai moteur, comme
|
||||
// dans l'application.
|
||||
//
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { BeforeAll, Given, Then, When } from "@cucumber/cucumber";
|
||||
import type { Engine } from "../../src/domain/game/engine.js";
|
||||
import {
|
||||
MovePractice,
|
||||
type PracticeVerdict,
|
||||
shouldTake,
|
||||
tutorialBoard,
|
||||
} from "../../src/domain/learning/tutorial.js";
|
||||
import { progress, seenAfter, type TutorialPlace } from "../../src/ui/tutorial-nav.js";
|
||||
import { parseTutorial, type Tutorial, type TutorialScreen } from "../../src/ui/tutorial-text.js";
|
||||
import { loadNodeEngine } from "../support/node-engine.js";
|
||||
|
||||
const root = new URL("../../../../", import.meta.url);
|
||||
|
||||
let engine: Engine;
|
||||
let tutorial: Tutorial;
|
||||
let seen = 0;
|
||||
let place: TutorialPlace = { step: 1, practice: false };
|
||||
let screen: TutorialScreen;
|
||||
let practice: MovePractice | null = null;
|
||||
let verdict: PracticeVerdict = "continue";
|
||||
let answeredRight = false;
|
||||
|
||||
BeforeAll(async () => {
|
||||
engine = await loadNodeEngine();
|
||||
tutorial = parseTutorial(readFileSync(new URL("docs/tutoriel/regles.md", root), "utf8"));
|
||||
});
|
||||
|
||||
Given("vous commencez le tutoriel", () => {
|
||||
seen = 0;
|
||||
place = { step: 1, practice: false };
|
||||
seen = seenAfter(seen, place);
|
||||
});
|
||||
|
||||
Then("il compte huit étapes, dont quatre où c'est à vous de jouer", () => {
|
||||
assert.equal(tutorial.steps.length, 8);
|
||||
assert.equal(tutorial.steps.filter((s) => s.practice !== null).length, 4);
|
||||
});
|
||||
|
||||
Then("chaque position du tutoriel se lit sur le plateau", async () => {
|
||||
for (const step of tutorial.steps) {
|
||||
for (const current of [step.explain, step.practice]) {
|
||||
if (current !== null) {
|
||||
const board = await tutorialBoard(engine, current.position, current.onRoll);
|
||||
assert.equal(board.sides.player.points.length, 24);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
When("vous arrivez à l'étape {int}", (n: number) => {
|
||||
for (let step = 1; step <= n; step++) {
|
||||
place = { step, practice: false };
|
||||
seen = seenAfter(seen, place);
|
||||
}
|
||||
});
|
||||
|
||||
Then("la barre de progression vous ramène aux étapes 1 et 2, mais pas aux suivantes", () => {
|
||||
const links = progress(tutorial.steps.length, place, seen)
|
||||
.filter((segment) => segment.link)
|
||||
.map((segment) => segment.step);
|
||||
assert.deepEqual(links, [1, 2]);
|
||||
});
|
||||
|
||||
Given("vous êtes à l'écran « à vous » de l'étape {int}", async (n: number) => {
|
||||
const found = tutorial.steps[n - 1]?.practice;
|
||||
assert.ok(found != null, `l'étape ${n} n'a pas d'écran « à vous »`);
|
||||
screen = found;
|
||||
practice =
|
||||
screen.roll === null || screen.expected.length === 0
|
||||
? null
|
||||
: await MovePractice.start(engine, screen.position, screen.roll, screen.expected);
|
||||
verdict = "continue";
|
||||
answeredRight = false;
|
||||
});
|
||||
|
||||
When(
|
||||
"vous déplacez un pion de la flèche {int} à la flèche {int}",
|
||||
async (from: number, to: number) => {
|
||||
assert.ok(practice !== null);
|
||||
verdict = await practice.play({ from, to });
|
||||
if (verdict === "wrong") {
|
||||
practice.reset();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
Then("le pion revient, et une phrase vous dit pourquoi", () => {
|
||||
assert.equal(verdict, "wrong");
|
||||
assert.deepEqual(practice?.pendingHalfMoves, []);
|
||||
assert.ok(screen.wrong.map((part) => part.text).join("").length > 20);
|
||||
});
|
||||
|
||||
When("vous réunissez deux pions sur la flèche 5", async () => {
|
||||
assert.ok(practice !== null);
|
||||
verdict = await practice.play({ from: 8, to: 5 });
|
||||
assert.equal(verdict, "continue");
|
||||
verdict = await practice.play({ from: 6, to: 5 });
|
||||
});
|
||||
|
||||
Then("le tutoriel salue votre coup", () => {
|
||||
assert.equal(verdict, "right");
|
||||
assert.ok(screen.right.length > 0);
|
||||
});
|
||||
|
||||
When("vous répondez « Passer »", async () => {
|
||||
answeredRight = (await shouldTake(engine, screen.position)) === false;
|
||||
});
|
||||
|
||||
When("vous répondez « Prendre »", async () => {
|
||||
answeredRight = (await shouldTake(engine, screen.position)) === true;
|
||||
});
|
||||
|
||||
Then("le maître dit qu'il aurait pris", () => {
|
||||
assert.ok(!answeredRight);
|
||||
assert.match(screen.wrong.map((part) => part.text).join(""), /Le maître prendrait/);
|
||||
});
|
||||
|
||||
Then("le tutoriel salue votre réponse", () => {
|
||||
assert.ok(answeredRight);
|
||||
assert.match(screen.right.map((part) => part.text).join(""), /Le maître prend aussi/);
|
||||
});
|
||||
@@ -20,6 +20,7 @@ import { memoryStorage } from "../../src/storage/open-library.js";
|
||||
import { BANNER_MS } from "../../src/ui/app.js";
|
||||
import { createHaptics } from "../../src/ui/haptics.js";
|
||||
import { nextTheme, parseSettings, type Settings } from "../../src/ui/settings.js";
|
||||
import { betweenTurns, updateBannerShown } from "../../src/ui/updates.js";
|
||||
import { loadNodeEngine } from "../support/node-engine.js";
|
||||
|
||||
setDefaultTimeout(30_000);
|
||||
@@ -163,3 +164,47 @@ Then("il passe à clair, puis à sombre, puis revient à automatique", () => {
|
||||
}
|
||||
assert.deepEqual(seen, ["auto", "light", "dark", "auto"]);
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------ Mise à jour
|
||||
|
||||
// Le bandeau dépend de deux choses : une version attend (le service worker
|
||||
// l'a installée), et la partie est entre deux tours. La partie est celle du
|
||||
// domaine, sur le vrai moteur.
|
||||
let updateAvailable = false;
|
||||
|
||||
Given("une nouvelle version d'Ustagammon est disponible", () => {
|
||||
updateAvailable = true;
|
||||
});
|
||||
|
||||
const shownNow = (otherBanner = false) =>
|
||||
updateBannerShown(
|
||||
updateAvailable,
|
||||
betweenTurns(game.state.phase, game.pendingHalfMoves.length),
|
||||
otherBanner,
|
||||
);
|
||||
|
||||
Given("vous avez des dés à jouer", () => {
|
||||
// Jet d'ouverture 3-1 : vous commencez.
|
||||
game = Game.start(engine, { dice: fixedDice([3, 1, 6, 4, 5, 2]), ply: 0 });
|
||||
assert.equal(game.state.turn, "player");
|
||||
assert.equal(game.state.phase, "moving");
|
||||
});
|
||||
|
||||
When("vous déplacez un pion de votre coup", async () => {
|
||||
assert.ok(await game.playHalfMove({ from: 8, to: 5 }));
|
||||
});
|
||||
|
||||
Then("l'application ne vous propose pas encore de mettre à jour", () => {
|
||||
assert.ok(!shownNow());
|
||||
});
|
||||
|
||||
When("vous avez joué votre coup", async () => {
|
||||
assert.ok(await game.playHalfMove({ from: 6, to: 5 }));
|
||||
await game.confirmMove();
|
||||
});
|
||||
|
||||
Then("l'application vous propose de mettre à jour", () => {
|
||||
assert.ok(shownNow());
|
||||
// Jamais par-dessus le bandeau « Reprendre l'ancienne ».
|
||||
assert.ok(!shownNow(true));
|
||||
});
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
// Tests du tutoriel des règles : le texte (docs/tutoriel/regles.md) se lit,
|
||||
// chaque position est valide pour le vrai moteur, chaque coup illustré et
|
||||
// chaque coup attendu est légal, chaque écran « à vous » accepte ses coups
|
||||
// justes et refuse les autres, et la réponse au double est celle du moteur.
|
||||
//
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { test } from "node:test";
|
||||
import type { Move } from "../src/domain/game/engine.js";
|
||||
import {
|
||||
canStillLeadTo,
|
||||
illustratedMove,
|
||||
MovePractice,
|
||||
shouldTake,
|
||||
tutorialBoard,
|
||||
} from "../src/domain/learning/tutorial.js";
|
||||
import { parseInline, parseTutorial, type TutorialScreen } from "../src/ui/tutorial-text.js";
|
||||
import { loadNodeEngine } from "./support/node-engine.js";
|
||||
|
||||
const root = new URL("../../../", import.meta.url);
|
||||
const tutorial = parseTutorial(readFileSync(new URL("docs/tutoriel/regles.md", root), "utf8"));
|
||||
|
||||
test("le texte du tutoriel a huit étapes, une fin, et ses écrans « à vous »", () => {
|
||||
assert.equal(tutorial.steps.length, 8);
|
||||
assert.deepEqual(
|
||||
tutorial.steps.filter((s) => s.practice !== null).map((s) => s.number),
|
||||
[3, 4, 6, 8],
|
||||
);
|
||||
assert.ok(tutorial.end.action !== "");
|
||||
assert.ok(tutorial.end.paragraphs.length > 0);
|
||||
for (const step of tutorial.steps) {
|
||||
assert.ok(step.explain.paragraphs.length > 0, `étape ${step.number} sans texte`);
|
||||
const practice = step.practice;
|
||||
if (practice !== null) {
|
||||
assert.ok(practice.paragraphs.length > 0, `étape ${step.number} : consigne absente`);
|
||||
assert.ok(
|
||||
practice.right.length > 0 && practice.wrong.length > 0,
|
||||
`étape ${step.number} : phrases juste et refus absentes`,
|
||||
);
|
||||
assert.ok(
|
||||
practice.expected.length > 0 || practice.answer !== null,
|
||||
`étape ${step.number} : ni coup attendu, ni réponse au double`,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("chaque mot du glossaire cité par le tutoriel a sa fiche", () => {
|
||||
const screens = tutorial.steps.flatMap((s) => [s.explain, ...(s.practice ? [s.practice] : [])]);
|
||||
const inlines = [
|
||||
...screens.flatMap((s) => [...s.paragraphs.flat(), ...s.right, ...s.wrong]),
|
||||
...tutorial.end.paragraphs.flat(),
|
||||
];
|
||||
const terms = inlines.flatMap((i) => (i.term === undefined ? [] : [i.term]));
|
||||
assert.ok(terms.length >= 10, `seulement ${terms.length} liens vers le glossaire`);
|
||||
for (const term of terms) {
|
||||
assert.ok(
|
||||
existsSync(new URL(`docs/glossaire/${term}.md`, root)),
|
||||
`fiche ${term}.md absente`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("les liens et le gras du texte sont lus", () => {
|
||||
assert.deepEqual(parseInline("Un [blot](blot.md) **seul**, voilà."), [
|
||||
{ text: "Un " },
|
||||
{ text: "blot", term: "blot" },
|
||||
{ text: " " },
|
||||
{ text: "seul", strong: true },
|
||||
{ text: ", voilà." },
|
||||
]);
|
||||
});
|
||||
|
||||
test("un texte mal formé est refusé", () => {
|
||||
assert.throws(() =>
|
||||
parseTutorial("## 2. Sans la première\n\n- position: x\n\nTexte.\n\n## Fin\n"),
|
||||
);
|
||||
assert.throws(() => parseTutorial("## 1. Sans position\n\nTexte.\n\n## Fin\n"));
|
||||
assert.throws(() => parseTutorial("## 1. Jet faux\n\n- position: x\n- jet: 7-1\n\n## Fin\n"));
|
||||
assert.throws(() => parseTutorial("## 1. Sans fin\n\n- position: x\n\nTexte.\n"));
|
||||
});
|
||||
|
||||
/** Tous les écrans, avec leur nom pour les messages. */
|
||||
function screens(): [string, TutorialScreen][] {
|
||||
return tutorial.steps.flatMap((s) => [
|
||||
[`étape ${s.number}`, s.explain] as [string, TutorialScreen],
|
||||
...(s.practice === null
|
||||
? []
|
||||
: [[`étape ${s.number}, à vous`, s.practice] as [string, TutorialScreen]]),
|
||||
]);
|
||||
}
|
||||
|
||||
test("chaque position du tutoriel est valide pour le moteur", async () => {
|
||||
const engine = await loadNodeEngine();
|
||||
for (const [name, screen] of screens()) {
|
||||
const board = await tutorialBoard(engine, screen.position, screen.onRoll);
|
||||
const count = (side: "player" | "computer") => {
|
||||
const s = board.sides[side];
|
||||
return s.points.reduce((a, b) => a + b, 0) + s.bar + s.off;
|
||||
};
|
||||
assert.equal(count("player"), 15, `${name} : pions ivoire`);
|
||||
assert.equal(count("computer"), 15, `${name} : pions noyer`);
|
||||
}
|
||||
});
|
||||
|
||||
test("chaque coup illustré est légal, et la partie de l'étape 7 finit par un gammon", async () => {
|
||||
const engine = await loadNodeEngine();
|
||||
for (const [name, screen] of screens()) {
|
||||
if (screen.move === null) {
|
||||
continue;
|
||||
}
|
||||
assert.ok(screen.roll !== null, `${name} : coup sans jet`);
|
||||
const { steps, after } = await illustratedMove(
|
||||
engine,
|
||||
screen.position,
|
||||
screen.roll,
|
||||
screen.move,
|
||||
);
|
||||
assert.equal(steps.boards.length, steps.halfMoves.length + 1, name);
|
||||
if (name === "étape 7") {
|
||||
assert.deepEqual(after.result, { winner: "player", kind: "gammon" });
|
||||
} else {
|
||||
assert.equal(after.result, null, name);
|
||||
}
|
||||
if (name === "étape 4") {
|
||||
assert.ok(steps.hits.some(Boolean), "étape 4 : le coup illustré ne frappe pas");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("chaque écran « à vous » accepte ses coups attendus et refuse les autres", async () => {
|
||||
const engine = await loadNodeEngine();
|
||||
for (const [name, screen] of screens()) {
|
||||
if (screen.expected.length === 0) {
|
||||
continue;
|
||||
}
|
||||
assert.ok(screen.roll !== null, `${name} : pas de jet`);
|
||||
const legal = await engine.legalMoves(screen.position, screen.roll);
|
||||
for (const move of legal) {
|
||||
const practice = await MovePractice.start(
|
||||
engine,
|
||||
screen.position,
|
||||
screen.roll,
|
||||
screen.expected,
|
||||
);
|
||||
let verdict = "continue";
|
||||
for (const halfMove of move.halfMoves) {
|
||||
verdict = await practice.play(halfMove);
|
||||
if (verdict !== "continue") {
|
||||
break;
|
||||
}
|
||||
}
|
||||
const right = screen.expected.includes(move.notation);
|
||||
assert.equal(verdict, right ? "right" : "wrong", `${name} : ${move.notation}`);
|
||||
}
|
||||
// Un coup attendu au moins, et un coup refusé au moins.
|
||||
assert.ok(
|
||||
legal.some((m) => !screen.expected.includes(m.notation)),
|
||||
`${name} : rien à refuser`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("le joueur peut revenir en arrière après un coup refusé", async () => {
|
||||
const engine = await loadNodeEngine();
|
||||
const practice = await MovePractice.start(engine, "4HPwATDgc/ABMA", [3, 1], ["8/5 6/5"]);
|
||||
assert.equal(await practice.play({ from: 24, to: 21 }), "wrong");
|
||||
practice.reset();
|
||||
assert.deepEqual(practice.pendingHalfMoves, []);
|
||||
assert.equal(await practice.play({ from: 8, to: 5 }), "continue");
|
||||
assert.equal(await practice.play({ from: 6, to: 5 }), "right");
|
||||
});
|
||||
|
||||
test("la réponse au double de l'étape 8 est celle du moteur", async () => {
|
||||
const engine = await loadNodeEngine();
|
||||
for (const [name, screen] of screens()) {
|
||||
if (screen.answer === null) {
|
||||
continue;
|
||||
}
|
||||
assert.equal(screen.onRoll, "computer", `${name} : l'ordinateur doit avoir la main`);
|
||||
assert.equal(await shouldTake(engine, screen.position), screen.answer === "take", name);
|
||||
}
|
||||
});
|
||||
|
||||
test("des demi-coups peuvent encore mener à un coup attendu, ou plus", () => {
|
||||
const move = (notation: string, halfMoves: [number, number][]): Move => ({
|
||||
notation,
|
||||
halfMoves: halfMoves.map(([from, to]) => ({ from, to })),
|
||||
position: notation,
|
||||
});
|
||||
const expected = [
|
||||
move("8/5 6/5", [
|
||||
[8, 5],
|
||||
[6, 5],
|
||||
]),
|
||||
];
|
||||
assert.ok(canStillLeadTo([], expected));
|
||||
assert.ok(canStillLeadTo([{ from: 6, to: 5 }], expected));
|
||||
assert.ok(!canStillLeadTo([{ from: 8, to: 7 }], expected));
|
||||
assert.ok(
|
||||
!canStillLeadTo(
|
||||
[
|
||||
{ from: 8, to: 5 },
|
||||
{ from: 8, to: 5 },
|
||||
],
|
||||
expected,
|
||||
),
|
||||
);
|
||||
});
|
||||
+75
-8
@@ -19,7 +19,15 @@ import {
|
||||
pointColor,
|
||||
pointSlot,
|
||||
} from "../src/ui/board-model.js";
|
||||
import { parseFeature, parseGlossaryEntry, parseGlossaryIndex } from "../src/ui/help-text.js";
|
||||
import { FEATURES } from "../src/ui/help.js";
|
||||
import { HELP_QUESTIONS, helpHash } from "../src/ui/help-context.js";
|
||||
import {
|
||||
matchesSearch,
|
||||
parseFeature,
|
||||
parseGlossaryEntry,
|
||||
parseGlossaryIndex,
|
||||
} from "../src/ui/help-text.js";
|
||||
import { slug } from "../src/ui/router.js";
|
||||
import { TEXTS } from "../src/ui/texts.js";
|
||||
|
||||
// Compilé dans build/web/tests/ : la racine du dépôt est trois dossiers plus haut.
|
||||
@@ -143,21 +151,77 @@ test("l'aide lit le scénario tel qu'il est écrit dans docs/scenarios/", () =>
|
||||
const feature = parseFeature(read("docs/scenarios/jouer-une-partie.feature"));
|
||||
assert.equal(feature.title, "Jouer une partie contre l'ordinateur");
|
||||
assert.match(feature.description[0] ?? "", /^Vous jouez une partie complète/);
|
||||
assert.equal(feature.scenarios[0]?.title, "Commencer une partie");
|
||||
assert.equal(feature.scenarios[0]?.title, "Comment commence une partie ?");
|
||||
assert.equal(feature.scenarios[0]?.position, "4HPwATDgc/ABMA");
|
||||
assert.deepEqual(feature.scenarios[0]?.steps, [
|
||||
"Quand vous commencez une nouvelle partie",
|
||||
"Alors les pions sont en position de départ",
|
||||
"Et le premier jet de dés désigne qui commence",
|
||||
]);
|
||||
const refused = feature.scenarios.find((s) => s.title === "Un coup interdit est refusé");
|
||||
assert.match(refused?.description[0] ?? "", /^La case 12 est tenue/);
|
||||
const refused = feature.scenarios.find(
|
||||
(s) => s.title === "Pourquoi je ne peux pas poser mon pion sur cette flèche ?",
|
||||
);
|
||||
assert.match(
|
||||
refused?.description[0] ?? "",
|
||||
/^Une flèche où l'ordinateur a au moins deux pions/,
|
||||
);
|
||||
assert.equal(refused?.steps[0], "Étant donné que c'est à vous de jouer avec le jet 3-1");
|
||||
assert.equal(feature.scenarios[2]?.position, null);
|
||||
// Chaque scénario a des étapes : aucune n'a été prise pour du texte libre.
|
||||
for (const scenario of feature.scenarios) {
|
||||
assert.ok(scenario.steps.length >= 2, scenario.title);
|
||||
}
|
||||
});
|
||||
|
||||
test("aide : chaque scénario affiché est une question avec sa réponse", () => {
|
||||
const listed = read("docs/scenarios/README.md");
|
||||
const ids = new Set<string>();
|
||||
for (const url of FEATURES) {
|
||||
assert.ok(
|
||||
listed.includes(`](${url.replace("docs/scenarios/", "")})`),
|
||||
`${url} hors de l'index`,
|
||||
);
|
||||
const feature = parseFeature(read(url));
|
||||
assert.ok(feature.description.length > 0, `${url} : pas d'introduction`);
|
||||
for (const scenario of feature.scenarios) {
|
||||
// La réponse se lit seule : pas de vocabulaire technique.
|
||||
assert.ok(scenario.description.length > 0, `${scenario.title} : pas de réponse`);
|
||||
for (const word of ["scénario", "événement", "moteur", "Étant donné"]) {
|
||||
assert.ok(
|
||||
!scenario.description.join(" ").includes(word),
|
||||
`${scenario.title} : « ${word} » dans la réponse`,
|
||||
);
|
||||
}
|
||||
const id = slug(scenario.title);
|
||||
assert.ok(id !== "" && !ids.has(id), `${scenario.title} : identifiant ${id} en double`);
|
||||
ids.add(id);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("aide contextuelle : chaque question ouverte depuis la partie existe dans les scénarios", () => {
|
||||
const titles = FEATURES.flatMap((url) => parseFeature(read(url)).scenarios.map((s) => s.title));
|
||||
for (const title of Object.values(HELP_QUESTIONS)) {
|
||||
assert.ok(titles.includes(title), `« ${title} » n'est plus le titre d'un scénario`);
|
||||
}
|
||||
assert.equal(helpHash("stuck"), "#/aide/je-ne-peux-rien-jouer-avec-mes-des");
|
||||
assert.equal(helpHash(null), "#/aide");
|
||||
});
|
||||
|
||||
test("glossaire : nom anglais, renvois, recherche sans accents", () => {
|
||||
const entry = parseGlossaryEntry(read("docs/glossaire/camp.md"));
|
||||
assert.ok(entry !== null);
|
||||
assert.equal(entry.english, "Home board");
|
||||
assert.deepEqual(
|
||||
entry.seeAlso.map((s) => s.name),
|
||||
["sortie", "barre", "point"],
|
||||
);
|
||||
assert.ok(matchesSearch(entry, "camp"));
|
||||
assert.ok(matchesSearch(entry, "HOME"));
|
||||
assert.ok(matchesSearch(entry, "fleches sortir"));
|
||||
assert.ok(!matchesSearch(entry, "videau"));
|
||||
});
|
||||
|
||||
test("l'aide lit chaque fiche de l'index du glossaire", () => {
|
||||
const files = parseGlossaryIndex(read("docs/glossaire/README.md"));
|
||||
assert.ok(files.includes("videau.md"));
|
||||
@@ -167,10 +231,13 @@ test("l'aide lit chaque fiche de l'index du glossaire", () => {
|
||||
assert.ok(entry !== null, file);
|
||||
assert.ok(entry.brief.length > 10, file);
|
||||
}
|
||||
assert.deepEqual(parseGlossaryEntry(read("docs/glossaire/videau.md")), {
|
||||
term: "Videau",
|
||||
brief: "le dé à doubler, marqué 2, 4, 8, 16, 32 et 64 : il indique la valeur actuelle de la partie.",
|
||||
});
|
||||
const videau = parseGlossaryEntry(read("docs/glossaire/videau.md"));
|
||||
assert.equal(videau?.term, "Videau");
|
||||
assert.equal(videau?.english, "Cube");
|
||||
assert.equal(
|
||||
videau?.brief,
|
||||
"le dé à doubler, marqué 2, 4, 8, 16, 32 et 64 : il indique la valeur actuelle de la partie.",
|
||||
);
|
||||
assert.equal(parseGlossaryEntry("pas une fiche"), null);
|
||||
});
|
||||
|
||||
|
||||
@@ -85,3 +85,16 @@ unique pour tous les écrans, avant sa première mise en œuvre.
|
||||
- Une adresse inconnue mène à la partie en cours avec un message clair.
|
||||
- L'adresse de l'écran courant est la source de vérité de la navigation :
|
||||
l'application l'écoute (`hashchange`) et dessine l'écran correspondant.
|
||||
|
||||
### 2026-11-09 — Adresses du tutoriel et de l'installation
|
||||
|
||||
Ajoutées avec l'accueil et le tutoriel (étape 0e.5), dans le même schéma :
|
||||
|
||||
| Écran | Adresse |
|
||||
| --- | --- |
|
||||
| Écran « à vous » d'une étape du tutoriel | `#/tutoriel/<n>/a-vous` |
|
||||
| Fin du tutoriel | `#/tutoriel/fin` |
|
||||
| Marche à suivre pour installer l'application | `#/installer` |
|
||||
|
||||
Lancée sans adresse, l'application ouvre l'accueil (`#/accueil`) ; `#/` reste
|
||||
la partie en cours, pour que les liens existants ne changent pas.
|
||||
|
||||
@@ -23,7 +23,6 @@ Demandes de Pierre du 2026-11-03, traitées avant d'ouvrir l'étape 1.
|
||||
|
||||
| Sous-étape | Contenu | État | Plan |
|
||||
| --- | --- | --- | --- |
|
||||
| 0e.5 | Page d'accueil, mise à jour de l'app en un toucher, installation en un toucher (« Ajouter à l'écran d'accueil », proposée par l'app), tutoriel des règles très didactique avec plateaux illustrés, aide réécrite sur un ton plus humain ; textes à valider par Pierre | ⏳ | `plans/2026-11-03-accueil-tutoriel-aide.md` |
|
||||
|
||||
## Étapes suivantes
|
||||
|
||||
|
||||
@@ -40,6 +40,7 @@ exportée. `make test` vérifie le lien dans les deux sens : chaque fiche avec
|
||||
| [Barre](barre.md) | Bar |
|
||||
| [Blot](blot.md) | Blot |
|
||||
| [Branche](branche.md) | Branch |
|
||||
| [Camp](camp.md) | Home board |
|
||||
| [Chance](chance.md) | Luck |
|
||||
| [Compte de points](compte-de-points.md) | Pip count |
|
||||
| [Coup](coup.md) | Move |
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
---
|
||||
term: Camp
|
||||
english: Home board
|
||||
---
|
||||
# Camp
|
||||
|
||||
**En bref** : les six dernières flèches d'un joueur, avant sa sortie : il y rassemble ses pions pour les sortir, et un pion adverse frappé doit y rentrer.
|
||||
|
||||
Votre camp est en bas à droite (vos flèches 1 à 6) ; celui de l'ordinateur,
|
||||
en haut à droite (vos flèches 19 à 24), où rentrent vos pions frappés.
|
||||
|
||||
Voir aussi : [Sortie](sortie.md), [Barre](barre.md), [Point](point.md)
|
||||
@@ -1,76 +0,0 @@
|
||||
# Accueil, installation, tutoriel des règles, aide (0e.5)
|
||||
|
||||
- **Date** : 2026-11-03
|
||||
- **Étape** : 0e.5 (feuille de route), qui couvre aussi le tutoriel de 1g
|
||||
- **Statut** : prévu (après la fusion de 0e.4)
|
||||
- **Exécutant** : sous-agent general-purpose, dans un worktree
|
||||
|
||||
## Objectif
|
||||
|
||||
Traiter les points 2 et 3 du message de Pierre du 2026-11-03 et sa demande d'installation en un toucher :
|
||||
- une page d'accueil ;
|
||||
- l'installation proposée par l'app ;
|
||||
- un tutoriel des règles très didactique, illustré par le plateau ;
|
||||
- une aide plus humaine.
|
||||
|
||||
## Références
|
||||
|
||||
- Règles : `docs/identite/charte/accueil-tutoriel-aide.md`. Le texte du tutoriel y figure en première version, à faire valider par Pierre.
|
||||
- Maquettes : https://claude.ai/artifact/CQw6vbDpi6XQqVWQa4sANe, rangée « Étape 0e.5 » et artboard « Accueil ».
|
||||
- ADR 0008 (source unique), ADR 0013 (positions par identifiants gnubg).
|
||||
|
||||
## Consignes
|
||||
|
||||
1. **Accueil**, selon la charte :
|
||||
- au premier lancement, et une seule fois, le choix entre « Je découvre » et « Je connais les règles » ;
|
||||
- ensuite : la partie en cours, la carte d'installation, la prochaine leçon et la progression résumée. La leçon et la progression restent en attente tant que 1g et 1h n'existent pas : n'afficher que ce qui existe.
|
||||
2. **Installation** :
|
||||
- sous Chrome, garder l'invite reçue avec `beforeinstallprompt` ;
|
||||
- sous Firefox et sur iPhone, une feuille en 3 étapes ;
|
||||
- la carte n'apparaît qu'à partir de la première partie terminée. « Plus tard » la masque, et elle ne revient qu'une fois, après 3 parties. Le menu garde une ligne tant que l'app n'est pas installée ;
|
||||
- plus rien en `display-mode: standalone` ni après l'événement `appinstalled`.
|
||||
3. **Tutoriel**, en 8 étapes :
|
||||
- le texte vit dans un fichier source unique sous `docs/` (par exemple `docs/tutoriel/`), copié dans le site par `make site` comme le glossaire, sous licence CC BY-SA 4.0 ;
|
||||
- positions données par leurs identifiants gnubg ;
|
||||
- sur les écrans « à vous », le moteur vérifie le coup joué en le comparant aux coups attendus de l'étape ;
|
||||
- le coup illustré se joue seul après 1 s, avec « Revoir » ;
|
||||
- un test vérifie que chaque position du tutoriel est valide pour le moteur et que chaque coup attendu est légal ;
|
||||
- ajouter la fiche du glossaire « camp ».
|
||||
4. **Aide**, en trois onglets :
|
||||
- « Jouer » : les scénarios deviennent des questions repliables. Le titre est la question et la description la réponse ; les étapes Gherkin sont cachées. Reformuler les titres des `.feature` sans casser les tests ;
|
||||
- « Les mots du jeu » : les cartes du glossaire, avec une recherche ;
|
||||
- « À propos » : licence, crédits, version ;
|
||||
- le tutoriel est proposé en tête de l'aide.
|
||||
5. **Mise à jour de l'application** (demande de Pierre du 2026-11-09). Quand
|
||||
le service worker trouve une nouvelle version, l'app affiche un bandeau
|
||||
discret « Nouvelle version disponible » avec « Mettre à jour », jamais
|
||||
pendant un coup en cours. Toucher le bouton active la nouvelle version
|
||||
(`skipWaiting`, puis rechargement au `controllerchange`), et la partie
|
||||
reprend où elle en était. Le menu propose aussi « Rechercher une mise à
|
||||
jour » (`registration.update()`), qui dit « Vous avez la dernière
|
||||
version » sinon. L'onglet « À propos » affiche la version publiée (le
|
||||
commit de `version.json`). Aujourd'hui, `web/sw.js` appelle
|
||||
`skipWaiting()` dès l'installation et efface l'ancien cache : une page
|
||||
ouverte garde son ancien code et peut ne plus trouver les anciens
|
||||
fichiers. Le nouveau service worker doit attendre l'accord du joueur, sauf
|
||||
à la toute première installation.
|
||||
6. **Scénarios Gherkin** : premier lancement, installation, suivre le tutoriel.
|
||||
7. **`make test-browser`** :
|
||||
- premier lancement, tutoriel complet avec ses écrans « à vous », aide ;
|
||||
- mise à jour : publier deux versions du site de test l'une après
|
||||
l'autre, voir le bandeau, mettre à jour, retrouver la partie ;
|
||||
- `beforeinstallprompt` simulé dans Chromium ;
|
||||
- captures dans `build/browser-shots/accueil-tutoriel/` pour la recette design.
|
||||
|
||||
Chaque écran (accueil, étapes du tutoriel, onglets et questions de l'aide) a son adresse et s'ouvre par un vrai lien, selon l'amendement de l'ADR 0013 ; le routeur vient de 0e.4.
|
||||
|
||||
`make lint`, `make test` et `make test-browser` doivent passer. Aucune nouvelle dépendance.
|
||||
|
||||
## Critères de réussite
|
||||
|
||||
- Un débutant suit le tutoriel jusqu'au bout sur le téléphone, et chaque écran « à vous » accepte le bon coup et refuse les autres.
|
||||
- Les trois commandes passent, et les captures sont livrées pour la recette.
|
||||
|
||||
## Résultat
|
||||
|
||||
À remplir à la clôture.
|
||||
@@ -33,6 +33,15 @@ Fonctionnalité: <la tâche>
|
||||
Et <autre constat>
|
||||
```
|
||||
|
||||
Dans l'aide, chaque scénario devient une question repliable : son titre est
|
||||
la question, formulée comme le joueur se la pose (« Comment jouer un
|
||||
coup ? ») ; le paragraphe sous le titre est la réponse, qui doit se lire
|
||||
seule ; les étapes ne s'affichent pas. Une étiquette
|
||||
`@position:<Position ID>` au-dessus du scénario ajoute à la réponse le lien
|
||||
« Voir sur le plateau ». L'adresse d'une question (`#/aide/<identifiant>`)
|
||||
est tirée de son titre : changer un titre change son adresse, et les titres
|
||||
cités par `app/src/ui/help-context.ts` sont vérifiés par `make test`.
|
||||
|
||||
Règles d'écriture :
|
||||
|
||||
- la deuxième personne (« vous ») et les mots du glossaire (`docs/glossaire/`) ;
|
||||
@@ -49,4 +58,7 @@ Règles d'écriture :
|
||||
| [jouer-une-partie.feature](jouer-une-partie.feature) | Jouer une partie contre l'ordinateur | Étape 0d |
|
||||
| [mes-parties.feature](mes-parties.feature) | Retrouver mes parties, leurs branches, les exporter en SGF | Étape 1b |
|
||||
| [partager-une-position.feature](partager-une-position.feature) | Ouvrir et partager le lien d'une position | Étape 0e.4 |
|
||||
| [regler-l-application.feature](regler-l-application.feature) | Régler l'application (menu) | Étape 0e |
|
||||
| [regler-l-application.feature](regler-l-application.feature) | Régler l'application (menu), la mettre à jour | Étape 0e |
|
||||
| [decouvrir-l-application.feature](decouvrir-l-application.feature) | Premier lancement et accueil | Étape 0e.5 |
|
||||
| [apprendre-les-regles.feature](apprendre-les-regles.feature) | Apprendre les règles avec le tutoriel | Étape 0e.5 |
|
||||
| [installer-l-application.feature](installer-l-application.feature) | Installer l'application | Étape 0e.5 |
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
# language: fr
|
||||
Fonctionnalité: Apprendre les règles avec le tutoriel
|
||||
|
||||
Le tutoriel explique les règles en huit petites étapes, sur le vrai
|
||||
plateau : les pions bougent pour montrer chaque règle, et une étape sur
|
||||
deux vous fait jouer le geste vous-même. GNU Backgammon vérifie chaque
|
||||
coup, comme en partie.
|
||||
|
||||
Scénario: Comment se passe le tutoriel ?
|
||||
Chaque étape tient en un écran court : un titre, le plateau, deux ou
|
||||
trois phrases. Le coup montré se joue tout seul après une seconde ;
|
||||
« Revoir » le rejoue. La barre du haut montre où vous en êtes et vous
|
||||
ramène aux étapes déjà vues. « Quitter » vous emmène jouer.
|
||||
|
||||
Étant donné que vous commencez le tutoriel
|
||||
Alors il compte huit étapes, dont quatre où c'est à vous de jouer
|
||||
Et chaque position du tutoriel se lit sur le plateau
|
||||
Quand vous arrivez à l'étape 3
|
||||
Alors la barre de progression vous ramène aux étapes 1 et 2, mais pas aux suivantes
|
||||
|
||||
@position:4HPwATDgc/ABMA
|
||||
Scénario: Je joue un autre coup que celui demandé
|
||||
Sur les écrans « à vous », vous jouez sur le vrai plateau. Si votre coup
|
||||
n'est pas celui demandé, le pion revient et une phrase vous dit
|
||||
pourquoi ; réessayez autant que vous voulez. Le bon coup est salué, et
|
||||
« Continuer » vous mène à la suite.
|
||||
|
||||
Étant donné que vous êtes à l'écran « à vous » de l'étape 3
|
||||
Quand vous déplacez un pion de la flèche 24 à la flèche 21
|
||||
Alors le pion revient, et une phrase vous dit pourquoi
|
||||
Quand vous réunissez deux pions sur la flèche 5
|
||||
Alors le tutoriel salue votre coup
|
||||
|
||||
Scénario: Comment répondre au double dans le tutoriel ?
|
||||
À la dernière étape, l'ordinateur vous propose de doubler. Prenez ou
|
||||
passez : le maître vous dit ensuite ce qu'il aurait fait, et pourquoi.
|
||||
|
||||
Étant donné que vous êtes à l'écran « à vous » de l'étape 8
|
||||
Quand vous répondez « Passer »
|
||||
Alors le maître dit qu'il aurait pris
|
||||
Quand vous répondez « Prendre »
|
||||
Alors le tutoriel salue votre réponse
|
||||
@@ -0,0 +1,31 @@
|
||||
# language: fr
|
||||
Fonctionnalité: Découvrir l'application
|
||||
|
||||
À la toute première ouverture, Ustagammon vous demande d'où vous partez,
|
||||
puis s'efface : ensuite, il s'ouvre sur l'accueil, avec votre partie en
|
||||
cours à reprendre d'un toucher.
|
||||
|
||||
@position:4HPwATDgc/ABMA
|
||||
Scénario: Que me propose l'application à la première ouverture ?
|
||||
Deux choix, et c'est tout : « Je découvre » ouvre le tutoriel des règles,
|
||||
« Je connais les règles » lance une partie tout de suite. Ce choix ne
|
||||
revient plus ; le tutoriel reste dans le menu.
|
||||
|
||||
Étant donné que vous ouvrez Ustagammon pour la toute première fois
|
||||
Alors l'accueil vous propose de découvrir les règles ou de jouer tout de suite
|
||||
Quand vous choisissez de découvrir les règles
|
||||
Alors le tutoriel s'ouvre à sa première étape
|
||||
Et ce choix ne vous est plus proposé
|
||||
|
||||
Scénario: Où retrouver ma partie en rouvrant l'application ?
|
||||
L'application s'ouvre sur l'accueil. Votre partie en cours y est, avec
|
||||
son plateau et les comptes de points : « Reprendre la partie » vous y
|
||||
ramène. Sans partie en cours, « Nouvelle partie » en commence une.
|
||||
|
||||
Étant donné que vous avez choisi de jouer tout de suite
|
||||
Et qu'une partie est en cours
|
||||
Quand vous rouvrez Ustagammon
|
||||
Alors l'accueil vous propose de reprendre votre partie
|
||||
Quand la partie se termine
|
||||
Et que vous rouvrez Ustagammon
|
||||
Alors l'accueil vous propose une nouvelle partie
|
||||
@@ -0,0 +1,46 @@
|
||||
# language: fr
|
||||
Fonctionnalité: Installer l'application
|
||||
|
||||
Installé, Ustagammon a son icône sur l'écran d'accueil du téléphone,
|
||||
s'ouvre en plein écran et marche sans réseau. L'application vous le
|
||||
propose au bon moment, une fois, sans jamais interrompre une partie.
|
||||
|
||||
Scénario: Comment installer l'application ?
|
||||
Touchez « Installer » sur l'accueil, ou « Installer l'application » dans
|
||||
le menu. Avec Chrome, le téléphone vous demande de confirmer. Avec
|
||||
Firefox ou sur iPhone, l'application vous montre la marche à suivre, en
|
||||
trois étapes.
|
||||
|
||||
Étant donné que votre navigateur ne propose pas lui-même l'installation
|
||||
Quand vous demandez à installer l'application avec Firefox
|
||||
Alors la marche à suivre compte trois étapes, en commençant par le menu du navigateur
|
||||
Quand vous demandez à installer l'application sur iPhone
|
||||
Alors la marche à suivre compte trois étapes, en commençant par le bouton de partage
|
||||
|
||||
Scénario: Quand l'application me propose-t-elle de l'installer ?
|
||||
Jamais à la première ouverture ni pendant une partie : l'invitation
|
||||
apparaît sur l'accueil après votre première partie terminée. « Plus
|
||||
tard » la masque ; elle revient une seule fois, trois parties plus tard.
|
||||
Le menu garde « Installer l'application » tant qu'elle n'est pas
|
||||
installée.
|
||||
|
||||
Étant donné que vous ouvrez Ustagammon pour la toute première fois
|
||||
Alors l'accueil ne vous propose pas d'installer l'application
|
||||
Quand vous terminez une partie
|
||||
Alors l'accueil vous propose d'installer l'application
|
||||
Quand vous choisissez « Plus tard »
|
||||
Alors l'accueil ne vous propose pas d'installer l'application
|
||||
Mais le menu vous le propose toujours
|
||||
Quand vous terminez 3 parties de plus
|
||||
Alors l'accueil vous propose d'installer l'application
|
||||
Quand vous choisissez « Plus tard »
|
||||
Et que vous terminez 3 parties de plus
|
||||
Alors l'accueil ne vous propose pas d'installer l'application
|
||||
|
||||
Scénario: Et une fois l'application installée ?
|
||||
Une fois Ustagammon installé, plus rien ne vous propose de l'installer.
|
||||
|
||||
Étant donné que vous avez terminé une partie
|
||||
Quand vous installez l'application
|
||||
Alors l'accueil ne vous propose pas d'installer l'application
|
||||
Et le menu non plus
|
||||
@@ -1,16 +1,22 @@
|
||||
# language: fr
|
||||
Fonctionnalité: Jouer une partie contre l'ordinateur
|
||||
|
||||
Vous jouez une partie complète contre l'ordinateur, qui s'appuie sur le
|
||||
moteur GNU Backgammon. L'application tire les dés, n'accepte que les coups
|
||||
Vous jouez une partie complète contre l'ordinateur, qui s'appuie sur
|
||||
GNU Backgammon. L'application tire les dés, n'accepte que les coups
|
||||
permis par les règles, et l'ordinateur joue et décide du videau tout seul.
|
||||
|
||||
Scénario: Commencer une partie
|
||||
@position:4HPwATDgc/ABMA
|
||||
Scénario: Comment commence une partie ?
|
||||
Les pions sont en position de départ. Chaque camp lance un dé : celui
|
||||
qui a le plus fort commence, et joue ces deux dés. En cas d'égalité, on
|
||||
relance.
|
||||
|
||||
Quand vous commencez une nouvelle partie
|
||||
Alors les pions sont en position de départ
|
||||
Et le premier jet de dés désigne qui commence
|
||||
|
||||
Scénario: Jouer un coup permis
|
||||
@position:4HPwATDgc/ABMA
|
||||
Scénario: Comment jouer un coup ?
|
||||
Touchez un pion, puis sa case d'arrivée ; s'il n'en a qu'une, il y va dès
|
||||
que vous le touchez. Chaque dé joué s'estompe. Une fois tous vos dés
|
||||
joués, votre coup attend que vous le validiez : touchez vos dés, d'un côté
|
||||
@@ -24,16 +30,17 @@ Fonctionnalité: Jouer une partie contre l'ordinateur
|
||||
Alors votre coup est accepté
|
||||
Et c'est ensuite au tour de l'ordinateur
|
||||
|
||||
Scénario: Un pion qui n'a qu'une case d'arrivée
|
||||
Après 8/5, il ne vous reste que le 1 : chaque pion jouable n'a plus
|
||||
qu'une case d'arrivée, et il y va dès que vous le touchez.
|
||||
Scénario: Pourquoi mon pion part-il dès que je le touche ?
|
||||
Un pion qui n'a qu'une case d'arrivée possible y va dès que vous le
|
||||
touchez, sans attendre un second toucher. Par exemple, avec 3-1, une fois
|
||||
8/5 joué, il ne vous reste que le 1.
|
||||
|
||||
Étant donné que c'est à vous de jouer avec le jet 3-1
|
||||
Quand vous jouez 8/5
|
||||
Alors seul votre 3 est marqué comme joué
|
||||
Et chacun de vos pions jouables n'a plus qu'une case d'arrivée
|
||||
|
||||
Scénario: Reprendre un pion avant de valider
|
||||
Scénario: Je me suis trompé de pion
|
||||
Tant que vous n'avez pas validé votre coup, « Annuler » reprend le
|
||||
dernier pion déplacé ; touchez-le encore pour reprendre le précédent.
|
||||
|
||||
@@ -45,21 +52,27 @@ Fonctionnalité: Jouer une partie contre l'ordinateur
|
||||
Alors la position n'a pas changé
|
||||
Et aucun de vos dés n'est marqué comme joué
|
||||
|
||||
Scénario: Un coup interdit est refusé
|
||||
La case 12 est tenue par l'ordinateur, qui y a cinq pions : vous ne
|
||||
pouvez pas vous y poser.
|
||||
@position:4HPwATDgc/ABMA
|
||||
Scénario: Pourquoi je ne peux pas poser mon pion sur cette flèche ?
|
||||
Une flèche où l'ordinateur a au moins deux pions est fermée : vous ne
|
||||
pouvez pas vous y poser. Au départ, par exemple, la flèche 12 est tenue
|
||||
par cinq de ses pions.
|
||||
|
||||
Étant donné que c'est à vous de jouer avec le jet 3-1
|
||||
Quand vous essayez de jouer 13/12
|
||||
Alors votre coup est refusé
|
||||
Et la position n'a pas changé
|
||||
|
||||
Scénario: L'ordinateur joue
|
||||
Scénario: Comment joue l'ordinateur ?
|
||||
L'ordinateur lance ses dés et joue le coup que GNU Backgammon, l'un des
|
||||
meilleurs programmes de backgammon, juge le meilleur. Il répond en
|
||||
moins de deux secondes.
|
||||
|
||||
Étant donné que c'est au tour de l'ordinateur
|
||||
Quand ses dés sont tirés
|
||||
Alors il joue le coup recommandé par le moteur en moins de 2 secondes
|
||||
|
||||
Scénario: Aucun coup possible avec votre jet
|
||||
Scénario: Je ne peux rien jouer avec mes dés
|
||||
Il arrive que les dés ne permettent de bouger aucun pion. Votre jet reste
|
||||
affiché, barré, avec un message. L'application ne joue jamais à votre
|
||||
place : le tour ne passe à l'ordinateur que quand vous touchez vos dés
|
||||
@@ -72,7 +85,7 @@ Fonctionnalité: Jouer une partie contre l'ordinateur
|
||||
Quand vous laissez passer votre tour
|
||||
Alors c'est ensuite au tour de l'ordinateur
|
||||
|
||||
Scénario: Voir le coup de l'ordinateur
|
||||
Scénario: Qu'a joué l'ordinateur ?
|
||||
Après chaque coup de l'ordinateur, ses pions glissent un par un sur le
|
||||
plateau. Restent ensuite, jusqu'à votre prochaine action, une flèche par
|
||||
pion déplacé, ses pions arrivés entourés et un pion fantôme là où ils
|
||||
@@ -84,11 +97,13 @@ Fonctionnalité: Jouer une partie contre l'ordinateur
|
||||
Et votre pion frappé part sur la barre
|
||||
Et vous pouvez revoir son coup
|
||||
|
||||
Scénario: Voir le compte de points
|
||||
Au-dessus et au-dessous du plateau, le bandeau de chaque camp donne son
|
||||
compte de points, et « mène de 7 » s'affiche du côté de celui qui mène la
|
||||
course. Touchez un compte pour lire ce qu'il veut dire. En paysage, les
|
||||
comptes sont aux deux bouts de la barre.
|
||||
Scénario: À quoi sert le compte de points ?
|
||||
Le compte de points dit combien de cases vos pions doivent encore
|
||||
parcourir avant de sortir : le plus petit mène la course. Au-dessus et
|
||||
au-dessous du plateau, le bandeau de chaque camp donne son compte, et
|
||||
« mène de 7 » s'affiche du côté de celui qui mène. Touchez un compte pour
|
||||
lire ce qu'il veut dire. En paysage, les comptes sont aux deux bouts de
|
||||
la barre.
|
||||
|
||||
Quand vous commencez une nouvelle partie
|
||||
Alors chaque camp a un compte de points de 167
|
||||
@@ -96,7 +111,7 @@ Fonctionnalité: Jouer une partie contre l'ordinateur
|
||||
Quand vous jouez 13/8 13/11
|
||||
Alors vous menez la course de 7
|
||||
|
||||
Scénario: Doubler, après confirmation
|
||||
Scénario: Comment doubler ?
|
||||
Avant de lancer les dés, vous pouvez doubler en touchant le videau ou
|
||||
« Doubler ». L'application vous demande de confirmer : une fois proposé,
|
||||
un double ne se reprend pas.
|
||||
@@ -109,19 +124,29 @@ Fonctionnalité: Jouer une partie contre l'ordinateur
|
||||
Et que vous confirmez
|
||||
Alors l'ordinateur prend ou passe, comme le recommande le moteur
|
||||
|
||||
Scénario: Prendre le double de l'ordinateur
|
||||
Scénario: L'ordinateur double : que se passe-t-il si je prends ?
|
||||
Si vous prenez, la partie continue et vaut deux fois plus. Le videau
|
||||
passe de votre côté : vous seul pourrez redoubler.
|
||||
|
||||
Étant donné que l'ordinateur vous propose de doubler
|
||||
Quand vous prenez
|
||||
Alors la partie continue à valeur doublée
|
||||
Et le videau est de votre côté : vous seul pourrez redoubler
|
||||
|
||||
Scénario: Passer le double de l'ordinateur
|
||||
Scénario: L'ordinateur double : que se passe-t-il si je passe ?
|
||||
Passer, c'est abandonner la partie tout de suite : vous la perdez à sa
|
||||
valeur d'avant le double.
|
||||
|
||||
Étant donné que l'ordinateur vous propose de doubler
|
||||
Quand vous passez
|
||||
Alors la partie s'arrête
|
||||
Et vous perdez la partie à sa valeur d'avant le double
|
||||
|
||||
Scénario: Finir la partie
|
||||
Scénario: Comment se termine une partie ?
|
||||
La partie s'arrête dès qu'un joueur a sorti tous ses pions. Le panneau de
|
||||
fin dit qui gagne, et combien de points : la valeur du videau, doublée
|
||||
pour un gammon et triplée pour un backgammon, une fois le videau doublé.
|
||||
|
||||
Étant donné qu'un joueur a sorti tous ses pions
|
||||
Alors la partie s'arrête
|
||||
Et le résultat indique le gagnant et les points gagnés
|
||||
|
||||
@@ -7,7 +7,11 @@ Fonctionnalité: Retrouver mes parties
|
||||
une, vous passez d'une branche à l'autre, ou vous l'exportez au format SGF
|
||||
pour l'ouvrir dans GNU Backgammon.
|
||||
|
||||
Scénario: Reprendre une partie parmi plusieurs
|
||||
Scénario: Comment reprendre une autre partie ?
|
||||
Toutes vos parties restent sur le téléphone. Dans « Mes parties », depuis
|
||||
le menu, la dernière jouée vient en premier : « Reprendre » vous ramène à
|
||||
une partie telle que vous l'aviez laissée.
|
||||
|
||||
Étant donné que vous avez joué deux parties, l'une après l'autre
|
||||
Quand vous fermez l'application puis la rouvrez
|
||||
Alors la dernière partie jouée est à l'écran
|
||||
@@ -15,7 +19,7 @@ Fonctionnalité: Retrouver mes parties
|
||||
Quand vous reprenez la première partie
|
||||
Alors elle est à l'écran, telle que vous l'aviez laissée
|
||||
|
||||
Scénario: Rejouer d'ici sans perdre l'original
|
||||
Scénario: Puis-je rejouer un moment de la partie autrement ?
|
||||
Revenez à un moment passé de la partie et jouez autre chose : une
|
||||
nouvelle branche commence là, et la partie d'origine reste entière.
|
||||
|
||||
@@ -28,7 +32,11 @@ Fonctionnalité: Retrouver mes parties
|
||||
Alors vous êtes sur la nouvelle branche
|
||||
Et vous pouvez revenir à la partie d'origine, au coup près
|
||||
|
||||
Scénario: Exporter une partie
|
||||
Scénario: Comment garder une partie dans un fichier ?
|
||||
Dans « Mes parties », « Exporter (SGF) » enregistre la partie dans un
|
||||
fichier que GNU Backgammon sait ouvrir ; les suites rejouées y sont des
|
||||
variantes.
|
||||
|
||||
Étant donné que vous avez joué plusieurs coups
|
||||
Et que vous avez rejoué d'ici une autre suite
|
||||
Quand vous exportez la partie
|
||||
|
||||
@@ -6,7 +6,7 @@ Fonctionnalité: Partager une position
|
||||
question. Le lien contient les deux identifiants de GNU Backgammon, celui
|
||||
de la position et celui du match, et rien d'autre.
|
||||
|
||||
Scénario: Partager la position
|
||||
Scénario: Comment partager une position ?
|
||||
Pendant la partie, « Copier le lien de la position », dans le menu ou à
|
||||
côté du dernier coup de l'ordinateur, copie l'adresse de la position à
|
||||
l'écran. Ouvert ailleurs, le lien montre la même position.
|
||||
@@ -17,7 +17,7 @@ Fonctionnalité: Partager une position
|
||||
Quand vous ouvrez ce lien
|
||||
Alors vous voyez exactement la même position, avec le même videau
|
||||
|
||||
Scénario: Ouvrir un lien vers une position
|
||||
Scénario: On m'a envoyé le lien d'une position
|
||||
Un lien vers une position montre le plateau, sans le rendre jouable :
|
||||
vous y êtes en bas, avec la main. « Jouer à partir d'ici » commence une
|
||||
nouvelle partie contre l'ordinateur depuis cette position, et votre
|
||||
@@ -33,7 +33,7 @@ Fonctionnalité: Partager une position
|
||||
Et le videau est à 2, chez l'ordinateur
|
||||
Et votre partie précédente est toujours enregistrée
|
||||
|
||||
Scénario: Ouvrir un lien abîmé
|
||||
Scénario: Le lien d'une position ne s'ouvre pas
|
||||
Un lien coupé ou mal recopié ne casse rien : un message le dit, et
|
||||
« Retour à ma partie » reste là.
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ Fonctionnalité: Régler l'application
|
||||
l'écran allumé pendant la partie. Rien de ce qu'il contient ne vous pose de
|
||||
question : ce qui se fait se rattrape.
|
||||
|
||||
Scénario: Commencer une nouvelle partie, puis reprendre l'ancienne
|
||||
Scénario: J'ai commencé une nouvelle partie par erreur
|
||||
Une nouvelle partie commence tout de suite, sans question. Pendant 10
|
||||
secondes, le bandeau « Reprendre l'ancienne » vous rend la partie que
|
||||
vous venez de quitter, exactement là où vous l'aviez laissée.
|
||||
@@ -19,7 +19,7 @@ Fonctionnalité: Régler l'application
|
||||
Quand vous reprenez l'ancienne partie
|
||||
Alors vous retrouvez exactement la partie quittée
|
||||
|
||||
Scénario: Couper les vibrations
|
||||
Scénario: Comment couper les vibrations ?
|
||||
Le téléphone vibre brièvement quand votre pion se pose, quand un pion est
|
||||
frappé, quand l'ordinateur vous propose de doubler et à la fin de la
|
||||
partie. Ces vibrations accompagnent toujours ce que montre l'écran ; rien
|
||||
@@ -32,7 +32,7 @@ Fonctionnalité: Régler l'application
|
||||
Et qu'un pion est frappé
|
||||
Alors le téléphone ne vibre pas
|
||||
|
||||
Scénario: Garder l'écran allumé pendant la partie
|
||||
Scénario: Pourquoi l'écran ne se met-il pas en veille ?
|
||||
Pour que le téléphone ne se mette pas en veille pendant que vous
|
||||
réfléchissez, l'écran reste allumé tant qu'une partie est en cours.
|
||||
|
||||
@@ -41,8 +41,27 @@ Fonctionnalité: Régler l'application
|
||||
Quand vous coupez « Écran allumé pendant la partie » dans le menu
|
||||
Alors l'écran peut se mettre en veille comme d'habitude
|
||||
|
||||
Scénario: Choisir le thème
|
||||
Scénario: Comment passer en thème sombre ?
|
||||
Le thème suit d'abord celui du téléphone. Dans le menu, « Thème » passe à
|
||||
clair, puis à sombre, puis revient à automatique.
|
||||
|
||||
Étant donné que vous ouvrez l'application pour la première fois
|
||||
Alors le thème suit celui du téléphone
|
||||
Quand vous changez de thème dans le menu
|
||||
Alors il passe à clair, puis à sombre, puis revient à automatique
|
||||
|
||||
Scénario: Comment mettre l'application à jour ?
|
||||
Dès qu'une nouvelle version d'Ustagammon est publiée, l'application la
|
||||
télécharge d'elle-même, puis affiche en bas de l'écran « Nouvelle
|
||||
version prête. », seulement entre deux tours, jamais pendant que vous
|
||||
jouez un coup. « Mettre à jour » l'installe et vous ramène où vous en
|
||||
étiez. Dans le menu, « Rechercher une mise à
|
||||
jour » vérifie tout de suite s'il y en a une.
|
||||
|
||||
Étant donné qu'une nouvelle version d'Ustagammon est disponible
|
||||
Et que vous avez des dés à jouer
|
||||
Alors l'application ne vous propose pas encore de mettre à jour
|
||||
Quand vous déplacez un pion de votre coup
|
||||
Alors l'application ne vous propose pas encore de mettre à jour
|
||||
Quand vous avez joué votre coup
|
||||
Alors l'application vous propose de mettre à jour
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
# Tutoriel des règles
|
||||
|
||||
Le texte du tutoriel de l'application, en une seule source (ADR 0008) :
|
||||
`regles.md`. `make site` copie ce dossier dans le site publié
|
||||
(`docs/tutoriel/`) ; l'application le lit à l'exécution
|
||||
(`app/src/ui/tutorial-text.ts`), et `make test` vérifie chaque position et
|
||||
chaque coup avec le vrai moteur (`app/tests/tutorial.test.ts`).
|
||||
|
||||
Texte sous licence **CC BY-SA 4.0**. Première version, rédigée par Claude
|
||||
Code d'après la charte (`docs/identite/charte/accueil-tutoriel-aide.md`),
|
||||
**à valider par Pierre**.
|
||||
|
||||
## Format
|
||||
|
||||
Tout ce qui précède la première étape (ce titre, cette notice) n'est pas
|
||||
affiché.
|
||||
|
||||
```markdown
|
||||
## 3. Les dés et les doubles
|
||||
|
||||
- position: 4HPwATDgc/ABMA
|
||||
- jet: 5-2
|
||||
- coup: 13/11 13/8
|
||||
|
||||
Un paragraphe, avec un [mot du glossaire](pion.md) et du **gras**.
|
||||
|
||||
### À vous
|
||||
|
||||
- position: 4HPwATDgc/ABMA
|
||||
- jet: 3-1
|
||||
- attendu: 8/5 6/5
|
||||
- juste: La phrase affichée quand le coup est juste.
|
||||
- refus: La phrase affichée quand le coup est refusé.
|
||||
|
||||
La consigne de l'écran.
|
||||
|
||||
## Fin
|
||||
|
||||
- action: Jouer ma première partie
|
||||
|
||||
Le texte de la fin du tutoriel.
|
||||
```
|
||||
|
||||
- `## <n>. <titre>` commence l'étape *n* (huit au plus, dans l'ordre) ;
|
||||
`### À vous` commence son écran « à vous », facultatif ; `## Fin`, l'écran
|
||||
qui suit la dernière étape.
|
||||
- Les lignes `- clé: valeur` en tête d'écran le décrivent :
|
||||
- `position` : l'identifiant de position de GNU Backgammon (ADR 0013), vu
|
||||
du camp au trait ;
|
||||
- `trait` : `ordinateur` si c'est l'ordinateur qui a la main dans cette
|
||||
position (par défaut, vous) ;
|
||||
- `jet` et `coup` : sur un écran d'explication, le coup illustré, qui se
|
||||
joue tout seul (notation de gnubg) ;
|
||||
- `chemin: oui` : le sens du jeu, dessiné en pointillés sur le plateau ;
|
||||
- `attendu` (une ligne par coup accepté) : sur un écran « à vous », les
|
||||
coups justes, en notation de gnubg, tels que le moteur les écrit ;
|
||||
- `réponse` : `prendre` ou `passer`, pour un écran « à vous » où
|
||||
l'ordinateur double ; le moteur doit donner la même réponse ;
|
||||
- `juste` et `refus` : les phrases qui suivent un geste juste, ou refusé ;
|
||||
- `action` : le libellé du bouton de la fin.
|
||||
- Les autres lignes forment les paragraphes. `[mot](fiche.md)` renvoie à une
|
||||
fiche du glossaire, à la première apparition du mot.
|
||||
|
||||
Aucune règle n'est écrite dans le code : le moteur lit les positions,
|
||||
illustre les coups, juge les coups joués et les décisions de videau.
|
||||
@@ -0,0 +1,128 @@
|
||||
# Les règles du backgammon
|
||||
|
||||
Tutoriel d'Ustagammon, en huit étapes. Texte sous licence CC BY-SA 4.0,
|
||||
validé par Pierre le 9 novembre 2026. Format : voir README.md.
|
||||
|
||||
## 1. Le but du jeu
|
||||
|
||||
- position: 4HPwATDgc/ABMA
|
||||
|
||||
Vous avez quinze [pions](pion.md) ivoire, l'ordinateur quinze pions noyer. Chacun fait faire le tour du [plateau](plateau.md) à ses pions, puis les [sort](sortie.md).
|
||||
|
||||
Le premier qui a sorti tous les siens gagne la [partie](partie.md).
|
||||
|
||||
## 2. Dans quel sens avancer
|
||||
|
||||
- position: 4HPwATDgc/ABMA
|
||||
- chemin: oui
|
||||
|
||||
Vos pions ivoire font le tour du plateau dans le sens des pointillés : ils partent d'en haut à droite, passent à gauche, puis reviennent en bas jusqu'aux **six dernières flèches**, en bas à droite. C'est là que vous les rassemblerez avant de les sortir.
|
||||
|
||||
Les pions noyer de l'ordinateur font le même chemin, en sens inverse.
|
||||
|
||||
## 3. Les dés et les doubles
|
||||
|
||||
- position: 4HPwATDgc/ABMA
|
||||
- jet: 5-2
|
||||
- coup: 13/11 13/8
|
||||
|
||||
Chaque dé déplace un pion d'autant de flèches. Avec 5 et 2, vous jouez un pion de 5 et un pion de 2, ou le même pion de 5 puis de 2.
|
||||
|
||||
Un double se joue quatre fois : avec 4-4, vous avancez quatre fois de 4.
|
||||
|
||||
### À vous
|
||||
|
||||
- position: 4HPwATDgc/ABMA
|
||||
- jet: 3-1
|
||||
- attendu: 8/5 6/5
|
||||
- juste: Deux pions sur la flèche 5 : elle est à vous, l'ordinateur ne peut plus s'y poser.
|
||||
- refus: Ce coup est permis, mais il ne réunit pas deux pions sur la flèche 5. Jouez un pion de la flèche 8 avec le 3, et un de la flèche 6 avec le 1.
|
||||
|
||||
Vous avez 3 et 1. Réunissez deux pions sur la flèche 5 : touchez un pion, puis sa case d'arrivée.
|
||||
|
||||
## 4. Frapper un pion seul
|
||||
|
||||
- position: 4HPwESDgc/ABMA
|
||||
- jet: 4-2
|
||||
- coup: 13/11 13/9*
|
||||
|
||||
Un pion seul sur une flèche s'appelle un [blot](blot.md). Si vous vous posez dessus, vous le frappez : il part sur la [barre](barre.md) et devra refaire tout le chemin.
|
||||
|
||||
Une flèche où l'adversaire a deux pions ou plus est fermée : vous ne pouvez pas vous y poser.
|
||||
|
||||
### À vous
|
||||
|
||||
- position: 4HPwIDDgc/ABMA
|
||||
- jet: 6-1
|
||||
- attendu: 13/7* 8/7
|
||||
- attendu: 13/7*/6
|
||||
- attendu: 13/7* 6/5
|
||||
- attendu: 24/23 13/7*
|
||||
- attendu: 24/18 8/7*
|
||||
- attendu: 8/7* 8/2
|
||||
- juste: Touché : le pion noyer part sur la barre, il devra refaire tout le chemin.
|
||||
- refus: Ce coup ne frappe pas le pion seul de la flèche 7. Un pion qui s'y pose le frappe : celui de la flèche 13 avec le 6, ou celui de la flèche 8 avec le 1.
|
||||
|
||||
Vous avez 6 et 1. Le pion noyer de la flèche 7 est seul : frappez-le.
|
||||
|
||||
## 5. Rentrer depuis la barre
|
||||
|
||||
- position: 4HPwATDgc/ABUA
|
||||
- jet: 4-3
|
||||
- coup: bar/22 13/9
|
||||
|
||||
Votre pion frappé attend sur la barre, au milieu. Avant de jouer quoi que ce soit d'autre, il doit rentrer dans le [camp](camp.md) adverse, en haut à droite.
|
||||
|
||||
Chaque dé indique une flèche d'entrée : avec 3 et 4, la 22 ou la 21. Si les deux flèches sont fermées, votre tour passe.
|
||||
|
||||
## 6. Sortir ses pions
|
||||
|
||||
- position: bHcAANh2dwAAAA
|
||||
- jet: 5-2
|
||||
- coup: 5/off 2/off
|
||||
|
||||
Quand vos quinze pions sont dans les six dernières flèches, vous pouvez commencer à les sortir : un 6 sort un pion de la flèche 6, un 1 un pion de la flèche 1.
|
||||
|
||||
Touchez le pion, puis la sortie à droite.
|
||||
|
||||
### À vous
|
||||
|
||||
- position: bHcAANh2dwAAAA
|
||||
- jet: 6-3
|
||||
- attendu: 6/off 3/off
|
||||
- juste: Deux pions sortis. Plus que treize !
|
||||
- refus: Ce coup est permis, mais il ne sort qu'un pion. Le 6 sort un pion de la flèche 6, le 3 un pion de la flèche 3.
|
||||
|
||||
Vous avez 6 et 3 : sortez deux pions.
|
||||
|
||||
## 7. Gammon et backgammon
|
||||
|
||||
- position: 8J4HBwAFAAAAAA
|
||||
- jet: 2-1
|
||||
- coup: 2/off 1/off
|
||||
|
||||
Une partie gagnée vaut 1 point. Si l'adversaire n'a sorti aucun pion, c'est un [gammon](gammon.md) : 2 points ; s'il lui reste en plus un pion sur la barre ou dans votre camp, c'est un [backgammon](backgammon.md) : 3 points. En partie libre, gammon et backgammon ne comptent que si le videau a été doublé : c'est la [règle de Jacoby](regle-de-jacoby.md).
|
||||
|
||||
## 8. Le videau
|
||||
|
||||
- position: 4HPwATDgc/ABMA
|
||||
|
||||
Le cube aux faces 2, 4, 8, 16, 32 et 64 s'appelle le [videau](videau.md) : il sert à miser. Avant de lancer vos dés, vous pouvez proposer de [doubler](doubler.md) la valeur de la partie.
|
||||
|
||||
L'ordinateur [prend](prendre.md), et la partie continue en valant le double, ou il [passe](passer.md), et vous gagnez tout de suite. Celui qui a pris garde le cube : lui seul pourra redoubler.
|
||||
|
||||
### À vous
|
||||
|
||||
- position: 7O7YAADs7jYAAA
|
||||
- trait: ordinateur
|
||||
- réponse: prendre
|
||||
- juste: Le maître prend aussi. L'ordinateur mène la course, mais vous gagnez encore assez souvent pour que prendre vous coûte moins cher, en moyenne, que passer.
|
||||
- refus: Le maître prendrait. Passer vous coûte un point tout de suite ; en prenant, vous gardez vos chances de gagner, et le videau est chez vous.
|
||||
|
||||
C'est à l'ordinateur de jouer, et il mène la course. Il vous propose de doubler : prenez-vous, ou passez-vous ?
|
||||
|
||||
## Fin
|
||||
|
||||
- action: Jouer ma première partie
|
||||
|
||||
Vous connaissez les règles. Le reste s'apprend en jouant, et le maître vous accompagne.
|
||||
+23
-4
@@ -11,6 +11,7 @@ set -euo pipefail
|
||||
|
||||
CAPROVER_MACHINE="${CAPROVER_MACHINE:-sans-pub}"
|
||||
CAPROVER_APP="${CAPROVER_APP:-ustagammon}"
|
||||
SITE_URL="${SITE_URL:-https://ustagammon.sans.pub}"
|
||||
|
||||
die() {
|
||||
echo "Erreur : $1" >&2
|
||||
@@ -33,9 +34,14 @@ git fetch -q origin main
|
||||
[ "$(git rev-parse main)" = "$(git rev-parse origin/main)" ] \
|
||||
|| die "main diffère d'origin/main : git pull (ou fusionner la MR) d'abord"
|
||||
|
||||
# Le jeton CapRover expire ; caprover ls ne le vérifie pas.
|
||||
caprover api -n "$CAPROVER_MACHINE" -t /user/system/info -m GET -d '{}' -o false >/dev/null 2>&1 \
|
||||
|| die "session CapRover expirée : lancer caprover login dans un terminal"
|
||||
# Le jeton CapRover expire ; caprover ls ne le vérifie pas. Sans terminal
|
||||
# (stdin fermé), la CLI ne peut pas demander le mot de passe : elle doit
|
||||
# échouer, et sa sortie ne doit pas contenir de demande de mot de passe (le
|
||||
# 2026-11-09, elle a demandé le mot de passe puis rendu 0 sans rien publier).
|
||||
if ! session=$(caprover api -n "$CAPROVER_MACHINE" -t /user/system/info -m GET -d '{}' -o false </dev/null 2>&1) \
|
||||
|| grep -qi "password\|not valid anymore" <<<"$session"; then
|
||||
die "session CapRover expirée : lancer caprover login dans un terminal"
|
||||
fi
|
||||
|
||||
echo "Vérification avant publication : make lint et make test"
|
||||
make lint
|
||||
@@ -43,4 +49,17 @@ make test
|
||||
|
||||
make package
|
||||
echo "Publication de main ($(git rev-parse --short main)) sur l'app $CAPROVER_APP"
|
||||
caprover deploy -n "$CAPROVER_MACHINE" -a "$CAPROVER_APP" -t build/ustagammon.tar
|
||||
caprover deploy -n "$CAPROVER_MACHINE" -a "$CAPROVER_APP" -t build/ustagammon.tar </dev/null
|
||||
|
||||
# La CLI peut rendre 0 sans avoir publié : on vérifie le résultat réel, le
|
||||
# commit servi par le site (version.json), pendant deux minutes au plus.
|
||||
commit=$(git rev-parse main)
|
||||
for _ in $(seq 24); do
|
||||
served=$(curl -fsS "$SITE_URL/version.json?verif=$RANDOM" 2>/dev/null || true)
|
||||
if grep -q "\"$commit\"" <<<"$served"; then
|
||||
echo "Publié : $SITE_URL sert $(git rev-parse --short main)."
|
||||
exit 0
|
||||
fi
|
||||
sleep 5
|
||||
done
|
||||
die "le site ne sert pas le commit $(git rev-parse --short main) après 2 minutes : la publication a échoué (version servie : ${served:-aucune})"
|
||||
|
||||
+35
-4
@@ -2,7 +2,11 @@
|
||||
# Joue une partie complète par l'interface dans Chromium et Firefox sans
|
||||
# écran (tests/browser/play.mjs), sur le site servi par l'image de production
|
||||
# construite en local, comme tests/test_site.sh. Les captures d'écran vont
|
||||
# dans build/browser-shots/.
|
||||
# dans build/browser-shots/. Puis l'accueil, le tutoriel, l'aide,
|
||||
# l'installation et la mise à jour (tests/browser/accueil.mjs), avec leurs
|
||||
# captures dans build/browser-shots/accueil-tutoriel/.
|
||||
#
|
||||
# BROWSERS=chromium et SCRIPTS=accueil (ou play) restreignent le test.
|
||||
#
|
||||
# Outils : l'image de Playwright (navigateurs) et le paquet playwright,
|
||||
# installé une fois par npm dans build/browser-tools/ (réseau nécessaire la
|
||||
@@ -14,6 +18,8 @@ cd "$(dirname "$0")/.."
|
||||
|
||||
image="${PLAYWRIGHT_IMAGE:?lancer via make test-browser}"
|
||||
browsers="${BROWSERS:-chromium firefox}"
|
||||
# Les scripts à lancer (tests/browser/<nom>.mjs), tous par défaut.
|
||||
scripts="${SCRIPTS:-play accueil}"
|
||||
|
||||
unset MAKEFLAGS MFLAGS MAKELEVEL
|
||||
make -s package >/dev/null
|
||||
@@ -33,15 +39,40 @@ if [ ! -f "$tools/node_modules/playwright/package.json" ]; then
|
||||
"$image" npm install --no-audit --no-fund --save-exact "playwright@$version" >/dev/null
|
||||
fi
|
||||
|
||||
# Pour la mise à jour de l'application (tests/browser/accueil.mjs) : une
|
||||
# copie du site, servie par la même image mais depuis un dossier que le test
|
||||
# modifie, pour publier une deuxième version sur la même adresse.
|
||||
updates=build/browser-site
|
||||
rm -rf "$updates"
|
||||
cp -r build/site "$updates"
|
||||
update_container="$(docker run -d --rm -p 127.0.0.1::80 -v "$PWD/$updates:/srv:ro" "$site_image")"
|
||||
# shellcheck disable=SC2317 # appelée par trap, à la sortie
|
||||
update_cleanup() {
|
||||
docker rm -f "$update_container" >/dev/null 2>&1 || true
|
||||
site_cleanup
|
||||
}
|
||||
trap update_cleanup EXIT
|
||||
update_port="$(docker port "$update_container" 80/tcp | head -n1 | sed 's/.*://')"
|
||||
|
||||
rm -rf build/browser-shots
|
||||
mkdir -p build/browser-shots
|
||||
cp tests/browser/play.mjs "$tools/"
|
||||
cp tests/browser/play.mjs tests/browser/accueil.mjs "$tools/"
|
||||
status=0
|
||||
for browser in $browsers; do
|
||||
# Réseau de l'hôte : le site est sur 127.0.0.1. Le service worker
|
||||
# demande un contexte sûr, ce qu'est localhost.
|
||||
if [[ " $scripts " == *" play "* ]]; then
|
||||
docker run --rm --init --ipc=host --network host --user "$(id -u):$(id -g)" -e HOME=/tmp \
|
||||
-e TRACE -v "$PWD/$tools:/tools" -v "$PWD/build/browser-shots:/shots" -w /tools "$image" \
|
||||
node play.mjs "http://localhost:$port/" "$browser" /shots || status=1
|
||||
fi
|
||||
[[ " $scripts " == *" accueil "* ]] || continue
|
||||
# Accueil, tutoriel, aide, installation et mise à jour ; captures de la
|
||||
# recette dans build/browser-shots/accueil-tutoriel/.
|
||||
docker run --rm --init --ipc=host --network host --user "$(id -u):$(id -g)" -e HOME=/tmp \
|
||||
-e TRACE -v "$PWD/$tools:/tools" -v "$PWD/build/browser-shots:/shots" -w /tools "$image" \
|
||||
node play.mjs "http://localhost:$port/" "$browser" /shots || status=1
|
||||
-v "$PWD/$tools:/tools" -v "$PWD/build/browser-shots:/shots" -v "$PWD/$updates:/site" \
|
||||
-w /tools "$image" \
|
||||
node accueil.mjs "http://localhost:$port/" "$browser" /shots \
|
||||
"http://localhost:$update_port/" /site || status=1
|
||||
done
|
||||
exit "$status"
|
||||
|
||||
@@ -0,0 +1,548 @@
|
||||
// accueil.mjs -- l'accueil, le tutoriel des règles, l'aide, l'installation
|
||||
// et la mise à jour de l'application, dans un vrai navigateur sans écran
|
||||
// (Playwright), sur le site servi par l'image de production
|
||||
// (docs/plans/2026-11-03-accueil-tutoriel-aide.md, point 7).
|
||||
//
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
//
|
||||
// Usage : node accueil.mjs <adresse du site> <chromium|firefox> <dossier des
|
||||
// captures> <adresse du site modifiable> <dossier de ce site>
|
||||
// Lancé par tests/browser.sh. Le second site sert la même chose depuis un
|
||||
// dossier que ce script modifie : il y publie une deuxième version pour
|
||||
// essayer la mise à jour. Captures de la recette design (Chromium) dans
|
||||
// <dossier des captures>/accueil-tutoriel/, en 412×915 et 915×412, et aussi
|
||||
// en 844×320 pour le tutoriel.
|
||||
|
||||
import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
|
||||
import { chromium, firefox } from "playwright";
|
||||
|
||||
const [base, browserName, shots, updateBase, updateDir] = process.argv.slice(2);
|
||||
const browserType = { chromium, firefox }[browserName];
|
||||
if (!base || !browserType || !shots || !updateBase || !updateDir) {
|
||||
console.error("usage : node accueil.mjs <adresse> <chromium|firefox> <captures> <adresse 2> <dossier 2>");
|
||||
process.exit(2);
|
||||
}
|
||||
const full = browserName === "chromium";
|
||||
const dir = `${shots}/accueil-tutoriel`;
|
||||
mkdirSync(dir, { recursive: true });
|
||||
const SIZES = [
|
||||
[412, 915],
|
||||
[915, 412],
|
||||
];
|
||||
// Le tutoriel est aussi vérifié dans le plus petit paysage de la recette.
|
||||
const TUTORIAL_SIZES = [...SIZES, [844, 320]];
|
||||
|
||||
const browser = await browserType.launch();
|
||||
const problems = [];
|
||||
const log = (text) => console.log(`[${browserName}] ${text}`);
|
||||
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
let captures = 0;
|
||||
|
||||
function check(condition, message) {
|
||||
if (!condition) {
|
||||
throw new Error(message);
|
||||
}
|
||||
}
|
||||
|
||||
/** Un contexte neuf (stockage vide), avec des réglages déjà enregistrés si
|
||||
* settings est donné. Erreurs de la page et violations de la CSP relevées. */
|
||||
async function newPage(settings = null, url = base) {
|
||||
const context = await browser.newContext({
|
||||
viewport: { width: 412, height: 915 },
|
||||
deviceScaleFactor: 1,
|
||||
locale: "fr-FR",
|
||||
});
|
||||
await context.addInitScript((saved) => {
|
||||
window.__csp = [];
|
||||
document.addEventListener("securitypolicyviolation", (e) =>
|
||||
window.__csp.push(`${e.effectiveDirective} ${e.blockedURI}`),
|
||||
);
|
||||
// Un élément est-il à l'écran ? (Utilisé par les attentes.)
|
||||
window.__shown = (id) => {
|
||||
const el = document.getElementById(id);
|
||||
return el !== null && !el.closest("[hidden]") && el.getClientRects().length > 0;
|
||||
};
|
||||
window.__vibrations = [];
|
||||
Object.defineProperty(Navigator.prototype, "vibrate", {
|
||||
configurable: true,
|
||||
value: (pattern) => {
|
||||
window.__vibrations.push(String(pattern));
|
||||
return true;
|
||||
},
|
||||
});
|
||||
if (saved !== null && localStorage.getItem("ustagammon.settings") === null) {
|
||||
localStorage.setItem("ustagammon.settings", saved);
|
||||
}
|
||||
}, settings === null ? null : JSON.stringify(settings));
|
||||
const page = await context.newPage();
|
||||
page.on("pageerror", (error) => problems.push(`erreur de la page : ${error.message}`));
|
||||
page.on("console", (message) => {
|
||||
if (message.type() === "error") {
|
||||
problems.push(`console : ${message.text()}`);
|
||||
}
|
||||
});
|
||||
page.on("dialog", (dialog) => {
|
||||
problems.push(`boîte native : ${dialog.type()}`);
|
||||
void dialog.dismiss();
|
||||
});
|
||||
await page.goto(url);
|
||||
return { context, page };
|
||||
}
|
||||
|
||||
/** Attend que test (évalué dans la page) soit vrai, au plus ms. (Pas de
|
||||
* waitForFunction : Playwright y passe par eval, que la CSP interdit.) */
|
||||
async function until(page, what, test, arg, ms = 30_000) {
|
||||
let last = null;
|
||||
for (const end = Date.now() + ms; Date.now() < end; ) {
|
||||
last = await page.evaluate(test, arg).catch((error) => `page illisible : ${error.message}`);
|
||||
if (last === true) {
|
||||
return;
|
||||
}
|
||||
await sleep(100);
|
||||
}
|
||||
await page.screenshot({ path: `${shots}/${browserName}-accueil-echec.png` }).catch(() => {});
|
||||
throw new Error(`${what} : attente dépassée (${JSON.stringify(last)})`);
|
||||
}
|
||||
|
||||
/** Range une capture de l'écran courant dans chaque taille (Chromium). */
|
||||
async function capture(page, name, settle = async () => {}, sizes = SIZES) {
|
||||
if (!full) {
|
||||
return;
|
||||
}
|
||||
for (const [width, height] of sizes) {
|
||||
await page.setViewportSize({ width, height });
|
||||
await sleep(250);
|
||||
await settle();
|
||||
await page.screenshot({ path: `${dir}/${name}-${width}x${height}.png`, fullPage: true });
|
||||
captures++;
|
||||
}
|
||||
await page.setViewportSize({ width: 412, height: 915 });
|
||||
await sleep(150);
|
||||
}
|
||||
|
||||
/** Vérifie la CSP et ferme le contexte. */
|
||||
async function close(context, page) {
|
||||
const csp = await page.evaluate(() => window.__csp).catch(() => []);
|
||||
check(csp.length === 0, `violations de la CSP : ${csp.join(", ")}`);
|
||||
await context.close();
|
||||
}
|
||||
|
||||
// ------------------------------------------------- Premier lancement
|
||||
|
||||
/** L'écran du tutoriel prêt : plateau dessiné pour cette adresse, coup
|
||||
* illustré joué (« Revoir » utilisable) s'il y en a un. */
|
||||
async function tutorialReady(page, hash) {
|
||||
await until(page, `tutoriel ${hash}`, (hash) => {
|
||||
const section = document.getElementById("tutorial");
|
||||
const replay = document.getElementById("tutorial-replay");
|
||||
return location.hash === hash && section.dataset.ready !== undefined &&
|
||||
document.querySelectorAll("#tutorial-board polygon[data-point]").length === 24 &&
|
||||
(replay.hidden || !replay.disabled);
|
||||
}, hash);
|
||||
}
|
||||
|
||||
/** Recette du tutoriel dans la taille courante : numéros des flèches de
|
||||
* 12 px au moins ; en paysage, rien ne défile et les boutons sont en vue. */
|
||||
async function checkTutorialView(page, name) {
|
||||
const view = await page.evaluate(() => {
|
||||
const svg = document.getElementById("tutorial-board");
|
||||
const box = svg.getBoundingClientRect();
|
||||
const vb = svg.viewBox.baseVal;
|
||||
const scale = Math.min(box.width / vb.width, box.height / vb.height);
|
||||
const numbers = [...svg.querySelectorAll(".point-number")]
|
||||
.map((t) => Number(t.getAttribute("font-size")) * scale);
|
||||
const buttons = ["tutorial-back", "tutorial-next", "tutorial-drop", "tutorial-take"]
|
||||
.map((id) => document.getElementById(id))
|
||||
.filter((el) => el !== null && el.closest("[hidden]") === null)
|
||||
.map((el) => {
|
||||
const r = el.getBoundingClientRect();
|
||||
return { id: el.id, top: r.top, bottom: r.bottom };
|
||||
});
|
||||
return {
|
||||
landscape: document.documentElement.classList.contains("landscape"),
|
||||
minNumber: numbers.length === 0 ? 0 : Math.min(...numbers),
|
||||
scroll: document.scrollingElement.scrollHeight - innerHeight,
|
||||
body: (() => {
|
||||
const b = document.querySelector("#tutorial .tutorial-body");
|
||||
return b.scrollHeight - b.clientHeight;
|
||||
})(),
|
||||
height: innerHeight,
|
||||
buttons,
|
||||
};
|
||||
});
|
||||
const size = `${name} en ${await page.evaluate(() => `${innerWidth}x${innerHeight}`)}`;
|
||||
check(view.minNumber >= 12, `${size} : numéros des flèches de ${view.minNumber.toFixed(1)} px`);
|
||||
if (view.landscape) {
|
||||
check(view.scroll <= 1, `${size} : la page défile de ${view.scroll} px`);
|
||||
check(view.body <= 1, `${size} : le texte défile de ${view.body} px`);
|
||||
if (view.body > tutorialBodyScroll.px) {
|
||||
tutorialBodyScroll = { px: view.body, where: size };
|
||||
}
|
||||
for (const b of view.buttons) {
|
||||
check(b.top >= 0 && b.bottom <= view.height + 0.5,
|
||||
`${size} : « ${b.id} » hors de l'écran (${b.top}-${b.bottom} pour ${view.height})`);
|
||||
}
|
||||
}
|
||||
tutorialViews++;
|
||||
tutorialMinNumber = Math.min(tutorialMinNumber, view.minNumber);
|
||||
}
|
||||
let tutorialViews = 0;
|
||||
let tutorialBodyScroll = { px: 0, where: "" };
|
||||
let tutorialMinNumber = Infinity;
|
||||
|
||||
/** Capture d'un écran du tutoriel, recette comprise, dans les trois tailles. */
|
||||
async function tutorialCapture(page, name) {
|
||||
await capture(page, name, () => checkTutorialView(page, name), TUTORIAL_SIZES);
|
||||
}
|
||||
|
||||
/** Touche une case du plateau du tutoriel. */
|
||||
async function touch(page, place) {
|
||||
await page.click(`#tutorial-board [data-place="${place}"]`);
|
||||
await sleep(200);
|
||||
}
|
||||
|
||||
async function verdict(page) {
|
||||
await until(page, "verdict", () => {
|
||||
const v = document.getElementById("tutorial-feedback").dataset.verdict;
|
||||
return v === "right" || v === "wrong";
|
||||
});
|
||||
return page.evaluate(() => document.getElementById("tutorial-feedback").dataset.verdict);
|
||||
}
|
||||
|
||||
/** Les gestes des écrans « à vous » : un coup refusé, puis le bon. */
|
||||
const PRACTICE = {
|
||||
// 24/21 : ne peut plus mener à 8/5 6/5, refusé tout de suite.
|
||||
3: { wrong: [["p24", "p21"]], right: [["p8", "p5"], ["p6"]] },
|
||||
// 24/23 puis 24/18 : un coup complet qui ne frappe pas.
|
||||
4: { wrong: [["p24", "p23"], ["p24"]], right: [["p13"], ["p8"]] },
|
||||
// 6/3 : ne sort pas deux pions.
|
||||
6: { wrong: [["p6", "p3"]], right: [["p6", "off-player"], ["p3"]] },
|
||||
};
|
||||
|
||||
async function playTouches(page, touches) {
|
||||
for (const group of touches) {
|
||||
for (const place of group) {
|
||||
await touch(page, place);
|
||||
}
|
||||
await until(page, "demi-coup joué", () => !document.querySelector("#tutorial[data-busy]"));
|
||||
await sleep(150);
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
const { context, page } = await newPage();
|
||||
await until(page, "premier lancement", () =>
|
||||
location.hash === "#/accueil" && !document.getElementById("home-welcome").hidden);
|
||||
const first = await page.evaluate(() => ({
|
||||
topbar: getComputedStyle(document.querySelector(".topbar")).display,
|
||||
links: [...document.querySelectorAll("#home-welcome a.choice")].map((a) => a.getAttribute("href")),
|
||||
main: document.getElementById("home-main").hidden,
|
||||
}));
|
||||
check(first.topbar === "none" && first.main, `premier lancement : ${JSON.stringify(first)}`);
|
||||
check(JSON.stringify(first.links) === '["#/tutoriel/1","#/"]', `choix : ${first.links}`);
|
||||
await capture(page, "01-premier-lancement");
|
||||
log("premier lancement : « Je découvre » et « Je connais les règles », de vrais liens");
|
||||
|
||||
// Le tutoriel complet, par ses liens « Continuer ».
|
||||
await page.click("#welcome-discover");
|
||||
let screens = 0;
|
||||
for (let step = 1; step <= 8; step++) {
|
||||
const hash = `#/tutoriel/${step}`;
|
||||
await tutorialReady(page, hash);
|
||||
// La barre ne mène qu'aux étapes déjà vues.
|
||||
const links = await page.evaluate(() =>
|
||||
[...document.querySelectorAll("#tutorial-progress a")].map((a) => a.getAttribute("href")));
|
||||
const expected = Array.from({ length: step - 1 }, (_, i) => `#/tutoriel/${i + 1}`);
|
||||
check(JSON.stringify(links) === JSON.stringify(expected),
|
||||
`étape ${step} : barre de progression ${JSON.stringify(links)}`);
|
||||
const illustrated = await page.evaluate(() => !document.getElementById("tutorial-replay").hidden);
|
||||
if (illustrated) {
|
||||
check(await page.locator("#tutorial-board .trace-arrow").count() > 0,
|
||||
`étape ${step} : le coup illustré n'a pas laissé de traces`);
|
||||
// « Revoir » rejoue le coup.
|
||||
await page.click("#tutorial-replay");
|
||||
await tutorialReady(page, hash);
|
||||
}
|
||||
if (step === 2) {
|
||||
check(await page.locator("#tutorial-board .tutorial-path").count() === 1,
|
||||
"étape 2 : pas de chemin en pointillés");
|
||||
// Sous les pions et sous les numéros des flèches.
|
||||
const order = await page.evaluate(() => {
|
||||
const children = [...document.getElementById("tutorial-board").children];
|
||||
const at = (selector) => children.findIndex((c) => c.matches(selector));
|
||||
const numbers = children.filter((c) => c.matches(".point-number")).map((c) => children.indexOf(c));
|
||||
return { path: at(".tutorial-path"), pieces: at("g.pieces"), firstNumber: Math.min(...numbers) };
|
||||
});
|
||||
check(order.path >= 0 && order.path < order.pieces && order.path < order.firstNumber,
|
||||
`étape 2 : le chemin passe au-dessus des pions ou des numéros ${JSON.stringify(order)}`);
|
||||
}
|
||||
await tutorialCapture(page, `tutoriel-${step}`);
|
||||
screens++;
|
||||
await page.click("#tutorial-next");
|
||||
const practice = await page.evaluate(() => location.hash.endsWith("/a-vous"));
|
||||
if (!practice) {
|
||||
continue;
|
||||
}
|
||||
const avous = `${hash}/a-vous`;
|
||||
await tutorialReady(page, avous);
|
||||
check(await page.evaluate(() => document.getElementById("tutorial-next").classList.contains("secondary")),
|
||||
`étape ${step}, à vous : « Continuer » principal avant le geste`);
|
||||
await tutorialCapture(page, `tutoriel-${step}-a-vous`);
|
||||
if (step === 8) {
|
||||
await page.click("#tutorial-drop");
|
||||
check(await verdict(page) === "wrong", "étape 8 : « Passer » accepté");
|
||||
await tutorialCapture(page, "tutoriel-8-a-vous-refuse");
|
||||
await page.click("#tutorial-take");
|
||||
await until(page, "étape 8 juste", () =>
|
||||
document.getElementById("tutorial-feedback").dataset.verdict === "right");
|
||||
} else {
|
||||
const moves = PRACTICE[step];
|
||||
await playTouches(page, moves.wrong);
|
||||
check(await verdict(page) === "wrong", `étape ${step} : coup faux accepté`);
|
||||
// Le pion revient : de nouveau des pions jouables.
|
||||
await until(page, "pion revenu", () => document.querySelectorAll("#tutorial-board .hit.can-move").length > 0);
|
||||
const words = await page.textContent("#tutorial-feedback");
|
||||
check(!/erreur/i.test(words), `étape ${step} : « erreur » dans le refus`);
|
||||
await tutorialCapture(page, `tutoriel-${step}-a-vous-refuse`);
|
||||
await playTouches(page, moves.right);
|
||||
check(await verdict(page) === "right", `étape ${step} : bon coup refusé`);
|
||||
check(await page.locator("#tutorial-board .hit.can-move").count() === 0,
|
||||
`étape ${step} : plateau encore jouable après le bon coup`);
|
||||
}
|
||||
check(!(await page.evaluate(() => document.getElementById("tutorial-next").classList.contains("secondary"))),
|
||||
`étape ${step} : « Continuer » pas principal après le bon geste`);
|
||||
const vibrated = await page.evaluate(() => window.__vibrations.at(-1));
|
||||
check(vibrated === "12", `étape ${step} : vibration ${vibrated}`);
|
||||
await tutorialCapture(page, `tutoriel-${step}-a-vous-reussi`);
|
||||
screens++;
|
||||
await page.click("#tutorial-next");
|
||||
}
|
||||
await until(page, "fin du tutoriel", () => location.hash === "#/tutoriel/fin" &&
|
||||
document.getElementById("tutorial").dataset.ready !== undefined);
|
||||
await tutorialCapture(page, "tutoriel-fin");
|
||||
// Le retour du téléphone : l'écran d'avant.
|
||||
await page.goBack();
|
||||
await tutorialReady(page, "#/tutoriel/8/a-vous");
|
||||
await page.goForward();
|
||||
await until(page, "fin", () => location.hash === "#/tutoriel/fin");
|
||||
await page.click("#tutorial-next");
|
||||
await until(page, "première partie", () => location.hash === "#/" && window.__shown("game"));
|
||||
log(`tutoriel : ${screens} écrans, gestes refusés puis justes sur les 4 écrans « à vous », fin` +
|
||||
(full ? ` ; recette sur ${tutorialViews} vues, numéros de ${tutorialMinNumber.toFixed(1)} px au moins, ` +
|
||||
`texte défilant au plus de ${tutorialBodyScroll.px} px${tutorialBodyScroll.px > 0 ? ` (${tutorialBodyScroll.where})` : ""}` : ""));
|
||||
|
||||
// Le choix ne revient plus : l'accueil, ensuite.
|
||||
await page.goto(base);
|
||||
await until(page, "accueil", () => location.hash === "#/accueil" &&
|
||||
document.getElementById("home-welcome").hidden && !document.getElementById("home-main").hidden);
|
||||
await close(context, page);
|
||||
}
|
||||
|
||||
// --------------------------------------------------- Accueil, installation
|
||||
|
||||
{
|
||||
// Une partie terminée : l'invitation à installer apparaît.
|
||||
const settings = { welcomed: true, gamesStarted: 2, gamesFinished: 1, tutorialSeen: 99 };
|
||||
const { context, page } = await newPage(settings);
|
||||
await until(page, "accueil prêt", () => location.hash === "#/accueil" &&
|
||||
!document.getElementById("home-new").disabled && !document.getElementById("install-card").hidden);
|
||||
const home = await page.evaluate(() => ({
|
||||
resume: document.getElementById("home-resume").getAttribute("href"),
|
||||
primaries: [...document.querySelectorAll("#home a.button:not(.secondary), #home button:not(.secondary):not(.icon-button)")]
|
||||
.filter((el) => !el.closest("[hidden]")).length,
|
||||
rules: document.getElementById("home-rules").getAttribute("href"),
|
||||
}));
|
||||
check(home.resume === "#/" && home.primaries === 1 && home.rules === "#/tutoriel/1",
|
||||
`accueil : ${JSON.stringify(home)}`);
|
||||
await capture(page, "02-accueil");
|
||||
await capture(page, "03-carte-installation", async () => {
|
||||
await page.locator("#install-card").scrollIntoViewIfNeeded();
|
||||
});
|
||||
// Sans invite du navigateur : la marche à suivre, une feuille avec son
|
||||
// adresse, que le retour referme.
|
||||
await page.click("#install-card-button");
|
||||
await until(page, "feuille d'installation", () => location.hash === "#/installer" && window.__shown("install-sheet"));
|
||||
check((await page.locator("#install-steps li").count()) === 3, "marche à suivre sans trois étapes");
|
||||
await capture(page, "04-feuille-installation");
|
||||
await page.goBack();
|
||||
await until(page, "feuille refermée", () => location.hash === "#/accueil" && !window.__shown("install-sheet"));
|
||||
if (browserName === "chromium") {
|
||||
// L'invite de Chrome, simulée : « Installer » l'ouvre.
|
||||
await page.evaluate(() => {
|
||||
window.__prompted = 0;
|
||||
const event = new Event("beforeinstallprompt", { cancelable: true });
|
||||
event.prompt = () => {
|
||||
window.__prompted++;
|
||||
return Promise.resolve();
|
||||
};
|
||||
event.userChoice = Promise.resolve({ outcome: "dismissed" });
|
||||
window.dispatchEvent(event);
|
||||
});
|
||||
await page.click("#install-card-button");
|
||||
await until(page, "invite du navigateur", () => window.__prompted === 1);
|
||||
check(await page.evaluate(() => location.hash) === "#/accueil", "l'invite a ouvert la feuille");
|
||||
// Installée : plus aucune proposition.
|
||||
await page.evaluate(() => window.dispatchEvent(new Event("appinstalled")));
|
||||
await until(page, "installée", () => document.getElementById("install-card").hidden &&
|
||||
document.getElementById("menu-install").hidden);
|
||||
log("installation : invite de Chrome simulée, puis plus rien une fois installée");
|
||||
} else {
|
||||
await page.click("#install-later");
|
||||
await until(page, "plus tard", () => document.getElementById("install-card").hidden);
|
||||
log("installation : marche à suivre, « Plus tard »");
|
||||
}
|
||||
await close(context, page);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------- Aide
|
||||
|
||||
{
|
||||
const { context, page } = await newPage({ welcomed: true, gamesStarted: 2 }, `${base}#/aide`);
|
||||
await until(page, "aide", () => window.__shown("help-play") && document.getElementById("help-content").dataset.loaded === "true");
|
||||
const help = await page.evaluate(() => ({
|
||||
steps: document.querySelectorAll("#help-play .steps, #help-play li").length,
|
||||
questions: [...document.querySelectorAll("#help-play .question h3")].map((h) => h.textContent),
|
||||
tutorial: document.getElementById("help-tutorial")?.getAttribute("href"),
|
||||
}));
|
||||
check(help.steps === 0, "aide : les étapes Gherkin sont affichées");
|
||||
check(help.questions.length >= 25 && help.tutorial === "#/tutoriel/1", `aide : ${JSON.stringify(help)}`);
|
||||
check(help.questions.filter((q) => q.endsWith("?")).length >= 20, "aide : trop peu de questions");
|
||||
await capture(page, "05-aide-jouer");
|
||||
const question = await page.evaluate(() =>
|
||||
[...document.querySelectorAll("#help-play .question")].find((q) => q.querySelector(".see-board"))?.id);
|
||||
await page.click(`#${question} h3 a`);
|
||||
await until(page, "question ouverte", (id) => document.getElementById(id).classList.contains("open"), question);
|
||||
await capture(page, "06-aide-question");
|
||||
// Le retour du téléphone referme la question.
|
||||
await page.goBack();
|
||||
await until(page, "question refermée", () => location.hash === "#/aide" && !document.querySelector(".question.open"));
|
||||
await page.click('#help-tabs a[data-screen="glossary"]');
|
||||
await until(page, "glossaire", () => window.__shown("help-glossary"));
|
||||
const before = await page.locator("#help-glossary .term-card:not([hidden])").count();
|
||||
await page.fill("#glossary-search", "sortir");
|
||||
await sleep(200);
|
||||
const after = await page.locator("#help-glossary .term-card:not([hidden])").count();
|
||||
check(after > 0 && after < before, `recherche : ${after} fiches sur ${before}`);
|
||||
await capture(page, "07-aide-glossaire-recherche");
|
||||
await page.click('#help-tabs a[data-screen="about"]');
|
||||
await until(page, "à propos", () => window.__shown("help-about"));
|
||||
const version = await page.textContent("#about-version");
|
||||
check(/^Version publiée : [0-9a-f]{12}\.$/.test(version), `version : « ${version} »`);
|
||||
await capture(page, "08-aide-a-propos");
|
||||
log(`aide : ${help.questions.length} questions, recherche dans le glossaire (${after}/${before}), ${version}`);
|
||||
await close(context, page);
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------- Mise à jour
|
||||
|
||||
if (browserName === "chromium") {
|
||||
const { context, page } = await newPage({ welcomed: true, gamesStarted: 2 }, `${updateBase}#/`);
|
||||
await until(page, "partie prête", () => !document.body.classList.contains("busy"), undefined, 60_000);
|
||||
// Le service worker installé et aux commandes (première installation :
|
||||
// sans attendre l'accord du joueur).
|
||||
await until(page, "service worker", async () => {
|
||||
const reg = await navigator.serviceWorker.getRegistration();
|
||||
return reg?.active?.state === "activated" && navigator.serviceWorker.controller !== null;
|
||||
}, undefined, 120_000);
|
||||
const journal = () => page.evaluate(() => localStorage.length >= 0 &&
|
||||
new Promise((resolve) => {
|
||||
const open = indexedDB.open("ustagammon");
|
||||
open.onsuccess = () => {
|
||||
const db = open.result;
|
||||
const tx = db.transaction(["settings"]);
|
||||
const current = tx.objectStore("settings").get("current");
|
||||
current.onsuccess = () => {
|
||||
db.close();
|
||||
resolve(current.result?.value ?? null);
|
||||
};
|
||||
};
|
||||
open.onerror = () => resolve(null);
|
||||
}));
|
||||
check(await page.isHidden("#update-banner"), "bandeau de mise à jour sans nouvelle version");
|
||||
// Si vous avez la main (jet d'ouverture), le bandeau attend la fin du
|
||||
// tour : on publie la nouvelle version d'abord, puis on joue le coup.
|
||||
const toPlay = () => page.evaluate(() => {
|
||||
if (document.body.classList.contains("busy")) {
|
||||
return "busy";
|
||||
}
|
||||
const usable = (id) => {
|
||||
const b = document.getElementById(id);
|
||||
return b !== null && !b.hidden && !b.disabled;
|
||||
};
|
||||
for (const id of ["roll", "again", "confirm", "skip", "take"]) {
|
||||
if (usable(id)) {
|
||||
return id;
|
||||
}
|
||||
}
|
||||
if (document.querySelector("#board .hit.can-land")) {
|
||||
return "land";
|
||||
}
|
||||
return document.querySelector("#board .hit.can-move") ? "move" : "busy";
|
||||
});
|
||||
// Une deuxième version publiée : autre version.json, autre sw.js.
|
||||
writeFileSync(`${updateDir}/version.json`, '{"commit": "0123456789abcdef0123", "dirty": false}\n');
|
||||
const sw = readFileSync(`${updateDir}/sw.js`, "utf8");
|
||||
writeFileSync(`${updateDir}/sw.js`, sw.replace(/^const VERSION = "[0-9a-f]+";$/m, 'const VERSION = "0000000000000002";'));
|
||||
await page.click("#menu-button");
|
||||
await page.waitForSelector("#menu", { state: "visible" });
|
||||
await page.click("#menu-update");
|
||||
await until(page, "nouvelle version trouvée", () =>
|
||||
document.getElementById("update-text")?.textContent !== "Recherche d'une mise à jour…", undefined, 120_000);
|
||||
// Pendant votre coup, jamais de bandeau : seulement entre deux tours.
|
||||
let waited = false;
|
||||
for (let i = 0; i < 40; i++) {
|
||||
const todo = await toPlay();
|
||||
if (todo === "roll" || todo === "again") {
|
||||
break;
|
||||
}
|
||||
if (todo === "busy") {
|
||||
await sleep(100);
|
||||
continue;
|
||||
}
|
||||
if (todo === "move" || todo === "land" || todo === "confirm") {
|
||||
await sleep(400);
|
||||
check(await page.isHidden("#update-banner"), `bandeau de mise à jour pendant votre coup (${todo})`);
|
||||
waited = true;
|
||||
}
|
||||
await page.click(todo === "move" ? "#board .hit.can-move" : todo === "land" ? "#board .hit.can-land" : `#${todo}`);
|
||||
await sleep(200);
|
||||
}
|
||||
await until(page, "bandeau de mise à jour", () => window.__shown("update-banner") && window.__shown("update-apply"), undefined, 120_000);
|
||||
check((await page.textContent("#update-text")) === "Nouvelle version prête.",
|
||||
`bandeau : « ${await page.textContent("#update-text")} »`);
|
||||
// En bas, au-dessus de la barre d'actions, comme « Reprendre l'ancienne ».
|
||||
const place = await page.evaluate(() => {
|
||||
const banner = document.getElementById("update-banner").getBoundingClientRect();
|
||||
const actions = document.getElementById("actions").getBoundingClientRect();
|
||||
const top = document.querySelector(".topbar").getBoundingClientRect();
|
||||
return { bottom: banner.bottom, top: banner.top, actions: actions.top, bar: top.bottom };
|
||||
});
|
||||
check(place.bottom <= place.actions && place.top > place.bar,
|
||||
`bandeau de mise à jour mal placé : ${JSON.stringify(place)}`);
|
||||
const gameBefore = await journal();
|
||||
await capture(page, "09-mise-a-jour");
|
||||
await Promise.all([page.waitForEvent("load", { timeout: 60_000 }), page.click("#update-apply")]);
|
||||
await until(page, "partie reprise", () => location.hash === "#/" && !document.body.classList.contains("busy"), undefined, 60_000);
|
||||
check(await journal() === gameBefore, "après la mise à jour, ce n'est plus la même partie");
|
||||
await page.goto(`${updateBase}#/a-propos`);
|
||||
await until(page, "nouvelle version", () =>
|
||||
document.getElementById("about-version")?.textContent === "Version publiée : 0123456789ab.");
|
||||
log(`mise à jour : deuxième version publiée, bandeau ${waited ? "retenu pendant votre coup, puis " : ""}entre deux tours, « Mettre à jour », partie retrouvée, nouvelle version`);
|
||||
// Rien de neuf : « Vous avez la dernière version ».
|
||||
await page.goto(`${updateBase}#/`);
|
||||
await until(page, "partie", () => !document.body.classList.contains("busy"), undefined, 60_000);
|
||||
await page.click("#menu-button");
|
||||
await page.waitForSelector("#menu", { state: "visible" });
|
||||
await page.click("#menu-update");
|
||||
await until(page, "dernière version", () =>
|
||||
document.getElementById("update-text").textContent === "Vous avez la dernière version.", undefined, 60_000);
|
||||
await close(context, page);
|
||||
}
|
||||
|
||||
await browser.close();
|
||||
if (problems.length > 0) {
|
||||
console.error(problems.join("\n"));
|
||||
process.exit(1);
|
||||
}
|
||||
log(`aucune erreur dans la page${full ? ` ; ${captures} captures dans accueil-tutoriel/` : ""}`);
|
||||
+33
-8
@@ -677,6 +677,9 @@ async function screenOf(tab) {
|
||||
term: document.querySelector("#help-glossary dt.current")?.id ?? null,
|
||||
menu: shown("menu"),
|
||||
games: shown("games"),
|
||||
home: shown("home"),
|
||||
tutorial: shown("tutorial"),
|
||||
install: shown("install-sheet"),
|
||||
listed: document.querySelectorAll("#games .games-item").length,
|
||||
expanded:
|
||||
document
|
||||
@@ -747,17 +750,19 @@ async function checkAddresses() {
|
||||
let question = null;
|
||||
let gameId = null;
|
||||
let gamesBefore = 0;
|
||||
const notYet = (s) => s.hash === "#/" && s.game && s.info.includes("n'existe pas encore");
|
||||
const addresses = [
|
||||
["#/", playing],
|
||||
["#/accueil", notYet],
|
||||
["#/accueil", (s) => s.home && !s.game && !s.tutorial],
|
||||
["#/aide", (s) => s.help && s.play && !s.glossary && !s.about && s.question === null],
|
||||
[() => `#/aide/${question}`, (s) => s.help && s.play && s.question === `aide-${question}`],
|
||||
["#/glossaire", (s) => s.help && s.glossary && !s.play && s.term === null],
|
||||
["#/glossaire/lien-de-position", (s) => s.help && s.glossary && s.term === "glossaire-lien-de-position"],
|
||||
["#/a-propos", (s) => s.help && s.about && !s.play && !s.glossary],
|
||||
["#/tutoriel", notYet],
|
||||
["#/tutoriel/2", notYet],
|
||||
["#/tutoriel", (s) => s.hash === "#/tutoriel/1" && s.tutorial && !s.game],
|
||||
["#/tutoriel/2", (s) => s.tutorial && !s.home],
|
||||
["#/tutoriel/3/a-vous", (s) => s.tutorial && s.hash === "#/tutoriel/3/a-vous"],
|
||||
["#/tutoriel/fin", (s) => s.tutorial && s.hash === "#/tutoriel/fin"],
|
||||
["#/installer", (s) => s.install && s.hash === "#/installer"],
|
||||
["#/parties", (s) => s.games && s.listed >= 2 && !s.menu],
|
||||
[() => `#/parties/${gameId}`, (s) => s.games && s.expanded === gameId],
|
||||
["#/menu", (s) => s.menu && !s.games],
|
||||
@@ -810,7 +815,7 @@ async function checkAddresses() {
|
||||
const seen = await tab.evaluate(() => {
|
||||
const bar = document.getElementById("help-bar").getBoundingClientRect();
|
||||
const target = document.querySelector(
|
||||
"#help-play .question.open, #help-glossary dt.current",
|
||||
"#help-play .question.open, #help-glossary .term-card.current",
|
||||
).getBoundingClientRect();
|
||||
return { scrolled: window.scrollY, barTop: bar.top, barBottom: bar.bottom, top: target.top };
|
||||
});
|
||||
@@ -875,8 +880,26 @@ async function checkAddresses() {
|
||||
check((await tab.locator(selector).and(tab.getByRole("link")).count()) === 0,
|
||||
`${selector} : une action de jeu exposée comme un lien`);
|
||||
}
|
||||
await tab.click("#help-button");
|
||||
await expectScreen(tab, "aide par son lien", (s) => s.hash === "#/aide" && s.help && s.play);
|
||||
// Le bouton « Aide » ouvre la question du moment de la partie
|
||||
// (help-context.ts) ; ici, l'onglet « Jouer » sans question.
|
||||
// L'adresse est lue au moment du toucher : la partie qui vient de
|
||||
// s'ouvrir peut encore changer de moment (jet d'ouverture).
|
||||
const helpHref = await tab.evaluate(() => {
|
||||
const button = document.getElementById("help-button");
|
||||
const href = button.getAttribute("href");
|
||||
button.click();
|
||||
return href;
|
||||
});
|
||||
check(/^#\/aide(\/[a-z0-9-]+)?$/.test(helpHref), `bouton Aide : ${helpHref}`);
|
||||
await expectScreen(tab, "aide par son lien", (s) => s.hash === helpHref && s.help && s.play);
|
||||
if (helpHref !== "#/aide") {
|
||||
await tab.goBack();
|
||||
await expectScreen(tab, "retour à la partie", playing);
|
||||
await tab.evaluate(() => {
|
||||
location.hash = "#/aide";
|
||||
});
|
||||
await expectScreen(tab, "aide", (s) => s.hash === "#/aide" && s.help && s.play);
|
||||
}
|
||||
for (const selector of ["#help-back", "#help-tabs a", "#help-play .question h3 a"]) {
|
||||
check(await isLink(tab, selector), `${selector} : pas des liens`);
|
||||
}
|
||||
@@ -1018,7 +1041,9 @@ await context.route("**/wasm/engine.data", async (route) => {
|
||||
await route.continue();
|
||||
});
|
||||
const start = Date.now();
|
||||
await page.goto(base);
|
||||
// « #/ » : la partie ; sans adresse d'écran, l'application s'ouvrirait sur
|
||||
// l'accueil (tests/browser/accueil.mjs).
|
||||
await page.goto(`${base}#/`);
|
||||
await page.waitForSelector("#board polygon[data-point]");
|
||||
{
|
||||
const loading = await page.evaluate(() => ({
|
||||
|
||||
@@ -83,6 +83,15 @@ check "docs/glossaire/README.md" "text/markdown"
|
||||
check "docs/glossaire/videau.md" "text/markdown"
|
||||
cmp -s <(curl -s "$base/docs/glossaire/videau.md") docs/glossaire/videau.md \
|
||||
|| report "/docs/glossaire/videau.md diffère de la source"
|
||||
# Tutoriel : copie de sa source unique (ADR 0008).
|
||||
check "docs/tutoriel/regles.md" "text/markdown"
|
||||
cmp -s <(curl -s "$base/docs/tutoriel/regles.md") docs/tutoriel/regles.md \
|
||||
|| report "/docs/tutoriel/regles.md diffère de la source"
|
||||
for feature in apprendre-les-regles decouvrir-l-application installer-l-application; do
|
||||
check "docs/scenarios/$feature.feature" "text/plain"
|
||||
done
|
||||
# Le service worker attend l'accord du joueur avant de prendre la main.
|
||||
grep -q 'skip-waiting' build/site/sw.js || report "sw.js : pas d'attente de l'accord du joueur"
|
||||
|
||||
# Le manifeste désigne des icônes servies, et l'application à la racine.
|
||||
manifest="$(curl -s "$base/manifest.webmanifest")"
|
||||
|
||||
+720
-2
@@ -1293,7 +1293,7 @@ html.rotated .banner {
|
||||
box-shadow: var(--shadow-raised);
|
||||
}
|
||||
|
||||
.glossary dt {
|
||||
.glossary .term-card {
|
||||
padding-top: var(--space-3);
|
||||
font-weight: 700;
|
||||
}
|
||||
@@ -1337,7 +1337,7 @@ html.rotated .banner {
|
||||
}
|
||||
|
||||
.help .question,
|
||||
.glossary dt {
|
||||
.glossary .term-card {
|
||||
scroll-margin-top: calc(var(--help-bar-height) + 12px + env(safe-area-inset-top));
|
||||
}
|
||||
|
||||
@@ -1542,6 +1542,724 @@ a.link-button {
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
/* ------------------------------------------- Accueil, tutoriel, aide */
|
||||
|
||||
/* Maquettes « Étape 0e.5 » et « Accueil » ; règles :
|
||||
* docs/identite/charte/accueil-tutoriel-aide.md. */
|
||||
|
||||
.brand-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
/* Le tutoriel et le premier lancement ont leur propre en-tête. */
|
||||
html.bare .topbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.home,
|
||||
.tutorial {
|
||||
max-width: var(--page-width);
|
||||
margin: 0 auto;
|
||||
padding-bottom: var(--space-6);
|
||||
}
|
||||
|
||||
.overline {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
line-height: 16px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.caption {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
line-height: 18px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.card {
|
||||
display: block;
|
||||
padding: var(--space-4);
|
||||
color: var(--text);
|
||||
text-decoration: none;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: var(--shadow-raised);
|
||||
}
|
||||
|
||||
/* Une étiquette d'action dans une carte-lien, en contour. */
|
||||
.pill {
|
||||
display: inline-flex;
|
||||
flex: none;
|
||||
align-items: center;
|
||||
min-height: 44px;
|
||||
padding: 0 16px;
|
||||
font-weight: 700;
|
||||
color: var(--text);
|
||||
border: 2px solid var(--border-strong);
|
||||
border-radius: var(--radius-pill);
|
||||
}
|
||||
|
||||
/* Premier lancement. */
|
||||
.welcome {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-6);
|
||||
min-height: 100vh;
|
||||
min-height: 100dvh;
|
||||
padding: var(--space-6) 0 var(--space-6);
|
||||
}
|
||||
|
||||
.welcome-logo {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.welcome-logo img {
|
||||
height: 34px;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.welcome-board {
|
||||
margin-inline: calc(-1 * var(--space-2));
|
||||
width: calc(100% + 2 * var(--space-2));
|
||||
}
|
||||
|
||||
.welcome-text h2,
|
||||
.home-intro h2 {
|
||||
margin: 0 0 var(--space-2);
|
||||
font-size: 32px;
|
||||
line-height: 36px;
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.welcome-text p,
|
||||
.home-intro p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.welcome-choices {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.choice {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
min-height: 64px;
|
||||
padding: 10px 20px;
|
||||
color: var(--text);
|
||||
text-decoration: none;
|
||||
border: 2px solid var(--border-strong);
|
||||
border-radius: 16px;
|
||||
touch-action: manipulation;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
.choice span {
|
||||
font-size: 15px;
|
||||
line-height: 20px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.choice.primary {
|
||||
color: var(--on-accent);
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.choice.primary span {
|
||||
color: var(--on-accent);
|
||||
}
|
||||
|
||||
.choice:active {
|
||||
transform: translateY(1px);
|
||||
filter: brightness(0.94);
|
||||
}
|
||||
|
||||
.welcome-choices .caption {
|
||||
margin-top: var(--space-1);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Accueil. */
|
||||
.home-main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
padding-top: var(--space-2);
|
||||
}
|
||||
|
||||
.home-game {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.home-game-top {
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.home-mini {
|
||||
flex: none;
|
||||
width: 132px;
|
||||
}
|
||||
|
||||
/* La miniature ne porte pas les numéros des flèches, illisibles à cette
|
||||
* taille. */
|
||||
.home-board .point-number {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.home-facts {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.home-pips {
|
||||
font-size: 15px;
|
||||
line-height: 20px;
|
||||
color: var(--muted);
|
||||
font-variant-numeric: tabular-nums lining-nums;
|
||||
}
|
||||
|
||||
.home-actions {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.home-actions > * {
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
padding: 0 12px;
|
||||
}
|
||||
|
||||
.install-card {
|
||||
display: flex;
|
||||
gap: var(--space-3);
|
||||
align-items: center;
|
||||
margin-top: calc(-1 * var(--space-2));
|
||||
padding: 10px 4px 10px 14px;
|
||||
background: var(--surface-sunken);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.install-card .install-symbol {
|
||||
flex: none;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
.install-text {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
font-size: 15px;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.install-card button.secondary {
|
||||
flex: none;
|
||||
min-height: 44px;
|
||||
padding: 0 14px;
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.install-card .icon-button .icon {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.home-rules {
|
||||
display: flex;
|
||||
gap: var(--space-3);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.home-rules-text {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.home-rules-text .muted {
|
||||
font-size: 15px;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.home-progress:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.home-footer {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
margin-top: var(--space-4);
|
||||
font-size: 13px;
|
||||
line-height: 18px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.home-footer img {
|
||||
flex: none;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
/* Feuille d'installation. */
|
||||
.sheet-head .install-symbol {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
margin-right: var(--space-3);
|
||||
}
|
||||
|
||||
#install-sheet .sheet-title {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.install-lead {
|
||||
margin: var(--space-2) 0 var(--space-4);
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.install-steps {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
margin: 0 0 var(--space-4);
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.install-steps li {
|
||||
display: flex;
|
||||
gap: var(--space-3);
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.step-number {
|
||||
display: flex;
|
||||
flex: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
font-weight: 800;
|
||||
font-variant-numeric: tabular-nums lining-nums;
|
||||
background: var(--accent-soft);
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.install-done {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Tutoriel. */
|
||||
.tutorial-head {
|
||||
display: flex;
|
||||
gap: var(--space-3);
|
||||
align-items: center;
|
||||
min-height: 52px;
|
||||
padding-top: var(--space-2);
|
||||
}
|
||||
|
||||
.tutorial-progress {
|
||||
display: grid;
|
||||
flex: 1;
|
||||
grid-template-columns: repeat(8, minmax(0, 1fr));
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
/* Un segment : 4 px de trait, dans une zone de 44 px de haut quand c'est
|
||||
* un lien vers une étape déjà vue. */
|
||||
.tutorial-progress .segment {
|
||||
position: relative;
|
||||
display: block;
|
||||
height: 44px;
|
||||
}
|
||||
|
||||
.tutorial-progress .segment::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 20px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 4px;
|
||||
background: var(--border);
|
||||
border-radius: var(--radius-pill);
|
||||
}
|
||||
|
||||
.tutorial-progress .segment.done::before {
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
.tutorial-progress .segment[aria-current]::before {
|
||||
top: 19px;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
.tutorial-skip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 44px;
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.tutorial-title {
|
||||
margin: var(--space-1) 0 var(--space-3);
|
||||
font-size: 24px;
|
||||
line-height: 30px;
|
||||
}
|
||||
|
||||
.tutorial-board-box {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
@media (max-width: 40rem) {
|
||||
.tutorial-board-box {
|
||||
margin-inline: calc(var(--space-2) - var(--space-4));
|
||||
}
|
||||
}
|
||||
|
||||
#tutorial-replay {
|
||||
position: absolute;
|
||||
right: var(--space-1);
|
||||
bottom: calc(-44px - var(--space-1));
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.tutorial-text {
|
||||
margin-top: var(--space-3);
|
||||
padding-right: 52px;
|
||||
}
|
||||
|
||||
.tutorial-text p {
|
||||
margin: 0 0 10px;
|
||||
}
|
||||
|
||||
.tutorial-task {
|
||||
margin: var(--space-3) 0 0;
|
||||
padding: 10px 14px;
|
||||
font-weight: 700;
|
||||
background: var(--accent-soft);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.tutorial-feedback {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
align-items: flex-start;
|
||||
min-height: 25px;
|
||||
margin: var(--space-3) 0 0;
|
||||
}
|
||||
|
||||
.tutorial-feedback:empty {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.tutorial-feedback[data-verdict="right"] {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.tutorial-feedback[data-verdict="right"] .icon {
|
||||
color: var(--verdict-good);
|
||||
}
|
||||
|
||||
.tutorial-feedback[data-verdict="info"] {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.tutorial-cube {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
margin-top: var(--space-3);
|
||||
}
|
||||
|
||||
.tutorial-cube button {
|
||||
flex: 1 1 0;
|
||||
}
|
||||
|
||||
.tutorial-nav {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1.4fr;
|
||||
gap: var(--space-2);
|
||||
margin-top: var(--space-6);
|
||||
}
|
||||
|
||||
.tutorial-nav .button {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Le sens du jeu : pointillés text sur un liseré ivoire, jamais en brique
|
||||
* (la brique veut dire « à faire »). Couleurs de marque, les mêmes dans les
|
||||
* deux thèmes : le liseré reste lisible sur le bois. */
|
||||
.tutorial-path path {
|
||||
fill: none;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.tutorial-path .path-edge {
|
||||
stroke: var(--brand-ivoire);
|
||||
}
|
||||
|
||||
.tutorial-path .path-dots,
|
||||
.tutorial-path .path-head {
|
||||
stroke: var(--brand-noyer);
|
||||
}
|
||||
|
||||
/* En paysage, comme la position partagée : le plateau à gauche sur toute
|
||||
* la hauteur (avec la colonne gauche du cadre), et à droite une colonne
|
||||
* avec la barre de progression, le titre, le texte, la consigne « à vous »
|
||||
* et les boutons, le tout sans défilement de la page. Si le texte est trop
|
||||
* long pour l'écran, lui seul défile : les boutons restent en vue. */
|
||||
html.landscape .tutorial {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) clamp(288px, 44vw, 400px);
|
||||
grid-template-rows: auto auto minmax(0, 1fr) auto auto;
|
||||
max-width: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
background: var(--background);
|
||||
}
|
||||
|
||||
html.landscape .tutorial > * {
|
||||
grid-column: 2;
|
||||
margin-inline: 0;
|
||||
padding-inline: var(--space-4) calc(var(--space-4) + env(safe-area-inset-right));
|
||||
}
|
||||
|
||||
html.landscape .tutorial .overline {
|
||||
display: none;
|
||||
}
|
||||
|
||||
html.landscape .tutorial-head {
|
||||
grid-row: 1;
|
||||
min-height: 44px;
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
html.landscape .tutorial-title {
|
||||
grid-row: 2;
|
||||
margin: 0 0 var(--space-1);
|
||||
padding-right: calc(44px + var(--space-4));
|
||||
font-size: 20px;
|
||||
line-height: 26px;
|
||||
}
|
||||
|
||||
/* La boîte du plateau s'efface : le plateau et « Revoir » se placent
|
||||
* eux-mêmes dans la grille. */
|
||||
html.landscape .tutorial-board-box {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
html.landscape .tutorial-board {
|
||||
grid-row: 1 / -1;
|
||||
grid-column: 1;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
aspect-ratio: auto;
|
||||
background: var(--frame);
|
||||
}
|
||||
|
||||
html.landscape #tutorial-replay {
|
||||
position: static;
|
||||
grid-row: 2;
|
||||
grid-column: 2;
|
||||
justify-self: end;
|
||||
align-self: start;
|
||||
margin: -9px calc(var(--space-2) + env(safe-area-inset-right)) 0 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
html.landscape .tutorial-body {
|
||||
grid-row: 3;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
font-size: 15px;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
html.landscape .tutorial-text {
|
||||
margin: 0;
|
||||
padding-right: 0;
|
||||
}
|
||||
|
||||
html.landscape .tutorial-text p {
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
html.landscape .tutorial-task {
|
||||
margin: 0;
|
||||
padding: var(--space-2) 12px;
|
||||
}
|
||||
|
||||
html.landscape .tutorial-feedback {
|
||||
min-height: 0;
|
||||
margin: var(--space-2) 0 0;
|
||||
}
|
||||
|
||||
html.landscape .tutorial-cube {
|
||||
grid-row: 4;
|
||||
margin-top: var(--space-2);
|
||||
}
|
||||
|
||||
/* La réponse au double donnée et saluée, les deux boutons n'ont plus
|
||||
* d'usage : en paysage, leur place revient au texte. */
|
||||
html.landscape .tutorial[data-solved] .tutorial-cube {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Écran très bas (844 × 320) : texte un cran plus petit. */
|
||||
@media (max-height: 360px) {
|
||||
html.landscape .tutorial-body {
|
||||
font-size: 14px;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
html.landscape .tutorial-title {
|
||||
font-size: 18px;
|
||||
line-height: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
html.landscape .tutorial-nav {
|
||||
grid-row: 5;
|
||||
margin: 0;
|
||||
padding-block: var(--space-2) calc(var(--space-2) + env(safe-area-inset-bottom));
|
||||
}
|
||||
|
||||
/* Le premier lancement, en paysage : le plateau tient dans la hauteur. */
|
||||
html.landscape .welcome-board {
|
||||
width: min(100%, calc(70vh * 640 / 520));
|
||||
height: auto;
|
||||
margin-inline: auto;
|
||||
aspect-ratio: 640 / 520;
|
||||
}
|
||||
|
||||
html.landscape .welcome {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* Aide. */
|
||||
.help-tutorial {
|
||||
display: flex;
|
||||
gap: var(--space-3);
|
||||
align-items: center;
|
||||
margin-top: var(--space-4);
|
||||
}
|
||||
|
||||
.help-tutorial-text {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.help-tutorial-text .muted {
|
||||
font-size: 15px;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.help-intro {
|
||||
margin-top: var(--space-4);
|
||||
}
|
||||
|
||||
.see-board {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 44px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.glossary-search {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
margin-top: var(--space-3);
|
||||
}
|
||||
|
||||
.search-label {
|
||||
font-size: 15px;
|
||||
line-height: 20px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.glossary-search input {
|
||||
min-height: 48px;
|
||||
padding: 0 var(--space-3);
|
||||
font: inherit;
|
||||
color: var(--text);
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: var(--radius-md);
|
||||
user-select: text;
|
||||
-webkit-user-select: text;
|
||||
}
|
||||
|
||||
.term-card + .term-card {
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.glossary .english {
|
||||
margin-left: var(--space-2);
|
||||
font-size: 13px;
|
||||
font-style: italic;
|
||||
font-weight: 400;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.glossary dd p {
|
||||
margin: 0 0 var(--space-1);
|
||||
}
|
||||
|
||||
.see-also {
|
||||
font-size: 15px;
|
||||
line-height: 20px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.glossary .term-card.current {
|
||||
margin-inline: calc(-1 * var(--space-4));
|
||||
padding-inline: var(--space-4);
|
||||
background: var(--accent-soft);
|
||||
}
|
||||
|
||||
.glossary .term-card.current dt,
|
||||
.glossary .term-card.current dd {
|
||||
margin-inline: 0;
|
||||
padding-inline: 0;
|
||||
background: none;
|
||||
}
|
||||
|
||||
/* En paysage, comme l'écran de jeu : le plateau à gauche sur toute la
|
||||
* hauteur (colonne gauche du cadre avec le videau seul), le titre, le contexte et les actions
|
||||
* dans une colonne à droite. */
|
||||
|
||||
+35
-2
@@ -26,8 +26,10 @@
|
||||
<!-- Logo de la charte (docs/identite/logo/, copié dans icons/ par make
|
||||
site), version sombre en thème sombre (choix par app.css). -->
|
||||
<h1 class="brand">
|
||||
<img class="logo-light" src="icons/logo-horizontal-couleur.svg" alt="Ustagammon">
|
||||
<img class="logo-dark" src="icons/logo-horizontal-sombre.svg" alt="Ustagammon">
|
||||
<a id="home-link" href="#/accueil" class="brand-link">
|
||||
<img class="logo-light" src="icons/logo-horizontal-couleur.svg" alt="Ustagammon">
|
||||
<img class="logo-dark" src="icons/logo-horizontal-sombre.svg" alt="Ustagammon">
|
||||
</a>
|
||||
</h1>
|
||||
<!-- Ce qui mène à un autre écran est un lien vers son adresse (ADR 0013,
|
||||
amendement) ; les actions sont des boutons. -->
|
||||
@@ -82,6 +84,11 @@
|
||||
<noscript><p>Ustagammon a besoin de JavaScript.</p></noscript>
|
||||
</main>
|
||||
|
||||
<!-- L'accueil (#/accueil) et le tutoriel (#/tutoriel/...) : leur contenu
|
||||
est construit par app/src/ui/home-view.ts et tutorial-view.ts. -->
|
||||
<section id="home" class="home page" hidden></section>
|
||||
<section id="tutorial" class="tutorial page" hidden></section>
|
||||
|
||||
<!-- L'aide : un onglet par adresse (#/aide, #/glossaire, #/a-propos). -->
|
||||
<section id="help" class="help page" hidden>
|
||||
<!-- La barre haute de l'aide reste en vue quand la page défile. -->
|
||||
@@ -121,12 +128,31 @@
|
||||
</div>
|
||||
<button id="menu-new-game" type="button" class="item"></button>
|
||||
<a id="menu-games" href="#/parties" class="item"></a>
|
||||
<a id="menu-home" href="#/accueil" class="item"></a>
|
||||
<a id="menu-tutorial" href="#/tutoriel/1" class="item"></a>
|
||||
<button id="menu-copy-link" type="button" class="item"></button>
|
||||
<button id="menu-fullscreen" type="button" class="item"></button>
|
||||
<a id="menu-help" href="#/aide" class="item"></a>
|
||||
<button id="menu-install" type="button" class="item"></button>
|
||||
<button id="menu-theme" type="button" class="item"><span class="item-label"></span><span class="item-value"></span></button>
|
||||
<button id="menu-vibrations" type="button" class="item" role="switch"><span class="item-label"></span><span class="switch" aria-hidden="true"></span></button>
|
||||
<button id="menu-screen-on" type="button" class="item" role="switch"><span class="item-label"></span><span class="switch" aria-hidden="true"></span></button>
|
||||
<button id="menu-update" type="button" class="item"></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- La marche à suivre pour installer (#/installer) : une feuille. -->
|
||||
<div id="install-veil" class="veil sheet-veil" hidden>
|
||||
<div id="install-sheet" class="sheet" role="dialog" aria-modal="true" aria-labelledby="install-title">
|
||||
<div class="sheet-head">
|
||||
<span class="grip" aria-hidden="true"></span>
|
||||
<img class="install-symbol" src="icons/symbole-couleur.svg" alt="">
|
||||
<h2 id="install-title" class="sheet-title"></h2>
|
||||
<button id="install-close" type="button" class="icon-button"></button>
|
||||
</div>
|
||||
<p id="install-lead" class="install-lead"></p>
|
||||
<ol id="install-steps" class="install-steps"></ol>
|
||||
<button id="install-done" type="button" class="secondary install-done"></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -162,6 +188,13 @@
|
||||
<span class="banner-timer" aria-hidden="true"></span>
|
||||
</div>
|
||||
|
||||
<!-- « Nouvelle version prête. » : en bas, comme « Reprendre l'ancienne »,
|
||||
seulement entre deux tours. -->
|
||||
<div id="update-banner" class="banner update-banner" role="status" hidden>
|
||||
<span id="update-text"></span>
|
||||
<button id="update-apply" type="button" class="secondary"></button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -10,9 +10,14 @@
|
||||
//
|
||||
// Mise à jour : à chaque publication, VERSION change, donc ce fichier. Le
|
||||
// navigateur installe alors la nouvelle version, qui télécharge tout dans un
|
||||
// nouveau cache, prend la main tout de suite et efface l'ancien cache. La
|
||||
// page déjà ouverte a chargé tous ses fichiers au démarrage : elle continue
|
||||
// sans rien remarquer, et le prochain lancement utilise la nouvelle version.
|
||||
// nouveau cache, puis attend : la version en place garde la main, et son
|
||||
// cache, tant que le joueur n'a pas accepté la mise à jour. Une page ouverte
|
||||
// continue donc de trouver tous ses fichiers. L'application affiche le
|
||||
// bandeau « Nouvelle version disponible » (app/src/ui/updates.ts) ; « Mettre
|
||||
// à jour » envoie le message « skip-waiting » : la nouvelle version prend
|
||||
// la main, efface l'ancien cache, et la page se recharge. À la toute
|
||||
// première installation, aucune version n'est en place : elle prend la main
|
||||
// sans attendre.
|
||||
|
||||
/* global VERSION, FILES */
|
||||
|
||||
@@ -25,11 +30,17 @@ self.addEventListener("install", (event) => {
|
||||
// no-cache : revalider auprès du serveur, jamais une copie
|
||||
// périmée du cache HTTP du navigateur.
|
||||
await cache.addAll(FILES.map((file) => new Request(file, { cache: "no-cache" })));
|
||||
await self.skipWaiting();
|
||||
})(),
|
||||
);
|
||||
});
|
||||
|
||||
// L'accord du joueur (même nom que SKIP_WAITING dans updates.ts).
|
||||
self.addEventListener("message", (event) => {
|
||||
if (event.data?.type === "skip-waiting") {
|
||||
void self.skipWaiting();
|
||||
}
|
||||
});
|
||||
|
||||
self.addEventListener("activate", (event) => {
|
||||
event.waitUntil(
|
||||
(async () => {
|
||||
|
||||
Reference in New Issue
Block a user