Accueil, tutoriel, aide en questions, installation et mise à jour de l'application
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
3f5b00133d
commit
441bca1bd7
@@ -51,9 +51,10 @@ export async function tutorialBoard(
|
||||
board.result === null
|
||||
? null
|
||||
: {
|
||||
winner: (board.result.winner === "onRoll") === (onRoll === "player")
|
||||
? ("player" as const)
|
||||
: ("computer" as const),
|
||||
winner:
|
||||
(board.result.winner === "onRoll") === (onRoll === "player")
|
||||
? ("player" as const)
|
||||
: ("computer" as const),
|
||||
kind: board.result.kind,
|
||||
};
|
||||
return { sides, result };
|
||||
@@ -126,10 +127,7 @@ export async function illustratedMove(
|
||||
* 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 {
|
||||
export function canStillLeadTo(played: readonly HalfMove[], expected: readonly Move[]): boolean {
|
||||
return expected.some((move) => {
|
||||
const left = [...move.halfMoves];
|
||||
return played.every((h) => {
|
||||
|
||||
+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 {
|
||||
|
||||
+309
-12
@@ -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 { 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,53 @@ 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);
|
||||
const moveInProgress =
|
||||
busy || (game !== null && game.pendingHalfMoves.length > 0 && !ui.game.hidden);
|
||||
if (updateMessage !== null) {
|
||||
ui.updateBanner.hidden = false;
|
||||
ui.updateText.textContent = updateMessage;
|
||||
ui.updateApply.hidden = true;
|
||||
} else {
|
||||
const shown = updateBannerShown(
|
||||
updates?.available ?? false,
|
||||
moveInProgress,
|
||||
!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 +917,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 +1012,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 +1143,7 @@ export function startApp(options: AppOptions): void {
|
||||
},
|
||||
help: () => {
|
||||
closeTransient();
|
||||
navigation.go("#/aide");
|
||||
navigation.go(helpHash(helpMoment()));
|
||||
},
|
||||
fullscreen: () => {
|
||||
closeOverlays();
|
||||
@@ -1037,6 +1162,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 +1202,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 +1253,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 = () => {
|
||||
@@ -1164,14 +1294,41 @@ export function startApp(options: AppOptions): void {
|
||||
function applyRoute(route: Route): void {
|
||||
const help = isHelpScreen(route);
|
||||
const position = route.screen === "position";
|
||||
const home = route.screen === "home";
|
||||
const tutorial = route.screen === "tutorial" || route.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 = route.screen;
|
||||
if ((wasPage && !ui.game.hidden) || (page && wasScreen !== route.screen)) {
|
||||
window.scrollTo(0, 0);
|
||||
}
|
||||
if (tutorial) {
|
||||
void tutorialView.show(route);
|
||||
} 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";
|
||||
@@ -1214,16 +1371,147 @@ export function startApp(options: AppOptions): void {
|
||||
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,
|
||||
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. */
|
||||
@@ -1408,6 +1696,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);
|
||||
@@ -1550,8 +1841,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();
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -231,7 +322,7 @@ export function createHelpView(container: HTMLElement, tabs: HTMLElement): HelpV
|
||||
const opened = questions.find((q) => q.id === question) ?? null;
|
||||
const closed = route.screen === "help" && opened === null ? lastOpen : null;
|
||||
lastOpen = opened;
|
||||
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 disponible.",
|
||||
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: "Passer",
|
||||
tutorialSkipLabel: "Passer 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: "Je prends",
|
||||
drop: "Je passe",
|
||||
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);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
+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,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -121,8 +121,12 @@ function screenOf(raw: RawScreen, where: string): TutorialScreen {
|
||||
/** 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 }[] =
|
||||
[];
|
||||
const steps: {
|
||||
number: number;
|
||||
title: string;
|
||||
explain: RawScreen;
|
||||
practice: RawScreen | null;
|
||||
}[] = [];
|
||||
let end: RawScreen | null = null;
|
||||
let current: RawScreen | null = null;
|
||||
let paragraph: string[] = [];
|
||||
|
||||
@@ -0,0 +1,702 @@
|
||||
// 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'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. */
|
||||
function drawPath(svg: SVGSVGElement, g: Geometry): 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 width = g.pointWidth * 0.22;
|
||||
for (const [className, stroke] of [
|
||||
["path-edge", width * 2.4],
|
||||
["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 ${width * 2.6}`);
|
||||
}
|
||||
group.append(path);
|
||||
}
|
||||
const head = document.createElementNS(SVG_NS, "path");
|
||||
head.setAttribute("class", "path-head");
|
||||
const size = width * 2.2;
|
||||
head.setAttribute(
|
||||
"d",
|
||||
`M ${end - size} ${bottom - size} L ${end} ${bottom} L ${end - size} ${bottom + size}`,
|
||||
);
|
||||
head.setAttribute("stroke-width", String(width * 1.2));
|
||||
group.append(head);
|
||||
// Sous les zones à toucher, toujours dessinées en dernier.
|
||||
svg.insertBefore(group, svg.lastChild);
|
||||
}
|
||||
|
||||
/** 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;
|
||||
const text = element(section, "div", { id: "tutorial-text", className: "tutorial-text" });
|
||||
const task = element(section, "p", { id: "tutorial-task", className: "tutorial-task" });
|
||||
const feedback = element(section, "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;
|
||||
let busy = false;
|
||||
|
||||
// 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;
|
||||
};
|
||||
|
||||
function geometry(): Geometry {
|
||||
const width = svg.clientWidth || 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);
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
busy = 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) {
|
||||
busy = 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;
|
||||
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;
|
||||
busy = 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) {
|
||||
busy = 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;
|
||||
busy = 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 {
|
||||
busy = 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;
|
||||
clearTimer();
|
||||
busy = false;
|
||||
practice = null;
|
||||
illustration = null;
|
||||
illustrated = false;
|
||||
screen = null;
|
||||
boards = null;
|
||||
selected = null;
|
||||
targets = new Map();
|
||||
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();
|
||||
busy = false;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
// 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 le bandeau « Nouvelle
|
||||
// version disponible » ; « 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";
|
||||
|
||||
/**
|
||||
* Le bandeau « Nouvelle version disponible » est-il montré ? Quand une
|
||||
* version attend, jamais pendant un coup en cours (des pions déjà déplacés,
|
||||
* ou l'ordinateur qui joue), ni par-dessus le bandeau « Reprendre
|
||||
* l'ancienne ». Fonction pure, testée sous Node.
|
||||
*/
|
||||
export function updateBannerShown(
|
||||
available: boolean,
|
||||
moveInProgress: boolean,
|
||||
otherBanner: boolean,
|
||||
): boolean {
|
||||
return available && !moveInProgress && !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,84 @@
|
||||
// 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 { test } from "node:test";
|
||||
import { installPlatform } from "../src/ui/install.js";
|
||||
import {
|
||||
DEFAULT_SETTINGS,
|
||||
INSTALL_AGAIN_AFTER,
|
||||
installCardShown,
|
||||
isFirstLaunch,
|
||||
parseSettings,
|
||||
type Settings,
|
||||
} from "../src/ui/settings.js";
|
||||
|
||||
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("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", () => {
|
||||
|
||||
@@ -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 « Je passe »", async () => {
|
||||
answeredRight = (await shouldTake(engine, screen.position)) === false;
|
||||
});
|
||||
|
||||
When("vous répondez « Je prends »", 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 { updateBannerShown } from "../../src/ui/updates.js";
|
||||
import { loadNodeEngine } from "../support/node-engine.js";
|
||||
|
||||
setDefaultTimeout(30_000);
|
||||
@@ -163,3 +164,36 @@ 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 aucun coup n'est en cours de saisie. La partie est
|
||||
// celle du domaine, sur le vrai moteur.
|
||||
let updateAvailable = false;
|
||||
|
||||
Given("une nouvelle version d'Ustagammon est disponible", () => {
|
||||
updateAvailable = true;
|
||||
});
|
||||
|
||||
Given("vous avez déjà déplacé un pion de votre coup", async () => {
|
||||
// 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.ok(await game.playHalfMove({ from: 8, to: 5 }));
|
||||
});
|
||||
|
||||
Then("l'application ne vous propose pas encore de mettre à jour", () => {
|
||||
assert.ok(!updateBannerShown(updateAvailable, game.pendingHalfMoves.length > 0, false));
|
||||
});
|
||||
|
||||
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(updateBannerShown(updateAvailable, game.pendingHalfMoves.length > 0, false));
|
||||
// Jamais par-dessus le bandeau « Reprendre l'ancienne ».
|
||||
assert.ok(!updateBannerShown(updateAvailable, false, true));
|
||||
});
|
||||
|
||||
+46
-13
@@ -36,10 +36,14 @@ test("le texte du tutoriel a huit étapes, une fin, et ses écrans « à vous »
|
||||
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`);
|
||||
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`,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -53,7 +57,10 @@ test("chaque mot du glossaire cité par le tutoriel a sa fiche", () => {
|
||||
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`);
|
||||
assert.ok(
|
||||
existsSync(new URL(`docs/glossaire/${term}.md`, root)),
|
||||
`fiche ${term}.md absente`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -68,7 +75,9 @@ test("les liens et le gras du texte sont lus", () => {
|
||||
});
|
||||
|
||||
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("## 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"));
|
||||
@@ -104,8 +113,12 @@ test("chaque coup illustré est légal, et la partie de l'étape 7 finit par un
|
||||
continue;
|
||||
}
|
||||
assert.ok(screen.roll !== null, `${name} : coup sans jet`);
|
||||
const { steps, after } = await illustratedMove(engine, screen.position, screen.roll,
|
||||
screen.move);
|
||||
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" });
|
||||
@@ -127,8 +140,12 @@ test("chaque écran « à vous » accepte ses coups attendus et refuse les autre
|
||||
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);
|
||||
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);
|
||||
@@ -140,7 +157,10 @@ test("chaque écran « à vous » accepte ses coups attendus et refuse les autre
|
||||
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`);
|
||||
assert.ok(
|
||||
legal.some((m) => !screen.expected.includes(m.notation)),
|
||||
`${name} : rien à refuser`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -171,9 +191,22 @@ test("des demi-coups peuvent encore mener à un coup attendu, ou plus", () => {
|
||||
halfMoves: halfMoves.map(([from, to]) => ({ from, to })),
|
||||
position: notation,
|
||||
});
|
||||
const expected = [move("8/5 6/5", [[8, 5], [6, 5]])];
|
||||
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));
|
||||
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);
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user