Écrans des leçons, carte de l'accueil et adresses #/lecons

Liste des leçons, écrans d'une leçon sur le moteur d'écrans du
tutoriel, carte « prochaine leçon » de l'accueil, ligne du menu.
Tests du contenu sur le vrai moteur, scénario Gherkin, test navigateur.
Amendement de l'ADR 0013 ; 1g faite, plan clos.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Pierre MARTIN
2026-11-12 10:18:07 +01:00
co-authored by Claude Opus 5.5
parent e9ef965438
commit 0493858c1a
25 changed files with 2016 additions and 113 deletions
+4 -3
View File
@@ -127,9 +127,9 @@ SITE_ICONS := icone.svg icone-192.png icone-512.png icone-maskable-192.png icone
# docs/identite/logo/ (une seule source, ADR 0010) ;
# - l'application compilée (build/web/src/) sous app/ ;
# - le moteur sous wasm/ ;
# - les copies du glossaire, des scénarios, du tutoriel, des phrases du
# - les copies du glossaire, des scénarios, du tutoriel, des leçons, des phrases du
# maître et des prompts pour une IA sous docs/, lues par l'aide, le
# tutoriel, le conseil du maître et « Demander à une IA »
# tutoriel, les leçons, le conseil du maître et « Demander à une IA »
# (ADR 0008 : jamais commitées
# ailleurs) ;
# - version.json (commit publié) ;
@@ -143,7 +143,8 @@ site: engine-wasm web ## Assemble le site publié dans build/site/
cp -r $(BUILD_DIR)/web/src $(BUILD_DIR)/site/app
cp $(BUILD_DIR)/wasm/engine.mjs $(BUILD_DIR)/wasm/engine.wasm $(BUILD_DIR)/wasm/engine.data \
$(BUILD_DIR)/wasm/positions.json $(BUILD_DIR)/site/wasm/
cp -r docs/glossaire docs/scenarios docs/tutoriel docs/maitre docs/prompts $(BUILD_DIR)/site/docs/
cp -r docs/glossaire docs/scenarios docs/tutoriel docs/lecons docs/maitre docs/prompts \
$(BUILD_DIR)/site/docs/
printf '{"commit": "%s", "dirty": %s}\n' "$$(git rev-parse HEAD)" \
"$$(git diff --quiet HEAD && echo false || echo true)" > $(BUILD_DIR)/site/version.json
scripts/service-worker.sh $(BUILD_DIR)/site
+10 -2
View File
@@ -65,7 +65,7 @@ const settings = createSettingsStore(localStorage ?? memoryStorage());
// pendant que le moteur se charge : le plateau est dessiné sans les
// attendre. L'écran de jeu ne s'en sert qu'une fois engineReady résolue.
const opening = (async () => {
const { library } = await openGameLibrary({
const { library, lessons } = await openGameLibrary({
indexedDB: attempt(() => window.indexedDB),
localStorage,
});
@@ -76,7 +76,7 @@ const opening = (async () => {
resume: (journal) => Game.resume(engine, journal),
});
const saved = await session.restore();
return { library, session, saved };
return { library, session, saved, lessons };
})();
let stored: Awaited<typeof opening> | null = null;
/** Le stockage, ouvert (toujours le cas une fois engineReady résolue). */
@@ -154,6 +154,14 @@ startApp({
MovePractice.start(engine, position, roll, expected),
shouldTake: (position) => shouldTake(engine, position),
},
// La progression dans les leçons (étape 1g), dans le stockage des
// parties : lue une fois ouvert, enregistrée sans être attendue.
lessons: {
load: async () => (await opening).lessons.load(),
save: (progress) => {
void opening.then((opened) => opened.lessons.save(progress));
},
},
// 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
+239 -12
View File
@@ -44,6 +44,15 @@ import { type PositionLink, type SharedPosition, setupOf } from "../domain/game/
import type { SavedGameSummary } from "../domain/game/saved-game.js";
import { otherSide, type Side } from "../domain/game/side.js";
import type { GameState } from "../domain/game/state.js";
import {
finishedLessons,
LESSON_END,
type LessonProgress,
lessonRecord,
NO_PROGRESS,
withSolved,
withVisit,
} from "../domain/learning/lessons.js";
import { ADVICE_TEXTS } from "./advice-texts.js";
import { type Analyse, createAdviceTracker } from "./advice-tracker.js";
import { createAdviceView, VERDICT_ICONS } from "./advice-view.js";
@@ -82,6 +91,10 @@ 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 { LESSON_COUNT, type Lesson, lessonSource, outlineOf, parseLesson } from "./lesson-text.js";
import { LESSON_TEXTS } from "./lesson-texts.js";
import { lessonCards, nextLessonCard } from "./lessons-model.js";
import { createLessonsView } from "./lessons-view.js";
import { MATCH_TEXTS } from "./match-texts.js";
import { replacingLink, startNavigation } from "./navigation.js";
import { createNewGameView } from "./new-game-view.js";
@@ -106,8 +119,21 @@ import {
} from "./settings.js";
import { createSummaryView } from "./summary-view.js";
import { type PromptText, TEXTS } from "./texts.js";
import { seenAfter } from "./tutorial-nav.js";
import { createTutorialView, type TutorialAccess } from "./tutorial-view.js";
import {
lessonPlaceOf,
lessonPlaceRoute,
seenAfter,
placeOf as tutorialPlaceOf,
placeRoute as tutorialPlaceRoute,
} from "./tutorial-nav.js";
import { parseTutorial, type Tutorial } from "./tutorial-text.js";
import {
type Course,
cachedText,
createTutorialView,
TUTORIAL_SOURCE,
type TutorialAccess,
} from "./tutorial-view.js";
import { betweenTurns, type Updates, updateBannerShown } from "./updates.js";
/** Glissement d'un pion de l'ordinateur, et pause entre deux pions
@@ -147,8 +173,15 @@ 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. */
/** Le tutoriel : plateaux, coups illustrés, gestes jugés par le moteur ;
* les leçons s'en servent aussi. */
readonly tutorial: TutorialAccess;
/** La progression dans les leçons (étape 1g), dans le stockage des
* parties. */
readonly lessons: {
readonly load: () => Promise<LessonProgress>;
readonly save: (progress: LessonProgress) => void;
};
/** Les mises à jour de l'application (service worker) : onAvailable est
* appelé quand une nouvelle version attend. */
readonly startUpdates: (onAvailable: () => void) => Updates;
@@ -308,8 +341,11 @@ export function startApp(options: AppOptions): void {
resumeOld: byId<HTMLButtonElement>("resume-old"),
home: byId("home"),
tutorial: byId("tutorial"),
lessons: byId("lessons"),
lesson: byId("lesson"),
menuHome: byId<HTMLAnchorElement>("menu-home"),
menuTutorial: byId<HTMLAnchorElement>("menu-tutorial"),
menuLessons: byId<HTMLAnchorElement>("menu-lessons"),
menuInstall: byId<HTMLButtonElement>("menu-install"),
menuUpdate: byId<HTMLButtonElement>("menu-update"),
installVeil: byId("install-veil"),
@@ -407,6 +443,7 @@ export function startApp(options: AppOptions): void {
ui.resumeOld.textContent = TEXTS.resumeOld;
label(ui.menuHome, HOME_TEXTS.homeLink, "home");
label(ui.menuTutorial, HOME_TEXTS.tutorialMenu, "rules");
label(ui.menuLessons, LESSON_TEXTS.menu, "lessons");
label(ui.menuInstall, HOME_TEXTS.installMenu, "install");
label(ui.menuUpdate, HOME_TEXTS.updateCheck, "replay");
ui.installTitle.textContent = HOME_TEXTS.installTitle;
@@ -1722,6 +1759,10 @@ export function startApp(options: AppOptions): void {
navigation.leave((r) => r.screen === "analysis");
} else if (route.screen === "tutorial" || route.screen === "tutorialEnd") {
navigation.leave((r) => r.screen === "tutorial" || r.screen === "tutorialEnd");
} else if (route.screen === "lesson" || route.screen === "lessonEnd") {
navigation.leave((r) => r.screen === "lesson" || r.screen === "lessonEnd");
} else if (route.screen === "lessons") {
navigation.leave((r) => r.screen === "lessons");
}
},
};
@@ -1827,6 +1868,7 @@ export function startApp(options: AppOptions): void {
replacingLink(ui.menuHelp, navigation);
replacingLink(ui.menuHome, navigation);
replacingLink(ui.menuTutorial, navigation);
replacingLink(ui.menuLessons, navigation);
ui.menuClose.onclick = () => closeOverlays();
ui.menuCopyLink.onclick = () => {
const link = currentLink;
@@ -1878,9 +1920,12 @@ export function startApp(options: AppOptions): void {
const position = below.screen === "position";
const home = below.screen === "home";
const tutorial = below.screen === "tutorial" || below.screen === "tutorialEnd";
const lessonList = below.screen === "lessons";
const lesson = below.screen === "lesson" || below.screen === "lessonEnd";
const choosing = below.screen === "newGame";
const analysing = below.screen === "analysis";
const page = help || position || home || tutorial || choosing || analysing;
const page =
help || position || home || tutorial || lessonList || lesson || choosing || analysing;
const wasPage = ui.game.hidden;
const wasScreen = ui.root.dataset.screen;
ui.game.hidden = page;
@@ -1888,13 +1933,18 @@ export function startApp(options: AppOptions): void {
ui.position.hidden = !position;
ui.home.hidden = !home;
ui.tutorial.hidden = !tutorial;
ui.lessons.hidden = !lessonList;
ui.lesson.hidden = !lesson;
ui.analysis.hidden = !analysing;
const opening = choosing && ui.newGameSection.hidden;
ui.newGameSection.hidden = !choosing;
ui.root.classList.toggle("page-open", page);
// Le tutoriel, le premier lancement et l'écran « Nouvelle partie »
// ont leur propre en-tête.
ui.root.classList.toggle("bare", tutorial || choosing || (home && isFirstLaunch(settings)));
// Le tutoriel, les leçons, le premier lancement et l'écran « Nouvelle
// partie » ont leur propre en-tête.
ui.root.classList.toggle(
"bare",
tutorial || lesson || choosing || (home && isFirstLaunch(settings)),
);
if (opening) {
newGameView.render(busy || game === null);
newGameView.focus();
@@ -1910,6 +1960,21 @@ export function startApp(options: AppOptions): void {
} else {
tutorialView.hide();
}
if (lesson) {
// Une leçon qui n'existe pas : la liste des leçons.
if (below.lesson > LESSON_COUNT) {
navigation.replace(routeHash({ screen: "lessons" }));
return;
}
if (route.screen !== "install") {
void lessonView.show(below);
}
} else {
lessonView.hide();
}
if (lessonList) {
renderLessons();
}
if (home) {
homeProgress = null;
}
@@ -2064,8 +2129,26 @@ export function startApp(options: AppOptions): void {
sides: sides ?? options.cachedBoard()?.sides ?? null,
tutorialSeen: settings.tutorialSeen,
progress: homeProgress,
lessons:
lessonTexts === null || lessonProgress === null
? null
: {
next: nextLessonCard(lessonTexts, lessonProgress),
finished: finishedLessons(lessonProgress, lessonTexts.map(outlineOf)),
total: lessonTexts.length,
},
busy: busy || game === null,
});
if (lessonTexts === null || lessonProgress === null) {
lessonsReady().then(
() => {
if (navigation.route.screen === "home") {
renderHome();
}
},
() => undefined,
);
}
if (homeProgress === null && !progressLoading) {
progressLoading = true;
// La liste vient du stockage, ouvert avec le moteur.
@@ -2088,17 +2171,158 @@ export function startApp(options: AppOptions): void {
}
}
const tutorialView = createTutorialView({
section: ui.tutorial,
access: options.tutorial,
engineReady: options.engineReady,
const loadTutorial = cachedText(TUTORIAL_SOURCE, parseTutorial);
const tutorialCourse: Course = {
id: "tutorial",
load: () => loadTutorial(),
placeOf: tutorialPlaceOf,
routeOf: (_, place) => tutorialPlaceRoute(place),
seen: () => settings.tutorialSeen,
visit: (place) => {
visit: (_, place) => {
const seen = seenAfter(settings.tutorialSeen, place);
if (seen !== settings.tutorialSeen || !settings.welcomed) {
saveSettings({ tutorialSeen: seen, ...welcomed() });
}
},
solved: () => undefined,
quitHref: () => "#/",
firstBackHref: () => "#/accueil",
endHref: () => "#/",
texts: {
progress: HOME_TEXTS.tutorialProgress,
stepLink: HOME_TEXTS.tutorialStepLink,
quit: HOME_TEXTS.tutorialSkip,
quitLabel: HOME_TEXTS.tutorialSkipLabel,
overline: (_, n, total, practice) => HOME_TEXTS.tutorialOverline(n, total, practice),
endOverline: () => HOME_TEXTS.tutorialEndOverline,
endTitle: () => HOME_TEXTS.tutorialEndTitle,
loading: HOME_TEXTS.tutorialLoading,
failed: HOME_TEXTS.tutorialFailed,
},
};
const tutorialView = createTutorialView({
section: ui.tutorial,
access: options.tutorial,
engineReady: options.engineReady,
course: tutorialCourse,
placed: () => haptic("checkerPlaced"),
reducedMotion,
landscape: () => layout() === "landscape",
replace: (hash) => navigation.replace(hash),
});
// ------------------------------------------------------------ Leçons
// Les textes des leçons (docs/lecons/, copiés dans le site) et la
// progression enregistrée, lus une fois.
const lessonLoaders = Array.from({ length: LESSON_COUNT }, (_, i) =>
cachedText(lessonSource(i + 1), (text) => parseLesson(i + 1, text)),
);
let lessonTexts: Lesson[] | null = null;
let lessonProgress: LessonProgress | null = null;
const lessonProgressLoading = options.lessons
.load()
.catch(() => NO_PROGRESS)
.then((progress) => {
lessonProgress = progress;
return progress;
});
/** Les leçons et la progression, lues (rejetée si un texte manque). */
async function lessonsReady(): Promise<{ texts: Lesson[]; progress: LessonProgress }> {
const [texts, progress] = await Promise.all([
Promise.all(lessonLoaders.map((load) => load())),
lessonProgressLoading,
]);
lessonTexts = texts;
return { texts, progress };
}
const progressNow = () => lessonProgress ?? NO_PROGRESS;
function updateLessons(progress: LessonProgress): void {
lessonProgress = progress;
options.lessons.save(progress);
}
const lessonOf = (route: Route) =>
route.screen === "lesson" || route.screen === "lessonEnd" ? route.lesson : 1;
// Le texte que l'écran d'une leçon reçoit est une leçon (Lesson).
const lessonNumberOf = (text: Tutorial) => (text as Partial<Lesson>).number ?? 1;
const lessonTitleOf = (text: Tutorial) => (text as Partial<Lesson>).title ?? "";
const lessonsView = createLessonsView(ui.lessons);
function renderLessons(): void {
if (lessonTexts !== null && lessonProgress !== null) {
lessonsView.render(lessonCards(lessonTexts, lessonProgress));
return;
}
lessonsView.render(null);
lessonsReady().then(
() => {
if (navigation.route.screen === "lessons") {
renderLessons();
}
},
() => {
if (navigation.route.screen === "lessons") {
lessonsView.render(null, true);
}
},
);
}
const lessonCourse: Course = {
id: "lesson",
load: async (route) => {
await lessonProgressLoading;
const load = lessonLoaders[lessonOf(route) - 1];
if (load === undefined) {
throw new Error(`leçon ${lessonOf(route)} absente`);
}
return load();
},
placeOf: lessonPlaceOf,
routeOf: (route, place) => lessonPlaceRoute(lessonOf(route), place),
seen: (route) => lessonRecord(progressNow(), lessonOf(route)).reached,
visit: (route, place) => {
const number = lessonOf(route);
const at = place === "end" ? LESSON_END : place.step;
const record = lessonRecord(progressNow(), number);
if (record.last !== at || record.reached < at) {
updateLessons(withVisit(progressNow(), number, at));
}
if (!settings.welcomed) {
saveSettings(welcomed());
}
},
solved: (route, place) => {
if (place !== "end" && place.practice) {
updateLessons(withSolved(progressNow(), lessonOf(route), place.step));
}
},
quitHref: () => routeHash({ screen: "lessons" }),
firstBackHref: () => routeHash({ screen: "lessons" }),
endHref: (route) => {
const number = lessonOf(route);
return number < LESSON_COUNT
? routeHash({ screen: "lesson", lesson: number + 1, step: 1, practice: false })
: routeHash({ screen: "lessons" });
},
texts: {
progress: LESSON_TEXTS.progress,
stepLink: LESSON_TEXTS.stepLink,
quit: LESSON_TEXTS.quit,
quitLabel: LESSON_TEXTS.quitLabel,
overline: (text, n, total, practice) =>
LESSON_TEXTS.overline(lessonNumberOf(text), n, total, practice),
endOverline: (text) => LESSON_TEXTS.endOverline(lessonNumberOf(text)),
endTitle: (text) => LESSON_TEXTS.endTitle(lessonTitleOf(text)),
loading: LESSON_TEXTS.loading,
failed: LESSON_TEXTS.failed,
},
};
const lessonView = createTutorialView({
section: ui.lesson,
access: options.tutorial,
engineReady: options.engineReady,
course: lessonCourse,
placed: () => haptic("checkerPlaced"),
reducedMotion,
landscape: () => layout() === "landscape",
@@ -2369,6 +2593,9 @@ export function startApp(options: AppOptions): void {
if (!ui.tutorial.hidden) {
tutorialView.relayout();
}
if (!ui.lesson.hidden) {
lessonView.relayout();
}
});
};
window.addEventListener("resize", relayout);
+1
View File
@@ -35,6 +35,7 @@ export const FEATURES = [
"docs/scenarios/revoir-ma-partie.feature",
"docs/scenarios/demander-a-une-ia.feature",
"docs/scenarios/apprendre-les-regles.feature",
"docs/scenarios/suivre-une-lecon.feature",
"docs/scenarios/mes-parties.feature",
"docs/scenarios/partager-une-position.feature",
"docs/scenarios/regler-l-application.feature",
+1 -1
View File
@@ -119,7 +119,7 @@ export const HOME_TEXTS = {
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.",
"Les textes du tutoriel, des leçons, 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.",
],
+64 -7
View File
@@ -1,13 +1,13 @@
// 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
// installer, la prochaine leçon (1g), 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.
// La progression détaillée (1h) n'existe 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";
@@ -16,6 +16,9 @@ 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 { LESSON_TEXTS } from "./lesson-texts.js";
import type { LessonCardModel } from "./lessons-model.js";
import { fillLessonCard, lessonAction } from "./lessons-view.js";
import { TUTORIAL_DONE } from "./settings.js";
import { TEXTS } from "./texts.js";
@@ -38,6 +41,13 @@ export interface HomeState {
readonly tutorialSeen: number;
/** Parties jouées et gagnées (null : pas encore lues). */
readonly progress: { readonly played: number; readonly won: number } | null;
/** Les leçons : la prochaine (null : toutes terminées), et combien sont
* terminées sur combien (null : pas encore lues). */
readonly lessons: {
readonly next: LessonCardModel | null;
readonly finished: number;
readonly total: number;
} | null;
/** Le moteur se charge, ou l'écran est occupé. */
readonly busy: boolean;
}
@@ -186,6 +196,23 @@ export function createHomeView(options: HomeViewOptions): HomeView {
later.title = HOME_TEXTS.installLater;
later.addEventListener("click", () => options.dismissInstall());
// La prochaine leçon : une seule carte, « Continuer » en secondaire, et
// le lien vers toutes les leçons.
const lesson = element(main, "article", { id: "home-lesson", className: "card home-lesson" });
element(lesson, "span", { className: "overline", text: LESSON_TEXTS.homeOverline });
const lessonInfo = element(lesson, "div", { className: "home-lesson-info" });
const lessonActions = element(lesson, "div", { className: "home-lesson-actions" });
const lessonGo = element(lessonActions, "a", {
id: "home-lesson-go",
className: "button secondary",
});
const lessonAll = element(lessonActions, "a", {
id: "home-lessons",
className: "home-lessons-link",
text: LESSON_TEXTS.allLessons,
});
lessonAll.href = "#/lecons";
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 });
@@ -233,10 +260,40 @@ export function createHomeView(options: HomeViewOptions): HomeView {
: 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);
// La carte de la leçon, une fois les leçons lues.
lesson.hidden = state.lessons === null;
if (state.lessons !== null) {
const card = state.lessons.next;
if (card === null) {
lessonInfo.replaceChildren();
element(lessonInfo, "strong", {
className: "lesson-card-title",
text: LESSON_TEXTS.homeAllDone,
});
element(lessonInfo, "span", {
className: "muted",
text: LESSON_TEXTS.homeAllDoneDetail,
});
lessonGo.hidden = true;
} else {
fillLessonCard(lessonInfo, card);
lessonGo.hidden = false;
lessonGo.textContent = lessonAction(card);
lessonGo.href = card.href;
lessonGo.setAttribute(
"aria-label",
`${lessonAction(card)} : ${LESSON_TEXTS.openLabel(card.number, card.title)}`,
);
}
}
const parts: string[] = [];
if (state.lessons !== null) {
parts.push(LESSON_TEXTS.homeSummary(state.lessons.finished, state.lessons.total));
}
if (state.progress !== null && state.progress.played > 0) {
parts.push(HOME_TEXTS.progress(state.progress.played, state.progress.won));
}
progress.textContent = parts.join(" · ");
},
};
}
+1
View File
@@ -25,6 +25,7 @@ export const ICON_PATHS = {
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",
lessons: "M3 9l9-5 9 5-9 5zM7 11.2V16c0 1.6 2.2 3 5 3s5-1.4 5-3v-4.8M21 9v6",
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",
+57
View File
@@ -0,0 +1,57 @@
// Les textes de la liste des leçons, de leurs écrans et de la carte
// « prochaine leçon » de l'accueil (ADR 0007, point 5 ;
// docs/identite/charte/ecrans-etape-1.md, « 1g. Leçons »). Le texte des
// leçons elles-mêmes n'est pas ici : il vit dans docs/lecons/.
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
import type { LessonState } from "../domain/learning/lessons.js";
function plural(n: number, one: string, many: string): string {
return `${n} ${n > 1 ? many : one}`;
}
export const LESSON_TEXTS = {
// Liste
menu: "Les leçons",
listTitle: "Les leçons",
listLead:
"Cinq leçons courtes, plateau à l'appui, avec des exercices où vous jouez le coup du maître. L'ordre est conseillé, jamais imposé.",
rulesReminder: "Les règles du jeu",
rulesReview: "revoir",
rulesReviewLabel: "Revoir les règles du jeu (le tutoriel)",
number: (n: number) => `Leçon ${n}`,
listLoading: "Chargement des leçons…",
listFailed: "Les leçons n'ont pas pu se charger.",
state: (state: LessonState) =>
state.kind === "done"
? "Terminée"
: state.kind === "toStart"
? `À commencer · ${plural(state.total, "exercice", "exercices")}`
: `${plural(state.solved, "exercice", "exercices")} sur ${state.total}`,
continue: "Continuer",
start: "Commencer",
review: "Revoir",
openLabel: (n: number, title: string) => `Leçon ${n} : ${title}`,
// Écrans d'une leçon
progress: "Étapes de la leçon",
stepLink: (n: number, title: string) => `Étape ${n} : ${title}`,
quit: "Quitter",
quitLabel: "Quitter la leçon",
overline: (lesson: number, n: number, total: number, practice: boolean) =>
`Leçon ${lesson} · ${n} sur ${total}${practice ? " · à vous" : ""}`,
endOverline: (lesson: number) => `Leçon ${lesson} · fin`,
endTitle: (title: string) => title,
loading: "Chargement de la leçon…",
failed: "La leçon n'a pas pu se charger.",
// Accueil
homeOverline: "Prochaine leçon",
homeAllDone: "Toutes les leçons sont terminées",
homeAllDoneDetail: "Vous pouvez les revoir quand vous voulez.",
allLessons: "Toutes les leçons",
homeSummary: (finished: number, total: number) =>
`${plural(finished, "leçon terminée", "leçons terminées")} sur ${total}`,
} as const;
+66
View File
@@ -0,0 +1,66 @@
// Ce que montrent la liste des leçons et la carte « prochaine leçon » de
// l'accueil : fonctions pures, testées sous Node
// (docs/identite/charte/ecrans-etape-1.md, « 1g. Leçons »).
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
import {
type LessonProgress,
type LessonState,
lessonRecord,
lessonState,
nextLesson,
resumeStep,
} from "../domain/learning/lessons.js";
import { type Lesson, outlineOf } from "./lesson-text.js";
import { routeHash } from "./router.js";
/** Une leçon dans la liste, ou sur l'accueil. */
export interface LessonCardModel {
readonly number: number;
readonly title: string;
readonly state: LessonState;
/** Les exercices, dans l'ordre : réussi ou non (barre de progression). */
readonly exercises: readonly boolean[];
/** Où mène la carte : là où reprendre la leçon. */
readonly href: string;
/** La leçon proposée ensuite : carte bordée de brique, seule action
* principale de l'écran. */
readonly current: boolean;
}
/** L'adresse où reprendre la leçon. */
export function resumeHref(progress: LessonProgress, lesson: Lesson): string {
const step = resumeStep(progress, outlineOf(lesson));
return routeHash({ screen: "lesson", lesson: lesson.number, step, practice: false });
}
/** Les cartes de la liste des leçons, dans l'ordre. */
export function lessonCards(
lessons: readonly Lesson[],
progress: LessonProgress,
): LessonCardModel[] {
const outlines = lessons.map(outlineOf);
const next = nextLesson(progress, outlines)?.number ?? null;
return lessons.map((lesson, i) => {
const outline = outlines[i] ?? outlineOf(lesson);
const solved = lessonRecord(progress, lesson.number).solved;
return {
number: lesson.number,
title: lesson.title,
state: lessonState(progress, outline),
exercises: outline.exercises.map((step) => solved.includes(step)),
href: resumeHref(progress, lesson),
current: lesson.number === next,
};
});
}
/** La carte « prochaine leçon » de l'accueil (null : toutes terminées). */
export function nextLessonCard(
lessons: readonly Lesson[],
progress: LessonProgress,
): LessonCardModel | null {
return lessonCards(lessons, progress).find((card) => card.current) ?? null;
}
+134
View File
@@ -0,0 +1,134 @@
// La liste des leçons (#/lecons) et la carte d'une leçon, reprise par la
// carte « prochaine leçon » de l'accueil
// (docs/identite/charte/ecrans-etape-1.md, « 1g. Leçons »).
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
import { LESSON_TEXTS } from "./lesson-texts.js";
import type { LessonCardModel } from "./lessons-model.js";
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;
}
/** La pastille ronde au numéro de la leçon : verte une fois terminée,
* brique pour la leçon proposée, au contour sinon. */
function badge(parent: HTMLElement, card: LessonCardModel): void {
const kind = card.state.kind === "done" ? "done" : card.current ? "current" : "todo";
const span = element(parent, "span", {
className: `lesson-badge ${kind}`,
text: String(card.number),
});
span.setAttribute("aria-hidden", "true");
}
/** La barre de progression : un segment par exercice, en brique une fois
* réussi. */
function progressBar(parent: HTMLElement, card: LessonCardModel): void {
const bar = element(parent, "span", { className: "lesson-progress" });
bar.setAttribute("aria-hidden", "true");
for (const done of card.exercises) {
element(bar, "span", { className: done ? "segment done" : "segment" });
}
}
/** Le contenu d'une carte de leçon : pastille, numéro, titre, état et
* progression. */
export function fillLessonCard(target: HTMLElement, card: LessonCardModel): void {
target.replaceChildren();
const top = element(target, "span", { className: "lesson-card-top" });
badge(top, card);
const body = element(top, "span", { className: "lesson-card-body" });
element(body, "span", { className: "overline", text: LESSON_TEXTS.number(card.number) });
element(body, "strong", { className: "lesson-card-title", text: card.title });
element(body, "span", {
className: `lesson-card-state ${card.state.kind}`,
text: LESSON_TEXTS.state(card.state),
});
if (card.state.kind !== "toStart" && card.state.total > 0) {
progressBar(body, card);
}
}
/** Le libellé de l'action d'une leçon. */
export function lessonAction(card: LessonCardModel): string {
return card.state.kind === "toStart"
? LESSON_TEXTS.start
: card.state.kind === "done"
? LESSON_TEXTS.review
: LESSON_TEXTS.continue;
}
export interface LessonsView {
/** Les cartes (null : les leçons se chargent ; erreur : échec). */
render(cards: readonly LessonCardModel[] | null, failed?: boolean): void;
}
/** Crée la liste des leçons dans section (vide dans index.html). */
export function createLessonsView(section: HTMLElement): LessonsView {
section.replaceChildren();
const head = element(section, "div", { className: "lessons-intro" });
element(head, "h2", { text: LESSON_TEXTS.listTitle });
element(head, "p", { className: "muted", text: LESSON_TEXTS.listLead });
const rules = element(section, "p", { className: "lessons-rules caption" });
rules.append(`${LESSON_TEXTS.rulesReminder} · `);
const review = element(rules, "a", { id: "lessons-rules", text: LESSON_TEXTS.rulesReview });
review.href = "#/tutoriel/1";
review.setAttribute("aria-label", LESSON_TEXTS.rulesReviewLabel);
const status = element(section, "p", { id: "lessons-status", className: "muted" });
status.setAttribute("role", "status");
const list = element(section, "ol", { id: "lesson-list", className: "lesson-list" });
return {
render(cards, failed = false) {
status.textContent =
cards !== null ? "" : failed ? LESSON_TEXTS.listFailed : LESSON_TEXTS.listLoading;
list.replaceChildren();
for (const card of cards ?? []) {
const item = element(list, "li");
// La leçon proposée porte la seule action principale ; les
// autres cartes sont des liens, toutes restent ouvertes.
if (card.current) {
const article = element(item, "article", {
id: `lesson-card-${card.number}`,
className: "card lesson-card current",
});
const info = element(article, "div");
fillLessonCard(info, card);
const action = element(article, "a", {
className: "button lesson-card-action",
text: lessonAction(card),
});
action.href = card.href;
action.setAttribute(
"aria-label",
`${lessonAction(card)} : ${LESSON_TEXTS.openLabel(card.number, card.title)}`,
);
} else {
const link = element(item, "a", {
id: `lesson-card-${card.number}`,
className: "card lesson-card",
});
link.href = card.href;
fillLessonCard(link, card);
}
}
},
};
}
+47
View File
@@ -24,6 +24,19 @@ export type Route =
| { readonly screen: "tutorial"; readonly step: number | null; readonly practice: boolean }
/** #/tutoriel/fin : la fin du tutoriel. */
| { readonly screen: "tutorialEnd" }
/** #/lecons : la liste des leçons (étape 1g). */
| { readonly screen: "lessons" }
/** #/lecons/<n> (son premier écran), une étape de la leçon n :
* #/lecons/<n>/<étape>, et son exercice « à vous » :
* #/lecons/<n>/<étape>/a-vous. */
| {
readonly screen: "lesson";
readonly lesson: number;
readonly step: number | null;
readonly practice: boolean;
}
/** #/lecons/<n>/fin : la fin de la leçon n. */
| { readonly screen: "lessonEnd"; readonly lesson: number }
/** #/installer : la marche à suivre pour installer l'application. */
| { readonly screen: "install" }
/** #/parties, et une partie : #/parties/<identifiant>. */
@@ -91,6 +104,9 @@ export function parseRoute(hash: string): Route {
if (head === "tutoriel") {
return tutorialRoute(rest) ?? unknown;
}
if (head === "lecons") {
return lessonRoute(rest) ?? unknown;
}
if (head === "parties" && rest.length > 1) {
return gameReviewRoute(rest) ?? unknown;
}
@@ -194,6 +210,29 @@ function tutorialRoute(rest: readonly string[]): Route | null {
return { screen: "tutorial", step: Number(name), practice: part === "a-vous" };
}
/** #/lecons, #/lecons/<n>, #/lecons/<n>/<étape>, #/lecons/<n>/<étape>/a-vous,
* #/lecons/<n>/fin. */
function lessonRoute(rest: readonly string[]): Route | null {
const [number, name, part, ...more] = rest;
if (number === undefined) {
return { screen: "lessons" };
}
if (!/^[1-9]$/.test(number) || more.length > 0) {
return null;
}
const lesson = Number(number);
if (name === undefined) {
return { screen: "lesson", lesson, step: null, practice: false };
}
if (name === "fin") {
return part === undefined ? { screen: "lessonEnd", lesson } : null;
}
if (!/^[1-9][0-9]?$/.test(name) || (part !== undefined && part !== "a-vous")) {
return null;
}
return { screen: "lesson", lesson, step: Number(name), practice: part === "a-vous" };
}
/**
* #/position/... : le Position ID (14 caractères), puis, après une barre, le
* Match ID. Chaque identifiant est encodé avec encodeURIComponent, mais une
@@ -246,6 +285,14 @@ function screenHash(route: Route): string {
: `#/tutoriel/${route.step}${route.practice ? "/a-vous" : ""}`;
case "tutorialEnd":
return "#/tutoriel/fin";
case "lessons":
return "#/lecons";
case "lesson":
return route.step === null
? `#/lecons/${route.lesson}`
: `#/lecons/${route.lesson}/${route.step}${route.practice ? "/a-vous" : ""}`;
case "lessonEnd":
return `#/lecons/${route.lesson}/fin`;
case "install":
return "#/installer";
case "games":
+28 -5
View File
@@ -19,14 +19,19 @@ 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];
return route.screen === "tutorial"
? existingPlace(tutorial, route.step, route.practice)
: { step: 1, practice: false };
}
/** Le même écran qu'une adresse d'étape, s'il existe ; sinon la première
* étape (étape absente), ou l'explication (« à vous » absent). */
function existingPlace(tutorial: Tutorial, at: number | null, practice: boolean): TutorialPlace {
const step = at === null ? undefined : tutorial.steps[at - 1];
if (step === undefined) {
return { step: 1, practice: false };
}
return { step: step.number, practice: route.practice && step.practice !== null };
return { step: step.number, practice: practice && step.practice !== null };
}
/** L'adresse d'un écran du tutoriel. */
@@ -36,6 +41,24 @@ export function placeRoute(place: TutorialPlace): Route {
: { screen: "tutorial", step: place.step, practice: place.practice };
}
/** L'écran d'une leçon que désigne une adresse (#/lecons/<n>/...), ramené à
* un écran qui existe, comme placeOf pour le tutoriel. */
export function lessonPlaceOf(lesson: Tutorial, route: Route): TutorialPlace {
if (route.screen === "lessonEnd") {
return "end";
}
return route.screen === "lesson"
? existingPlace(lesson, route.step, route.practice)
: { step: 1, practice: false };
}
/** L'adresse d'un écran de la leçon number. */
export function lessonPlaceRoute(number: number, place: TutorialPlace): Route {
return place === "end"
? { screen: "lessonEnd", lesson: number }
: { screen: "lesson", lesson: number, 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") {
+135 -75
View File
@@ -2,11 +2,14 @@
// #/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 »).
// Les leçons (#/lecons/<n>/..., étape 1g) reprennent le même écran, avec
// leur texte et leurs adresses (Course).
//
// 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,
// Le texte vient de docs/tutoriel/regles.md ou de docs/lecons/ (copies 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.
@@ -39,20 +42,8 @@ import { boardGeometry, columnLeft, type Geometry, PORTRAIT_ASPECT } from "./geo
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";
import { nextPlace, previousPlace, progress, type TutorialPlace } from "./tutorial-nav.js";
import type { Paragraph, Tutorial, TutorialScreen } from "./tutorial-text.js";
/** Le coup illustré se joue tout seul après une seconde. */
const AUTOPLAY_MS = 1000;
@@ -61,7 +52,32 @@ 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";
/** Le texte du tutoriel, copié dans le site (make site). */
export const TUTORIAL_SOURCE = "docs/tutoriel/regles.md";
/**
* Un texte du site, lu une fois puis gardé : la fonction renvoyée le
* télécharge à son premier appel et le relit avec parse. Un échec n'est
* pas gardé : l'appel suivant réessaie.
*/
export function cachedText<T>(source: string, parse: (text: string) => T): () => Promise<T> {
let loaded: Promise<T> | null = null;
return () => {
loaded ??= fetch(source)
.then((response) => {
if (!response.ok) {
throw new Error(`${source} : ${response.status}`);
}
return response.text();
})
.then(parse)
.catch((error: unknown) => {
loaded = null;
throw error;
});
return loaded;
};
}
/** Un écran « à vous » où l'on joue un coup (MovePractice du domaine). */
export interface PracticeSession {
@@ -87,14 +103,53 @@ export interface TutorialAccess {
shouldTake(position: string): Promise<boolean>;
}
/**
* Un parcours d'écrans courts : le tutoriel des règles, ou une leçon (étape
* 1g), qui en reprennent la forme (charte, « 1g. Leçons »). Ce qui les
* distingue : leur texte, leurs adresses, leurs libellés, et ce que retient
* la progression.
*/
export interface Course {
/** Préfixe des identifiants des éléments de l'écran (« tutorial »,
* « lesson ») : deux parcours peuvent coexister dans la page. */
readonly id: string;
/** Le texte du parcours que désigne l'adresse. */
load(route: Route): Promise<Tutorial>;
/** L'écran que désigne l'adresse, ramené à un écran qui existe. */
placeOf(text: Tutorial, route: Route): TutorialPlace;
/** L'adresse d'un écran du même parcours que route. */
routeOf(route: Route, place: TutorialPlace): Route;
/** La dernière étape déjà vue : la barre de progression n'en mène pas
* plus loin. */
seen(route: Route): number;
/** L'arrivée sur un écran. */
visit(route: Route, place: TutorialPlace): void;
/** Un écran « à vous » réussi. */
solved(route: Route, place: TutorialPlace): void;
/** « Quitter » : où il mène. */
quitHref(route: Route): string;
/** « Retour » sur le premier écran. */
firstBackHref(route: Route): string;
/** L'action de l'écran de fin. */
endHref(route: Route): string;
readonly texts: {
readonly progress: string;
readonly stepLink: (n: number, title: string) => string;
readonly quit: string;
readonly quitLabel: string;
readonly overline: (text: Tutorial, n: number, total: number, practice: boolean) => string;
readonly endOverline: (text: Tutorial) => string;
readonly endTitle: (text: Tutorial) => string;
readonly loading: string;
readonly failed: string;
};
}
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;
readonly course: Course;
/** Un geste juste : la vibration « pion posé ». */
readonly placed: () => void;
readonly reducedMotion: MediaQueryList;
@@ -221,24 +276,28 @@ function drawDirections(svg: SVGSVGElement, g: Geometry, pxPerUnit: number): voi
/** Crée l'écran du tutoriel dans section (vide dans index.html). */
export function createTutorialView(options: TutorialViewOptions): TutorialView {
const { section, access } = options;
const { section, access, course } = options;
const idOf = (name: string) => `${course.id}-${name}`;
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" });
const bar = element(head, "nav", { id: idOf("progress"), className: "tutorial-progress" });
bar.setAttribute("aria-label", course.texts.progress);
const skip = element(head, "a", { id: idOf("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" });
skip.textContent = course.texts.quit;
skip.setAttribute("aria-label", course.texts.quitLabel);
const overline = element(section, "p", { id: idOf("overline"), className: "overline" });
const title = element(section, "h2", { id: idOf("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.id = idOf("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" });
const replay = element(boardBox, "button", {
id: idOf("replay"),
className: "icon-button tutorial-replay",
});
replay.type = "button";
replay.append(icon("replay"));
replay.setAttribute("aria-label", HOME_TEXTS.replayLabel);
@@ -246,39 +305,43 @@ export function createTutorialView(options: TutorialViewOptions): TutorialView {
// Le texte, la consigne « à vous » et la réponse : en paysage, la seule
// partie qui défile si l'écran est trop bas (app.css).
const body = element(section, "div", { className: "tutorial-body" });
const text = element(body, "div", { id: "tutorial-text", className: "tutorial-text" });
const task = element(body, "p", { id: "tutorial-task", className: "tutorial-task" });
const text = element(body, "div", { id: idOf("text"), className: "tutorial-text" });
const task = element(body, "p", { id: idOf("task"), className: "tutorial-task" });
const feedback = element(body, "p", {
id: "tutorial-feedback",
id: idOf("feedback"),
className: "tutorial-feedback",
});
feedback.setAttribute("role", "status");
const cube = element(section, "div", { id: "tutorial-cube", className: "tutorial-cube" });
const cube = element(section, "div", { id: idOf("cube"), className: "tutorial-cube" });
const drop = element(cube, "button", {
id: "tutorial-drop",
id: idOf("drop"),
className: "secondary",
text: HOME_TEXTS.drop,
});
drop.type = "button";
const take = element(cube, "button", {
id: "tutorial-take",
id: idOf("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",
id: idOf("back"),
className: "button secondary",
text: HOME_TEXTS.back,
});
const next = element(nav, "a", {
id: "tutorial-next",
id: idOf("next"),
className: "button",
text: HOME_TEXTS.next,
});
let tutorial: Promise<Tutorial> | null = null;
// Le dernier texte lu : tant qu'il n'y en a pas, l'écran dit qu'il se
// charge.
let lastText: Tutorial | null = null;
// L'adresse affichée.
let shownRoute: Route | 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;
@@ -302,22 +365,6 @@ export function createTutorialView(options: TutorialViewOptions): TutorialView {
let selected: Place | null = null;
let solved = false;
const load = () => {
tutorial ??= fetch(SOURCE)
.then((response) => {
if (!response.ok) {
throw new Error(`${SOURCE} : ${response.status}`);
}
return response.text();
})
.then(parseTutorial)
.catch((error: unknown) => {
tutorial = null;
throw error;
});
return tutorial;
};
/** En paysage, le plateau occupe toute la hauteur, à gauche de la
* colonne du texte (app.css), avec la géométrie du jeu en paysage. */
function geometry(): Geometry {
@@ -456,6 +503,9 @@ export function createTutorialView(options: TutorialViewOptions): TutorialView {
take.disabled = true;
drop.disabled = true;
options.placed();
if (shownRoute !== null && place !== null) {
course.solved(shownRoute, place);
}
}
async function playHalfMove(halfMove: HalfMove): Promise<void> {
@@ -550,17 +600,21 @@ export function createTutorialView(options: TutorialViewOptions): TutorialView {
take.addEventListener("click", () => void answer(true));
drop.addEventListener("click", () => void answer(false));
function renderProgress(total: number, at: TutorialPlace, steps: Tutorial["steps"]): void {
function renderProgress(route: Route, at: TutorialPlace, loaded: Tutorial): void {
const steps = loaded.steps;
const total = steps.length;
bar.replaceChildren();
for (const segment of progress(total, at, options.seen())) {
for (const segment of progress(total, at, course.seen(route))) {
const stepTitle = steps[segment.step - 1]?.title ?? "";
const label = HOME_TEXTS.tutorialStepLink(segment.step, stepTitle);
const label = course.texts.stepLink(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.href = routeHash(
course.routeOf(route, { step: segment.step, practice: false }),
);
item.setAttribute("aria-label", label);
} else {
item.setAttribute("aria-hidden", "true");
@@ -573,8 +627,8 @@ export function createTutorialView(options: TutorialViewOptions): TutorialView {
bar.setAttribute(
"aria-description",
at === "end"
? HOME_TEXTS.tutorialEndOverline
: HOME_TEXTS.tutorialOverline(at.step, total, at.practice),
? course.texts.endOverline(loaded)
: course.texts.overline(loaded, at.step, total, at.practice),
);
}
@@ -603,42 +657,48 @@ export function createTutorialView(options: TutorialViewOptions): TutorialView {
drop.disabled = false;
let loaded: Tutorial;
try {
if (tutorial === null) {
title.textContent = HOME_TEXTS.tutorialLoading;
if (lastText === null) {
title.textContent = course.texts.loading;
text.replaceChildren();
svg.replaceChildren();
}
loaded = await load();
loaded = await course.load(route);
} catch {
if (id === shown) {
title.textContent = HOME_TEXTS.tutorialFailed;
title.textContent = course.texts.failed;
}
return;
}
if (id !== shown) {
return;
}
const at = placeOf(loaded, route);
const wanted = routeHash(placeRoute(at));
lastText = loaded;
const at = course.placeOf(loaded, route);
const wanted = routeHash(course.routeOf(route, at));
if (routeHash(route) !== wanted) {
options.replace(wanted);
return;
}
place = at;
options.visit(at);
shownRoute = route;
course.visit(route, at);
const total = loaded.steps.length;
renderProgress(total, at, loaded.steps);
renderProgress(route, at, loaded);
skip.href = course.quitHref(route);
const before = previousPlace(loaded, at);
back.href = before === null ? "#/accueil" : routeHash(placeRoute(before));
back.href =
before === null
? course.firstBackHref(route)
: routeHash(course.routeOf(route, before));
if (at === "end") {
overline.textContent = HOME_TEXTS.tutorialEndOverline;
title.textContent = HOME_TEXTS.tutorialEndTitle;
overline.textContent = course.texts.endOverline(loaded);
title.textContent = course.texts.endTitle(loaded);
text.replaceChildren();
for (const paragraph of loaded.end.paragraphs) {
fillParagraph(element(text, "p"), paragraph);
}
next.textContent = loaded.end.action;
next.href = "#/";
next.href = course.endHref(route);
next.classList.remove("secondary");
screen = loaded.steps[0]?.explain ?? null;
} else {
@@ -648,7 +708,7 @@ export function createTutorialView(options: TutorialViewOptions): TutorialView {
return;
}
screen = { ...current, path: current.path && !at.practice };
overline.textContent = HOME_TEXTS.tutorialOverline(at.step, total, at.practice);
overline.textContent = course.texts.overline(loaded, at.step, total, at.practice);
title.textContent = step.title;
text.replaceChildren();
const paragraphs = at.practice ? [] : current.paragraphs;
@@ -669,7 +729,7 @@ export function createTutorialView(options: TutorialViewOptions): TutorialView {
}
const after = nextPlace(loaded, at);
next.textContent = HOME_TEXTS.next;
next.href = routeHash(placeRoute(after));
next.href = routeHash(course.routeOf(route, after));
next.classList.toggle("secondary", at.practice);
}
const currentScreen = screen;
+438
View File
@@ -0,0 +1,438 @@
// Tests des leçons (étape 1g) : le texte des cinq leçons (docs/lecons/) se
// lit ; chaque position est valide pour le vrai moteur ; chaque coup attendu
// d'un exercice est le meilleur coup du moteur à 2 plis, avec plus de 0,04
// d'avance sur le deuxième, et seul accepté ; chaque coup illustré est aussi
// le meilleur coup ; chaque compte de points cité est celui du moteur. Puis
// la progression : sa lecture (formats durables, ADR 0007), son
// enregistrement, les états affichés et la leçon proposée.
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
import assert from "node:assert/strict";
import { existsSync, readdirSync, readFileSync } from "node:fs";
import { test } from "node:test";
import type { Engine, EngineCube } from "../src/domain/game/engine.js";
import type { Roll } from "../src/domain/game/roll.js";
import {
finishedLessons,
LESSON_END,
LESSON_PROGRESS_VERSION,
type LessonOutline,
lessonRecord,
lessonState,
NO_PROGRESS,
nextLesson,
readLessonProgress,
resumeStep,
withSolved,
withVisit,
} from "../src/domain/learning/lessons.js";
import { MovePractice, tutorialBoard } from "../src/domain/learning/tutorial.js";
import { createKeyValueBackend } from "../src/storage/key-value-backend.js";
import { createLessonStore } from "../src/storage/lesson-store.js";
import { memoryStorage } from "../src/storage/open-library.js";
import { LESSON_COUNT, type Lesson, outlineOf, parseLesson } from "../src/ui/lesson-text.js";
import { LESSON_TEXTS } from "../src/ui/lesson-texts.js";
import { lessonCards, nextLessonCard } from "../src/ui/lessons-model.js";
import { lessonPlaceOf, lessonPlaceRoute } from "../src/ui/tutorial-nav.js";
import type { TutorialScreen } from "../src/ui/tutorial-text.js";
import { loadNodeEngine } from "./support/node-engine.js";
const root = new URL("../../../", import.meta.url);
const lessons: Lesson[] = Array.from({ length: LESSON_COUNT }, (_, i) =>
parseLesson(i + 1, readFileSync(new URL(`docs/lecons/lecon-${i + 1}.md`, root), "utf8")),
);
/** Niveau d'analyse des réponses attendues (décision de Pierre). */
const PLY = 2;
/** Avance minimale du coup attendu sur le deuxième : un exercice n'a
* qu'une bonne réponse nette. */
const MIN_GAP = 0.04;
const CENTERED: EngineCube = { value: 1, owner: "centered" };
/** Les écrans d'une leçon, avec leur nom pour les messages. */
function screens(lesson: Lesson): [string, TutorialScreen][] {
return lesson.steps.flatMap((s) => [
[`leçon ${lesson.number}, étape ${s.number}`, s.explain] as [string, TutorialScreen],
...(s.practice === null
? []
: [
[`leçon ${lesson.number}, étape ${s.number}, à vous`, s.practice] as [
string,
TutorialScreen,
],
]),
]);
}
const allScreens = () => lessons.flatMap(screens);
/** Le classement du moteur à 2 plis pour un coup : son rang, et l'écart
* avec le meilleur coup suivant (positif : le coup a cette avance). */
async function rankOf(
engine: Engine,
position: string,
roll: Roll,
notation: string,
): Promise<{ rank: number; best: string; gap: number; second: string }> {
const legal = await engine.legalMoves(position, roll);
const move = legal.find((m) => m.notation === notation);
assert.ok(move !== undefined, `${notation} n'est pas un coup légal de ${roll.join("-")}`);
const analysis = await engine.analyseMove(position, CENTERED, roll, move.position, PLY);
assert.ok(analysis !== null, `${notation} : aucune analyse`);
const second = analysis.ranking.find((m) => m.position !== analysis.best.position);
assert.ok(second !== undefined, `${notation} : un seul coup possible, rien à choisir`);
// Le deuxième coup analysé à son tour : l'analyse le réévalue au même
// niveau que le meilleur, ce qui donne l'écart exact.
const other = await engine.analyseMove(position, CENTERED, roll, second.position, PLY);
assert.ok(other !== null);
return {
rank: analysis.rank,
best: analysis.best.notation,
gap: -other.equityLoss,
second: second.notation,
};
}
test("cinq leçons, de deux à quatre exercices chacune, avec un titre et une fin", () => {
assert.equal(lessons.length, 5);
assert.deepEqual(
readdirSync(new URL("docs/lecons/", root))
.filter((f) => f.startsWith("lecon-"))
.sort(),
["lecon-1.md", "lecon-2.md", "lecon-3.md", "lecon-4.md", "lecon-5.md"],
);
assert.deepEqual(
lessons.map((l) => l.title),
[
"Les ouvertures",
"Construire ses cases",
"Frapper ou ne pas frapper",
"La course et le compte de points",
"Sortir sans se faire frapper",
],
);
for (const lesson of lessons) {
const exercises = outlineOf(lesson).exercises.length;
assert.ok(
exercises >= 2 && exercises <= 4,
`leçon ${lesson.number} : ${exercises} exercices`,
);
assert.ok(lesson.end.action !== "" && lesson.end.paragraphs.length > 0);
for (const step of lesson.steps) {
assert.ok(
step.explain.paragraphs.length > 0,
`leçon ${lesson.number}, étape ${step.number} sans texte`,
);
const practice = step.practice;
if (practice !== null) {
assert.ok(
practice.paragraphs.length > 0,
`leçon ${lesson.number} : consigne absente`,
);
assert.ok(practice.right.length > 0 && practice.wrong.length > 0);
assert.equal(practice.expected.length, 1, "un seul coup attendu par exercice");
assert.ok(practice.roll !== null);
}
}
}
});
test("le texte des leçons garde le ton du maître et les mots du glossaire", () => {
for (const lesson of lessons) {
const text = readFileSync(new URL(`docs/lecons/lecon-${lesson.number}.md`, root), "utf8");
// Pas de point d'exclamation, pas de « camp » (remplacé par les
// jans), pas de reproche.
assert.ok(!text.includes("!"), `leçon ${lesson.number} : point d'exclamation`);
assert.ok(!/\bcamp\b/i.test(text), `leçon ${lesson.number} : « camp »`);
assert.ok(
!/vous auriez dû|il fallait|erreur/i.test(text),
`leçon ${lesson.number} : reproche`,
);
assert.ok(text.includes("CC BY-SA 4.0"), `leçon ${lesson.number} : licence`);
const inlines = screens(lesson).flatMap(([, s]) => [
...s.paragraphs.flat(),
...s.right,
...s.wrong,
]);
for (const term of inlines.flatMap((i) => (i.term === undefined ? [] : [i.term]))) {
assert.ok(
existsSync(new URL(`docs/glossaire/${term}.md`, root)),
`fiche ${term}.md absente`,
);
}
}
const terms = new Set(
lessons
.flatMap((l) => screens(l).flatMap(([, s]) => s.paragraphs.flat()))
.flatMap((i) => (i.term === undefined ? [] : [i.term])),
);
for (const term of ["jan-interieur", "jan-exterieur", "point", "blot", "compte-de-points"]) {
assert.ok(terms.has(term), `aucun lien vers ${term}.md`);
}
});
test("chaque position des leçons est valide, et ses comptes de points sont ceux du moteur", async () => {
const engine = await loadNodeEngine();
let counts = 0;
for (const [name, screen] of allScreens()) {
const board = await tutorialBoard(engine, screen.position, screen.onRoll);
for (const side of ["player", "computer"] as const) {
const s = board.sides[side];
assert.equal(
s.points.reduce((a, b) => a + b, 0) + s.bar + s.off,
15,
`${name} : ${side}`,
);
}
assert.equal(board.result, null, `${name} : partie finie`);
if (screen.pips !== null) {
counts++;
assert.deepEqual(
[board.sides.player.pipCount, board.sides.computer.pipCount],
screen.pips,
`${name} : comptes de points`,
);
}
}
assert.ok(counts >= 4, "la leçon 4 cite les comptes de points");
});
test("chaque coup attendu est le meilleur coup du moteur à 2 plis, avec plus de 0,04 d'avance", async () => {
const engine = await loadNodeEngine();
const report: string[] = [];
for (const [name, screen] of allScreens()) {
const notation = screen.expected[0];
if (notation === undefined || screen.roll === null) {
continue;
}
const ranked = await rankOf(engine, screen.position, screen.roll, notation);
assert.equal(ranked.rank, 1, `${name} : le moteur préfère ${ranked.best} à ${notation}`);
assert.ok(
ranked.gap > MIN_GAP,
`${name} : ${notation} devant ${ranked.second} de ${ranked.gap.toFixed(3)} seulement`,
);
report.push(`${name} : ${notation} (+${ranked.gap.toFixed(3)} sur ${ranked.second})`);
}
assert.equal(report.length, 16);
});
test("chaque coup illustré est le meilleur coup du moteur à 2 plis", async () => {
const engine = await loadNodeEngine();
let illustrated = 0;
for (const [name, screen] of allScreens()) {
if (screen.move === null || screen.roll === null) {
continue;
}
illustrated++;
const ranked = await rankOf(engine, screen.position, screen.roll, screen.move);
assert.equal(ranked.rank, 1, `${name} : le moteur préfère ${ranked.best} à ${screen.move}`);
assert.ok(
ranked.gap > MIN_GAP,
`${name} : ${screen.move} seulement +${ranked.gap.toFixed(3)}`,
);
}
assert.ok(illustrated >= 5);
});
test("chaque exercice accepte le coup du moteur et refuse tous les autres", async () => {
const engine = await loadNodeEngine();
for (const [name, screen] of allScreens()) {
if (screen.expected.length === 0 || screen.roll === null) {
continue;
}
const legal = await engine.legalMoves(screen.position, screen.roll);
assert.ok(legal.length > 1, `${name} : rien à refuser`);
for (const move of legal) {
const practice = await MovePractice.start(
engine,
screen.position,
screen.roll,
screen.expected,
);
let verdict = "continue";
for (const halfMove of move.halfMoves) {
verdict = await practice.play(halfMove);
if (verdict !== "continue") {
break;
}
}
const right = screen.expected.includes(move.notation);
assert.equal(verdict, right ? "right" : "wrong", `${name} : ${move.notation}`);
}
}
});
// ------------------------------------------------------- Progression
const outlines: LessonOutline[] = [
{ number: 1, exercises: [1, 2, 3, 4] },
{ number: 2, exercises: [1, 2, 3] },
{ number: 3, exercises: [1, 2] },
];
test("la progression enregistrée se relit, même absente, abîmée ou d'une autre version", () => {
assert.deepEqual(readLessonProgress(undefined), NO_PROGRESS);
assert.deepEqual(readLessonProgress("texte"), NO_PROGRESS);
assert.deepEqual(readLessonProgress({ version: 99, lessons: {} }), NO_PROGRESS);
assert.deepEqual(readLessonProgress({ version: 1, lessons: null }), NO_PROGRESS);
const saved = {
version: LESSON_PROGRESS_VERSION,
lessons: {
1: { last: 3, reached: 3, solved: [2, 1, 2, -1, 1.5] },
2: { last: "x", reached: 1, solved: [] },
x: { last: 1, reached: 1, solved: [] },
4: { last: LESSON_END, reached: LESSON_END, solved: [1] },
},
};
assert.deepEqual(readLessonProgress(JSON.parse(JSON.stringify(saved))), {
version: 1,
lessons: {
1: { last: 3, reached: 3, solved: [1, 2] },
4: { last: LESSON_END, reached: LESSON_END, solved: [1] },
},
});
});
test("les états d'une leçon : à commencer, « 2 exercices sur 4 », terminée", () => {
let progress = NO_PROGRESS;
const first = outlines[0] as LessonOutline;
assert.deepEqual(lessonState(progress, first), { kind: "toStart", total: 4 });
assert.equal(LESSON_TEXTS.state(lessonState(progress, first)), "À commencer · 4 exercices");
progress = withVisit(progress, 1, 1);
assert.deepEqual(lessonState(progress, first), { kind: "inProgress", solved: 0, total: 4 });
progress = withSolved(withSolved(withVisit(progress, 1, 2), 1, 1), 1, 2);
progress = withSolved(progress, 1, 2);
assert.equal(LESSON_TEXTS.state(lessonState(progress, first)), "2 exercices sur 4");
assert.deepEqual(lessonRecord(progress, 1), { last: 2, reached: 2, solved: [1, 2] });
// Revenir en arrière garde l'étape la plus loin vue.
progress = withVisit(progress, 1, 1);
assert.deepEqual(lessonRecord(progress, 1), { last: 1, reached: 2, solved: [1, 2] });
progress = withSolved(withSolved(progress, 1, 3), 1, 4);
assert.deepEqual(lessonState(progress, first), { kind: "done", total: 4 });
assert.equal(LESSON_TEXTS.state(lessonState(progress, first)), "Terminée");
assert.equal(
LESSON_TEXTS.state({ kind: "inProgress", solved: 1, total: 3 }),
"1 exercice sur 3",
);
});
test("la leçon proposée : la première en cours, sinon la première à commencer", () => {
assert.equal(nextLesson(NO_PROGRESS, outlines)?.number, 1);
let progress = withVisit(NO_PROGRESS, 2, 1);
assert.equal(nextLesson(progress, outlines)?.number, 2);
for (const step of [1, 2, 3]) {
progress = withSolved(progress, 2, step);
}
assert.equal(nextLesson(progress, outlines)?.number, 1, "leçon 2 terminée");
assert.equal(finishedLessons(progress, outlines), 1);
for (const [number, steps] of [
[1, [1, 2, 3, 4]],
[3, [1, 2]],
] as const) {
for (const step of steps) {
progress = withSolved(progress, number, step);
}
}
assert.equal(nextLesson(progress, outlines), null, "toutes terminées");
assert.equal(finishedLessons(progress, outlines), 3);
});
test("« Continuer » reprend là où l'on a quitté la leçon", () => {
const first = outlines[0] as LessonOutline;
assert.equal(resumeStep(NO_PROGRESS, first), 1);
let progress = withVisit(NO_PROGRESS, 1, 3);
assert.equal(resumeStep(progress, first), 3);
// Après la fin, le premier exercice pas encore réussi.
progress = withSolved(withVisit(progress, 1, LESSON_END), 1, 1);
assert.equal(resumeStep(progress, first), 2);
});
test("la progression est enregistrée dans le stockage des parties et relue", async () => {
const storage = memoryStorage();
const store = createLessonStore(createKeyValueBackend(storage));
assert.deepEqual(await store.load(), NO_PROGRESS);
const progress = withSolved(withVisit(NO_PROGRESS, 3, 2), 3, 2);
void store.save(progress);
// Les enregistrements passent avant la lecture qui suit.
assert.deepEqual(await store.load(), progress);
assert.deepEqual(JSON.parse(storage.getItem("ustagammon.lessons") ?? "null"), progress);
const again = createLessonStore(createKeyValueBackend(storage));
assert.deepEqual(await again.load(), progress);
// Une valeur illisible n'empêche rien.
storage.setItem("ustagammon.lessons", "{abîmé");
assert.deepEqual(await again.load(), NO_PROGRESS);
// Un stockage qui refuse l'écriture non plus.
const refused = createLessonStore({
lessonProgress: async () => {
throw new Error("refusé");
},
saveLessonProgress: async () => {
throw new Error("refusé");
},
});
await refused.save(progress);
assert.deepEqual(await refused.load(), NO_PROGRESS);
});
test("la liste des leçons et la carte de l'accueil", () => {
const empty = lessonCards(lessons, NO_PROGRESS);
assert.deepEqual(
empty.map((c) => [c.number, c.state.kind, c.current, c.href]),
[
[1, "toStart", true, "#/lecons/1/1"],
[2, "toStart", false, "#/lecons/2/1"],
[3, "toStart", false, "#/lecons/3/1"],
[4, "toStart", false, "#/lecons/4/1"],
[5, "toStart", false, "#/lecons/5/1"],
],
);
let progress = withSolved(withVisit(NO_PROGRESS, 2, 2), 2, 1);
const card = nextLessonCard(lessons, progress);
assert.equal(card?.number, 2);
assert.equal(card?.href, "#/lecons/2/2");
assert.deepEqual(card?.exercises, [true, false, false]);
assert.equal(
LESSON_TEXTS.state(card?.state ?? { kind: "toStart", total: 0 }),
"1 exercice sur 3",
);
for (const lesson of lessons) {
for (const step of outlineOf(lesson).exercises) {
progress = withSolved(progress, lesson.number, step);
}
}
assert.equal(nextLessonCard(lessons, progress), null);
});
test("les écrans d'une leçon ont leur adresse, ramenée à un écran qui existe", () => {
const lesson = lessons[0] as Lesson;
assert.deepEqual(
lessonPlaceOf(lesson, { screen: "lesson", lesson: 1, step: 2, practice: true }),
{
step: 2,
practice: true,
},
);
assert.deepEqual(
lessonPlaceOf(lesson, { screen: "lesson", lesson: 1, step: null, practice: false }),
{
step: 1,
practice: false,
},
);
assert.deepEqual(
lessonPlaceOf(lesson, { screen: "lesson", lesson: 1, step: 9, practice: false }),
{
step: 1,
practice: false,
},
);
assert.equal(lessonPlaceOf(lesson, { screen: "lessonEnd", lesson: 1 }), "end");
assert.deepEqual(lessonPlaceRoute(3, { step: 2, practice: true }), {
screen: "lesson",
lesson: 3,
step: 2,
practice: true,
});
assert.deepEqual(lessonPlaceRoute(3, "end"), { screen: "lessonEnd", lesson: 3 });
});
+14
View File
@@ -34,6 +34,12 @@ const TABLE: [string, Route][] = [
["#/tutoriel/3", { screen: "tutorial", step: 3, practice: false }],
["#/tutoriel/3/a-vous", { screen: "tutorial", step: 3, practice: true }],
["#/tutoriel/fin", { screen: "tutorialEnd" }],
// Les leçons (amendement du 2026-11-12, 1g).
["#/lecons", { screen: "lessons" }],
["#/lecons/2", { screen: "lesson", lesson: 2, step: null, practice: false }],
["#/lecons/2/3", { screen: "lesson", lesson: 2, step: 3, practice: false }],
["#/lecons/2/3/a-vous", { screen: "lesson", lesson: 2, step: 3, practice: true }],
["#/lecons/2/fin", { screen: "lessonEnd", lesson: 2 }],
["#/installer", { screen: "install" }],
["#/parties", { screen: "games", game: null }],
["#/parties/mh2k3x-a1b2c3", { screen: "games", game: "mh2k3x-a1b2c3" }],
@@ -99,6 +105,14 @@ test("une adresse inconnue est reconnue comme telle", () => {
"#/tutoriel/3/b",
"#/tutoriel/3/a-vous/x",
"#/tutoriel/fin/a-vous",
"#/lecons/0",
"#/lecons/12",
"#/lecons/x",
"#/lecons/2/0",
"#/lecons/2/x",
"#/lecons/2/3/b",
"#/lecons/2/3/a-vous/x",
"#/lecons/2/fin/a-vous",
"#/installer/x",
"#/a-propos/x",
"#/position",
+205
View File
@@ -0,0 +1,205 @@
// Étapes du scénario docs/scenarios/suivre-une-lecon.feature (ADR 0008) :
// le texte des leçons est lu dans sa source (docs/lecons/), chaque geste est
// jugé par le domaine branché sur le vrai moteur, et la progression passe
// par le stockage des parties, 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 {
type LessonProgress,
lessonState,
NO_PROGRESS,
withSolved,
withVisit,
} from "../../src/domain/learning/lessons.js";
import {
MovePractice,
type PracticeVerdict,
tutorialBoard,
} from "../../src/domain/learning/tutorial.js";
import {
createKeyValueBackend,
type KeyValueStorage,
} from "../../src/storage/key-value-backend.js";
import { createLessonStore, type LessonStore } from "../../src/storage/lesson-store.js";
import { memoryStorage } from "../../src/storage/open-library.js";
import { LESSON_COUNT, type Lesson, outlineOf, parseLesson } from "../../src/ui/lesson-text.js";
import { LESSON_TEXTS } from "../../src/ui/lesson-texts.js";
import { lessonCards, nextLessonCard } from "../../src/ui/lessons-model.js";
import 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 lessons: Lesson[];
let storage: KeyValueStorage;
let store: LessonStore;
let progress: LessonProgress = NO_PROGRESS;
let screen: TutorialScreen;
let practice: MovePractice | null = null;
let verdict: PracticeVerdict = "continue";
BeforeAll(async () => {
engine = await loadNodeEngine();
lessons = Array.from({ length: LESSON_COUNT }, (_, i) =>
parseLesson(i + 1, readFileSync(new URL(`docs/lecons/lecon-${i + 1}.md`, root), "utf8")),
);
});
function lesson(n: number): Lesson {
const found = lessons[n - 1];
assert.ok(found !== undefined, `leçon ${n} absente`);
return found;
}
/** Enregistre la progression, comme l'application à chaque écran. */
async function save(next: LessonProgress): Promise<void> {
progress = next;
await store.save(progress);
}
Given("vous ouvrez les leçons pour la première fois", async () => {
storage = memoryStorage();
store = createLessonStore(createKeyValueBackend(storage));
progress = await store.load();
assert.deepEqual(progress, NO_PROGRESS);
});
Then("il y en a cinq, chacune avec deux à quatre exercices", () => {
assert.equal(lessons.length, 5);
for (const card of lessonCards(lessons, progress)) {
assert.equal(card.state.kind, "toStart");
assert.ok(card.state.total >= 2 && card.state.total <= 4);
}
});
Then("chaque position des leçons se lit sur le plateau", async () => {
for (const current of lessons.flatMap((l) => l.steps.flatMap((s) => [s.explain, s.practice]))) {
if (current !== null) {
const board = await tutorialBoard(engine, current.position, current.onRoll);
assert.equal(board.sides.player.points.length, 24);
}
}
});
Then("l'accueil vous propose la leçon {int}", (n: number) => {
assert.equal(nextLessonCard(lessons, progress)?.number, n);
});
When("vous réussissez le premier exercice de la leçon {int}", async (n: number) => {
const first = outlineOf(lesson(n)).exercises[0];
assert.ok(first !== undefined);
await save(withSolved(withVisit(progress, n, first), n, first));
});
Then(
"la leçon {int} en est à « {int} exercice sur {int} »",
(n: number, solved: number, total: number) => {
const state = lessonState(progress, outlineOf(lesson(n)));
assert.deepEqual(state, { kind: "inProgress", solved, total });
assert.equal(LESSON_TEXTS.state(state), `${solved} exercice sur ${total}`);
},
);
Then("l'accueil vous propose de continuer la leçon {int}", (n: number) => {
const card = nextLessonCard(lessons, progress);
assert.equal(card?.number, n);
assert.equal(card?.state.kind, "inProgress");
});
Given("vous êtes au premier exercice de la leçon {int}", async (n: number) => {
const step = lesson(n).steps.find((s) => s.practice !== null);
assert.ok(step?.practice != null && step.practice.roll !== null);
screen = step.practice;
practice = await MovePractice.start(
engine,
screen.position,
screen.roll as [number, number],
screen.expected,
);
verdict = "continue";
});
When(
"vous avancez 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 le maître vous met sur la voie", () => {
assert.equal(verdict, "wrong");
assert.deepEqual(practice?.pendingHalfMoves, []);
assert.match(screen.wrong.map((part) => part.text).join(""), /^Ce coup est permis, mais/);
});
When("vous faites le point {int}", async (point: number) => {
assert.ok(practice !== null && screen.roll !== null);
// Le coup du maître de cet exercice, demi-coup par demi-coup : il
// amène bien deux pions sur la flèche demandée.
const legal = await engine.legalMoves(screen.position, screen.roll);
const move = legal.find((m) => m.notation === screen.expected[0]);
assert.ok(move !== undefined);
assert.equal(move.halfMoves.filter((h) => h.to === point).length, 2);
for (const halfMove of move.halfMoves) {
verdict = await practice.play(halfMove);
}
});
Then("le maître salue votre coup", () => {
assert.equal(verdict, "right");
assert.match(screen.right.map((part) => part.text).join(""), /^C'est (aussi )?mon coup/);
});
Then("c'est le meilleur coup du moteur", async () => {
assert.ok(screen.roll !== null);
const best = await engine.bestMove(
screen.position,
{ value: 1, owner: "centered" },
screen.roll,
2,
);
assert.equal(best?.notation, screen.expected[0]);
});
Given("vous avez quitté la leçon {int} à sa deuxième étape", async (n: number) => {
storage = memoryStorage();
store = createLessonStore(createKeyValueBackend(storage));
progress = NO_PROGRESS;
await save(withVisit(withVisit(progress, n, 1), n, 2));
});
When("vous revenez plus tard aux leçons", async () => {
store = createLessonStore(createKeyValueBackend(storage));
progress = await store.load();
});
Then("« Continuer » vous ramène à la deuxième étape de la leçon {int}", (n: number) => {
const card = lessonCards(lessons, progress).find((c) => c.number === n);
assert.equal(card?.href, `#/lecons/${n}/2`);
assert.equal(nextLessonCard(lessons, progress)?.number, n);
});
When("vous réussissez tous les exercices de la leçon {int}", async (n: number) => {
let next = progress;
for (const step of outlineOf(lesson(n)).exercises) {
next = withSolved(withVisit(next, n, step), n, step);
}
await save(next);
});
Then("la leçon {int} est terminée", (n: number) => {
const state = lessonState(progress, outlineOf(lesson(n)));
assert.equal(state.kind, "done");
assert.equal(LESSON_TEXTS.state(state), "Terminée");
});
+26
View File
@@ -192,3 +192,29 @@ demande suivie de `/ia` :
- Le prompt contient, lui, le lien public de la position
(`https://ustagammon.sans.pub/#/position/<Position ID>/<Match ID>`),
qui se partage.
### 2026-11-12 — Adresses des leçons
Ajoutées avec les cinq premières leçons (étape 1g), dans le même schéma,
sur le modèle du tutoriel :
| Écran | Adresse |
| --- | --- |
| Liste des leçons | `#/lecons` |
| Une étape d'une leçon (son écran d'explication) | `#/lecons/<n>/<étape>` |
| L'exercice « à vous » d'une étape | `#/lecons/<n>/<étape>/a-vous` |
| Fin d'une leçon | `#/lecons/<n>/fin` |
- `<n>` est le numéro de la leçon (1 à 5 pour l'instant), `<étape>` celui
d'une étape de la leçon, à partir de 1 ; `#/lecons/<n>` mène à sa
première étape. Le `<écran>` du plan de l'étape 1g se décline ainsi en
`<étape>`, `<étape>/a-vous` ou `fin`, comme pour le tutoriel.
- Une leçon qui n'existe pas mène à la liste (`#/lecons`), une étape qui
n'existe pas à la première étape de la leçon, un exercice absent à
l'explication de son étape : jamais une page cassée.
- « Continuer » (la liste, la carte de l'accueil) mène à l'étape où l'on a
quitté la leçon ; « Quitter » et « Retour » sur la première étape
ramènent à la liste ; l'action de la fin mène à la leçon suivante, ou à la
liste après la dernière. Le menu a une ligne « Les leçons ».
- Ces adresses se partagent : le contenu des leçons est le même pour tous ;
seule la progression (barre, état) dépend de l'appareil.
+1 -1
View File
@@ -23,7 +23,7 @@ Repris du cadrage (modules, écrans clés, IA générative en V1) et des ADR 001
| 1d. Conseil du maître (fait) | Après chaque coup du joueur, jamais avant (ADR 0011) : appréciation du moteur (bon coup, imprécision, erreur, grosse erreur) avec mot, icône et couleur, phrase du maître fondée sur les chiffres du moteur, coup recommandé montré sur demande ; aussi pour les décisions de videau ; calcul en arrière-plan sans retarder le jeu | 1a |
| 1e. Fin de partie et analyse (fait) | Bilan (ADR 0011) : résultat, coups par appréciation, taux d'erreur comparé à la chance, « le coup à revoir » ; écran d'analyse : graphe d'équité (toucher un point ouvre la position), liste des erreurs filtrable, visualiseur pas à pas coup joué contre meilleur coup, « Rejouer d'ici » | 1a, 1b, 1d |
| 1f. Prompts à copier (fait) | Gabarits versionnés et testés (`docs/prompts/` ou équivalent, source unique), bouton « Demander à une IA » sur le plateau, le conseil du maître, l'analyse et la fin de partie ; le moteur décide, l'IA explique | 1d, 1e |
| 1g. Accueil, tutoriel et leçons | Accueil (reprendre, nouvelle partie, prochaine leçon, progression résumée), tutoriel des règles interactif et illustré (0e.5), cinq premières leçons numérotées (ADR 0011) avec positions et « à vous de jouer », progression locale ; contenus CC BY-SA 4.0 en brouillon, à relire par un joueur fort | 0e.5, 1a |
| 1g. Accueil, tutoriel et leçons (fait) | Accueil (reprendre, nouvelle partie, prochaine leçon, progression résumée), tutoriel des règles interactif et illustré (0e.5), cinq premières leçons numérotées (ADR 0011) avec positions et « à vous de jouer », progression locale ; contenus CC BY-SA 4.0 livrés sans relecture préalable, sous les garde-fous du moteur (décision du 2026-11-09) | 0e.5, 1a |
| 1h. Progression | Historique des parties et courbe du taux d'erreur | 1b, 1e |
| 1i. Plateau soigné | Dessin du plateau aligné sur la charte (`docs/identite/charte/board.js`), animations des pions, glisser-déposer en plus du toucher, double toucher pour le seul coup évident | 0e.3 |
| 1j. Plan de test | `docs/guide-test-etape-1.md` : parcours du débutant et du joueur de club sur le téléphone, avec ce qu'il faut rapporter | toutes |
+1
View File
@@ -65,4 +65,5 @@ Règles d'écriture :
| [regler-l-application.feature](regler-l-application.feature) | Régler l'application (menu), la mettre à jour | Étape 0e |
| [decouvrir-l-application.feature](decouvrir-l-application.feature) | Premier lancement et accueil | Étape 0e.5 |
| [apprendre-les-regles.feature](apprendre-les-regles.feature) | Apprendre les règles avec le tutoriel | Étape 0e.5 |
| [suivre-une-lecon.feature](suivre-une-lecon.feature) | Progresser avec les leçons, garder sa progression | Étape 1g |
| [installer-l-application.feature](installer-l-application.feature) | Installer l'application | Étape 0e.5 |
+46
View File
@@ -0,0 +1,46 @@
# language: fr
Fonctionnalité: Progresser avec les leçons
Cinq leçons courtes, sur le vrai plateau : les ouvertures, construire ses
cases, frapper ou non, la course et le compte de points, sortir sans se
faire frapper. Chacune explique une idée en quelques écrans, puis vous
fait jouer le coup du maître. GNU Backgammon vérifie chaque coup.
Scénario: Comment se passent les leçons ?
Les leçons sont dans le menu, et l'accueil propose la prochaine. Chaque
leçon a de deux à quatre exercices « à vous ». La liste montre où vous
en êtes : à commencer, « 1 exercice sur 3 », terminée. L'ordre est
conseillé, mais toutes les leçons restent ouvertes.
Étant donné que vous ouvrez les leçons pour la première fois
Alors il y en a cinq, chacune avec deux à quatre exercices
Et chaque position des leçons se lit sur le plateau
Et l'accueil vous propose la leçon 1
Quand vous réussissez le premier exercice de la leçon 2
Alors la leçon 2 en est à « 1 exercice sur 3 »
Et l'accueil vous propose de continuer la leçon 2
@position:4HPwATDgc/ABMA
Scénario: Je joue un autre coup que celui du maître
Sur un exercice, vous jouez sur le vrai plateau. Si votre coup n'est pas
celui du maître, le pion revient et une phrase vous met sur la voie ;
réessayez autant que vous voulez. Le coup attendu est toujours celui
que GNU Backgammon préfère, nettement devant les autres.
Étant donné que vous êtes au premier exercice de la leçon 1
Quand vous avancez un pion de la flèche 13 à la flèche 11
Alors le pion revient, et le maître vous met sur la voie
Quand vous faites le point 4
Alors le maître salue votre coup
Et c'est le meilleur coup du moteur
Scénario: Ma progression est-elle gardée ?
Oui : chaque exercice réussi est enregistré sur votre téléphone, avec
vos parties. Une leçon est terminée quand tous ses exercices sont
réussis ; « Continuer » vous ramène là où vous l'avez quittée.
Étant donné que vous avez quitté la leçon 3 à sa deuxième étape
Quand vous revenez plus tard aux leçons
Alors « Continuer » vous ramène à la deuxième étape de la leçon 3
Quand vous réussissez tous les exercices de la leçon 3
Alors la leçon 3 est terminée
+13 -4
View File
@@ -18,10 +18,12 @@
# build/browser-shots/nouvelle-partie/. Les retours des premiers joueurs
# (tests/browser/retours-joueurs.mjs : pions sur la barre, numéros sur le
# cadre, flèches du tutoriel, en thème clair et sombre), captures dans
# build/browser-shots/retours-joueurs/.
# build/browser-shots/retours-joueurs/. Les leçons (tests/browser/lecons.mjs :
# carte de l'accueil, liste, explication, exercice refusé puis réussi,
# progression gardée), captures dans build/browser-shots/lecons/.
#
# BROWSERS=chromium et SCRIPTS=accueil (ou play, match, maitre, bilan,
# prompts, nouvelle-partie, retours-joueurs) restreignent le test.
# prompts, nouvelle-partie, retours-joueurs, lecons) restreignent le test.
#
# Outils : l'image de Playwright (navigateurs) et le paquet playwright,
# installé une fois par npm dans build/browser-tools/ (réseau nécessaire la
@@ -34,7 +36,7 @@ cd "$(dirname "$0")/.."
image="${PLAYWRIGHT_IMAGE:?lancer via make test-browser}"
browsers="${BROWSERS:-chromium firefox}"
# Les scripts à lancer (tests/browser/<nom>.mjs), tous par défaut.
scripts="${SCRIPTS:-play accueil match maitre bilan prompts nouvelle-partie retours-joueurs}"
scripts="${SCRIPTS:-play accueil match maitre bilan prompts nouvelle-partie retours-joueurs lecons}"
unset MAKEFLAGS MFLAGS MAKELEVEL
make -s package >/dev/null
@@ -73,7 +75,7 @@ rm -rf build/browser-shots
mkdir -p build/browser-shots
cp tests/browser/play.mjs tests/browser/accueil.mjs tests/browser/match.mjs tests/browser/recette.mjs \
tests/browser/maitre.mjs tests/browser/bilan.mjs tests/browser/prompts.mjs tests/browser/nouvelle-partie.mjs \
tests/browser/retours-joueurs.mjs "$tools/"
tests/browser/retours-joueurs.mjs tests/browser/lecons.mjs "$tools/"
status=0
for browser in $browsers; do
# Réseau de l'hôte : le site est sur 127.0.0.1. Le service worker
@@ -136,5 +138,12 @@ for browser in $browsers; do
-v "$PWD/$tools:/tools" -v "$PWD/build/browser-shots:/shots" -w /tools "$image" \
node retours-joueurs.mjs "http://localhost:$port/" "$browser" /shots || status=1
fi
if [[ " $scripts " == *" lecons "* ]]; then
# Les leçons : carte de l'accueil, liste, exercices, progression ;
# captures dans build/browser-shots/lecons/.
docker run --rm --init --ipc=host --network host --user "$(id -u):$(id -g)" -e HOME=/tmp \
-v "$PWD/$tools:/tools" -v "$PWD/build/browser-shots:/shots" -w /tools "$image" \
node lecons.mjs "http://localhost:$port/" "$browser" /shots || status=1
fi
done
exit "$status"
+284
View File
@@ -0,0 +1,284 @@
// lecons.mjs -- les leçons (étape 1g) dans un vrai navigateur sans écran
// (Playwright), sur le site servi par l'image de production : la carte
// « prochaine leçon » de l'accueil, la liste des leçons, un écran
// d'explication, un exercice refusé puis réussi, la progression gardée après
// un rechargement, la fin d'une leçon et une adresse de leçon inconnue.
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
//
// Usage : node lecons.mjs <adresse du site> <chromium|firefox> <dossier des
// captures>. Lancé par tests/browser.sh. Captures de la recette (Chromium)
// dans <dossier des captures>/lecons/, en 412×915 et 915×412.
import { mkdirSync } from "node:fs";
import { chromium, firefox } from "playwright";
import { hiddenUnderActionBar, tallButtons } from "./recette.mjs";
const [base, browserName, shots] = process.argv.slice(2);
const browserType = { chromium, firefox }[browserName];
if (!base || !browserType || !shots) {
console.error("usage : node lecons.mjs <adresse> <chromium|firefox> <captures>");
process.exit(2);
}
const full = browserName === "chromium";
const dir = `${shots}/lecons`;
mkdirSync(dir, { recursive: true });
const SIZES = [
[412, 915],
[915, 412],
];
const browser = await browserType.launch();
const problems = [];
const log = (text) => console.log(`[${browserName}] ${text}`);
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
let captures = 0;
function check(condition, message) {
if (!condition) {
throw new Error(message);
}
}
/** Attend que test (évalué dans la page) soit vrai, au plus ms. (Pas de
* waitForFunction : Playwright y passe par eval, que la CSP interdit.) */
async function until(page, what, test, arg, ms = 30_000) {
let last = null;
for (const end = Date.now() + ms; Date.now() < end; ) {
last = await page.evaluate(test, arg).catch((error) => `page illisible : ${error.message}`);
if (last === true) {
return;
}
await sleep(100);
}
await page.screenshot({ path: `${shots}/${browserName}-lecons-echec.png` }).catch(() => {});
throw new Error(`${what} : attente dépassée (${JSON.stringify(last)})`);
}
/** Range une capture de l'écran courant dans chaque taille (Chromium), avec
* la recette : rien sous une barre d'action, aucun bouton étiré. */
async function capture(page, name, settle = async () => {}) {
if (!full) {
return;
}
for (const [width, height] of SIZES) {
await page.setViewportSize({ width, height });
await sleep(300);
await settle();
const hidden = await hiddenUnderActionBar(page);
check(hidden.length === 0, `${name} ${width}x${height} : caché sous la barre d'action : ${hidden.join(", ")}`);
const tall = await tallButtons(page);
check(tall.length === 0, `${name} ${width}x${height} : boutons étirés : ${tall.join(", ")}`);
await page.screenshot({ path: `${dir}/${name}-${width}x${height}.png`, fullPage: true });
captures++;
}
await page.setViewportSize({ width: 412, height: 915 });
await sleep(150);
}
/** L'écran d'une leçon prêt : plateau dessiné pour cette adresse, coup
* illustré joué (« Revoir » utilisable) s'il y en a un. */
async function lessonReady(page, hash) {
await until(page, `leçon ${hash}`, (hash) => {
const section = document.getElementById("lesson");
const replay = document.getElementById("lesson-replay");
return location.hash === hash && section.dataset.ready !== undefined &&
!section.hidden &&
document.querySelectorAll("#lesson-board polygon[data-point]").length === 24 &&
(replay.hidden || !replay.disabled);
}, hash);
}
/** Touche des cases du plateau de la leçon, groupe par groupe (un groupe :
* un demi-coup). */
async function playTouches(page, touches) {
for (const group of touches) {
for (const place of group) {
await page.click(`#lesson-board [data-place="${place}"]`);
await sleep(200);
}
await until(page, "demi-coup joué", () => !document.querySelector("#lesson[data-busy]"));
await sleep(150);
}
}
async function verdict(page) {
await until(page, "verdict", () => {
const v = document.getElementById("lesson-feedback").dataset.verdict;
return v === "right" || v === "wrong";
});
return page.evaluate(() => document.getElementById("lesson-feedback").dataset.verdict);
}
/** L'état affiché de chaque carte de la liste. */
async function listStates(page) {
return page.evaluate(() =>
[...document.querySelectorAll("#lesson-list .lesson-card")].map((card) => ({
id: card.id,
current: card.classList.contains("current"),
state: card.querySelector(".lesson-card-state")?.textContent ?? "",
done: card.querySelectorAll(".lesson-progress .segment.done").length,
})));
}
const context = await browser.newContext({
viewport: { width: 412, height: 915 },
deviceScaleFactor: 1,
locale: "fr-FR",
});
await context.addInitScript(() => {
window.__csp = [];
document.addEventListener("securitypolicyviolation", (e) =>
window.__csp.push(`${e.effectiveDirective} ${e.blockedURI}`),
);
// Le premier lancement est passé : l'accueil complet.
if (localStorage.getItem("ustagammon.settings") === null) {
localStorage.setItem("ustagammon.settings", JSON.stringify({ welcomed: true, tutorialSeen: 99 }));
}
});
const page = await context.newPage();
page.on("pageerror", (error) => problems.push(`erreur de la page : ${error.message}`));
page.on("console", (message) => {
if (message.type() === "error") {
problems.push(`console : ${message.text()}`);
}
});
page.on("dialog", (dialog) => {
problems.push(`boîte native : ${dialog.type()}`);
void dialog.dismiss();
});
try {
// ------------------------------------------- La carte de l'accueil
await page.goto(`${base}#/accueil`);
await until(page, "carte de la leçon", () => {
const card = document.getElementById("home-lesson");
return location.hash === "#/accueil" && !card.hidden && !card.closest("[hidden]") &&
card.textContent.includes("Leçon 1") && !document.getElementById("home-new").disabled;
});
const home = await page.evaluate(() => ({
go: document.getElementById("home-lesson-go").getAttribute("href"),
goText: document.getElementById("home-lesson-go").textContent,
goClass: document.getElementById("home-lesson-go").className,
all: document.getElementById("home-lessons").getAttribute("href"),
title: document.querySelector("#home-lesson .lesson-card-title").textContent,
state: document.querySelector("#home-lesson .lesson-card-state").textContent,
summary: document.getElementById("home-progress").textContent,
primaries: [...document.querySelectorAll("#home a.button:not(.secondary), #home button:not(.secondary):not(.icon-button)")]
.filter((el) => !el.closest("[hidden]")).length,
}));
check(home.go === "#/lecons/1/1" && home.goText === "Commencer" && home.goClass.includes("secondary") &&
home.all === "#/lecons" && home.title === "Les ouvertures" && home.state === "À commencer · 4 exercices" &&
home.summary.startsWith("0 leçon terminée sur 5") && home.primaries === 1,
`carte de l'accueil : ${JSON.stringify(home)}`);
await capture(page, "01-accueil-carte", async () => {
await page.locator("#home-lesson").scrollIntoViewIfNeeded();
});
log("accueil : carte « prochaine leçon » (leçon 1, à commencer), « Commencer » en secondaire");
// --------------------------------------------------------- La liste
await page.click("#home-lessons");
await until(page, "liste des leçons", () => location.hash === "#/lecons" &&
document.querySelectorAll("#lesson-list .lesson-card").length === 5);
const list = await listStates(page);
check(list[0].current && list.filter((c) => c.current).length === 1 &&
list.every((c) => c.state.startsWith("À commencer")), `liste : ${JSON.stringify(list)}`);
const primaries = await page.evaluate(() =>
[...document.querySelectorAll("#lessons a.button:not(.secondary)")].filter((el) => !el.closest("[hidden]")).length);
check(primaries === 1, `liste : ${primaries} actions principales`);
check(await page.getAttribute("#lessons-rules", "href") === "#/tutoriel/1", "liste : rappel des règles");
await capture(page, "02-liste");
log("liste : cinq leçons, la première proposée, une seule action principale, rappel des règles");
// ------------------------------------------- Un écran d'explication
await page.click("#lesson-card-1 .lesson-card-action");
await lessonReady(page, "#/lecons/1/1");
const explain = await page.evaluate(() => ({
overline: document.getElementById("lesson-overline").textContent,
title: document.getElementById("lesson-title").textContent,
segments: document.querySelectorAll("#lesson-progress .segment").length,
skip: document.getElementById("lesson-skip").getAttribute("href"),
back: document.getElementById("lesson-back").getAttribute("href"),
next: document.getElementById("lesson-next").getAttribute("href"),
topbar: getComputedStyle(document.querySelector(".topbar")).display,
glossary: [...document.querySelectorAll("#lesson-text a")].map((a) => a.getAttribute("href")),
}));
check(explain.overline === "Leçon 1 · 1 sur 4" && explain.title === "Un point dès le premier coup" &&
explain.segments === 4 && explain.skip === "#/lecons" && explain.back === "#/lecons" &&
explain.next === "#/lecons/1/1/a-vous" && explain.topbar === "none" &&
explain.glossary.includes("#/glossaire/jan-interieur"), `explication : ${JSON.stringify(explain)}`);
check(await page.locator("#lesson-board .trace-arrow").count() > 0, "le coup illustré n'a pas laissé de traces");
await capture(page, "03-explication");
log("explication : coup illustré 8/5 6/5 joué tout seul, barre de quatre étapes, liens du glossaire");
// ------------------------------------------- Un exercice « à vous »
await page.click("#lesson-next");
await lessonReady(page, "#/lecons/1/1/a-vous");
// 13/11 : ne mène pas à 8/4 6/4, refusé.
await playTouches(page, [["p13", "p11"]]);
check(await verdict(page) === "wrong", "exercice : 13/11 accepté");
await until(page, "pion revenu", () => document.querySelectorAll("#lesson-board .hit.can-move").length > 0);
const refused = await page.textContent("#lesson-feedback");
check(refused.startsWith("Ce coup est permis, mais") && !/erreur/i.test(refused), `refus : ${refused}`);
await capture(page, "04-exercice-refuse");
await playTouches(page, [["p8", "p4"], ["p6"]]);
check(await verdict(page) === "right", "exercice : 8/4 6/4 refusé");
check(await page.locator("#lesson-board .hit.can-move").count() === 0, "plateau encore jouable après le bon coup");
const right = await page.textContent("#lesson-feedback");
check(right.includes("8/4 6/4"), `réussite : ${right}`);
check(!(await page.evaluate(() => document.getElementById("lesson-next").classList.contains("secondary"))),
"« Continuer » pas principal après le bon coup");
await capture(page, "05-exercice-reussi");
log("exercice : 13/11 refusé avec bienveillance, 8/4 6/4 salué");
// ------------------------------------ La progression, gardée
await page.goto(`${base}#/lecons`);
await page.reload();
await until(page, "liste après rechargement", () => location.hash === "#/lecons" &&
document.querySelectorAll("#lesson-list .lesson-card").length === 5 &&
document.querySelector("#lesson-card-1 .lesson-card-state")?.textContent === "1 exercice sur 4");
const after = await listStates(page);
check(after[0].current && after[0].done === 1, `liste après l'exercice : ${JSON.stringify(after)}`);
await page.goto(`${base}#/accueil`);
await until(page, "accueil après l'exercice", () =>
document.querySelector("#home-lesson .lesson-card-state")?.textContent === "1 exercice sur 4" &&
document.getElementById("home-lesson-go").textContent === "Continuer" &&
document.getElementById("home-lesson-go").getAttribute("href") === "#/lecons/1/1");
log("progression : « 1 exercice sur 4 » après rechargement, « Continuer » sur l'accueil");
// --------------------------------------- La fin d'une leçon
await page.goto(`${base}#/lecons/1/fin`);
await lessonReady(page, "#/lecons/1/fin");
const end = await page.evaluate(() => ({
overline: document.getElementById("lesson-overline").textContent,
next: document.getElementById("lesson-next").getAttribute("href"),
text: document.getElementById("lesson-next").textContent,
}));
check(end.overline === "Leçon 1 · fin" && end.next === "#/lecons/2/1" && end.text === "Leçon suivante",
`fin : ${JSON.stringify(end)}`);
await page.click("#lesson-next");
await lessonReady(page, "#/lecons/2/1");
log("fin : « Leçon suivante » mène à la leçon 2");
// Une leçon inconnue : la liste ; une étape inconnue : la première.
await page.goto(`${base}#/lecons/9/1`);
await until(page, "leçon inconnue", () => location.hash === "#/lecons" &&
document.querySelectorAll("#lesson-list .lesson-card").length === 5);
await page.goto(`${base}#/lecons/3/9`);
await lessonReady(page, "#/lecons/3/1");
log("adresses : leçon inconnue vers la liste, étape inconnue vers la première");
const csp = await page.evaluate(() => window.__csp);
check(csp.length === 0, `violations de la CSP : ${csp.join(", ")}`);
check(problems.length === 0, problems.join("\n"));
log(`leçons : OK${full ? `, ${captures} captures dans ${dir}` : ""}`);
} catch (error) {
console.error(`[${browserName}] ÉCHEC : ${error.message}`);
if (problems.length > 0) {
console.error(problems.join("\n"));
}
await browser.close();
process.exit(1);
}
await browser.close();
+6
View File
@@ -87,6 +87,12 @@ cmp -s <(curl -s "$base/docs/glossaire/videau.md") docs/glossaire/videau.md \
check "docs/tutoriel/regles.md" "text/markdown"
cmp -s <(curl -s "$base/docs/tutoriel/regles.md") docs/tutoriel/regles.md \
|| report "/docs/tutoriel/regles.md diffère de la source"
# Leçons : copie de leur source unique (ADR 0008, étape 1g).
for lesson in 1 2 3 4 5; do
check "docs/lecons/lecon-$lesson.md" "text/markdown"
cmp -s <(curl -s "$base/docs/lecons/lecon-$lesson.md") "docs/lecons/lecon-$lesson.md" \
|| report "/docs/lecons/lecon-$lesson.md diffère de la source"
done
# Prompts pour une IA : copie de leur source unique (ADR 0008, étape 1f).
for template in coup videau bilan concept exercices; do
check "docs/prompts/$template.md" "text/markdown"
+189 -3
View File
@@ -1849,6 +1849,191 @@ html.bare .topbar {
height: 28px;
}
/* Leçons (1g) : la liste (#/lecons) et la carte « prochaine leçon » de
* l'accueil (docs/identite/charte/ecrans-etape-1.md, « 1g. Leçons »). */
.lessons {
max-width: var(--page-width);
margin: 0 auto;
padding-bottom: var(--space-6);
}
.lessons-intro {
padding-top: var(--space-2);
}
.lessons-intro h2 {
margin: 0 0 var(--space-2);
font-size: 32px;
line-height: 36px;
font-weight: 800;
letter-spacing: -0.01em;
}
.lessons-intro p {
margin: 0;
}
.lessons-rules {
display: flex;
align-items: center;
min-height: 44px;
margin-top: var(--space-2);
}
.lessons-rules a {
display: inline-flex;
align-items: center;
min-height: 44px;
margin-left: var(--space-1);
font-weight: 700;
color: var(--accent);
}
#lessons-status:empty {
display: none;
}
.lesson-list {
display: flex;
flex-direction: column;
gap: var(--space-3);
margin: var(--space-2) 0 0;
padding: 0;
list-style: none;
}
.lesson-card {
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.lesson-card.current {
border: 2px solid var(--accent);
}
.lesson-card-top {
display: flex;
gap: var(--space-3);
align-items: center;
}
.lesson-card-body {
display: flex;
flex: 1;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.lesson-card-title {
font-size: 17px;
line-height: 25px;
font-weight: 700;
}
.lesson-card-state {
font-size: 15px;
line-height: 20px;
color: var(--muted);
}
.lesson-card-state.done {
color: var(--verdict-good);
font-weight: 700;
}
/* La pastille ronde au numéro : contour pour une leçon à commencer, brique
* pour la leçon proposée, verte une fois terminée. */
.lesson-badge {
display: inline-flex;
flex: none;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
font-size: 17px;
font-weight: 800;
color: var(--text);
border: 2px solid var(--border-strong);
border-radius: var(--radius-pill);
}
.lesson-badge.current {
color: var(--on-accent);
background: var(--accent);
border-color: var(--accent);
}
.lesson-badge.done {
color: var(--on-accent);
background: var(--verdict-good);
border-color: var(--verdict-good);
}
/* La barre de progression : un segment par exercice. */
.lesson-progress {
display: grid;
grid-auto-columns: minmax(0, 1fr);
grid-auto-flow: column;
gap: var(--space-1);
margin-top: var(--space-1);
}
.lesson-progress .segment {
display: block;
height: 6px;
background: var(--surface-sunken);
border-radius: var(--radius-pill);
}
.lesson-progress .segment.done {
background: var(--accent);
}
.lesson-card-action {
align-self: stretch;
}
.home-lesson {
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.home-lesson-info {
display: flex;
flex-direction: column;
gap: 2px;
}
.home-lesson-info .muted {
font-size: 15px;
line-height: 20px;
}
.home-lesson-actions {
display: flex;
gap: var(--space-3);
align-items: center;
}
.home-lesson-actions .button {
flex: 1 1 0;
min-width: 0;
padding: 0 12px;
}
.home-lessons-link {
display: inline-flex;
flex: 1 1 0;
align-items: center;
justify-content: center;
min-height: 44px;
font-weight: 700;
color: var(--accent);
}
/* Feuille d'installation. */
.sheet-head .install-symbol {
width: 40px;
@@ -1909,7 +2094,8 @@ html.bare .topbar {
.tutorial-progress {
display: grid;
flex: 1;
grid-template-columns: repeat(8, minmax(0, 1fr));
grid-auto-columns: minmax(0, 1fr);
grid-auto-flow: column;
gap: var(--space-1);
}
@@ -1966,7 +2152,7 @@ html.bare .topbar {
}
}
#tutorial-replay {
.tutorial-board-box .tutorial-replay {
position: absolute;
right: var(--space-1);
bottom: calc(-44px - var(--space-1));
@@ -2119,7 +2305,7 @@ html.landscape .tutorial-board {
background: var(--frame);
}
html.landscape #tutorial-replay {
html.landscape .tutorial-board-box .tutorial-replay {
position: static;
grid-row: 2;
grid-column: 2;
+6
View File
@@ -96,6 +96,11 @@
est construit par app/src/ui/home-view.ts et tutorial-view.ts. -->
<section id="home" class="home page" hidden></section>
<section id="tutorial" class="tutorial page" hidden></section>
<!-- Les leçons (#/lecons, #/lecons/<n>/...) : la liste, construite par
app/src/ui/lessons-view.ts, et les écrans d'une leçon, construits par
tutorial-view.ts comme ceux du tutoriel. -->
<section id="lessons" class="lessons page" hidden></section>
<section id="lesson" class="tutorial page" hidden></section>
<!-- Le choix d'une nouvelle partie (#/nouvelle-partie), construit par
app/src/ui/new-game-view.ts. -->
@@ -146,6 +151,7 @@
<a id="menu-games" href="#/parties" class="item"></a>
<a id="menu-home" href="#/accueil" class="item"></a>
<a id="menu-tutorial" href="#/tutoriel/1" class="item"></a>
<a id="menu-lessons" href="#/lecons" class="item"></a>
<button id="menu-copy-link" type="button" class="item"></button>
<button id="menu-fullscreen" type="button" class="item"></button>
<a id="menu-help" href="#/aide" class="item"></a>