Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
094f535c84 | ||
|
|
0aec6cb7b1 | ||
|
|
835a907b9c | ||
|
|
6dd0e93d3d |
@@ -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 leçons, des phrases du
|
||||
# - les copies du glossaire, des scénarios, du tutoriel, des phrases du
|
||||
# maître et des prompts pour une IA sous docs/, lues par l'aide, le
|
||||
# tutoriel, les leçons, le conseil du maître et « Demander à une IA »
|
||||
# tutoriel, le conseil du maître et « Demander à une IA »
|
||||
# (ADR 0008 : jamais commitées
|
||||
# ailleurs) ;
|
||||
# - version.json (commit publié) ;
|
||||
@@ -143,8 +143,7 @@ site: engine-wasm web ## Assemble le site publié dans build/site/
|
||||
cp -r $(BUILD_DIR)/web/src $(BUILD_DIR)/site/app
|
||||
cp $(BUILD_DIR)/wasm/engine.mjs $(BUILD_DIR)/wasm/engine.wasm $(BUILD_DIR)/wasm/engine.data \
|
||||
$(BUILD_DIR)/wasm/positions.json $(BUILD_DIR)/site/wasm/
|
||||
cp -r docs/glossaire docs/scenarios docs/tutoriel docs/lecons docs/maitre docs/prompts \
|
||||
$(BUILD_DIR)/site/docs/
|
||||
cp -r docs/glossaire docs/scenarios docs/tutoriel 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
|
||||
|
||||
@@ -365,6 +365,83 @@ export class Game {
|
||||
return moves.filter((_, i) => checks[i]?.valid === true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Le coup évident qui commence par le pion de from (une case du joueur,
|
||||
* ou la barre : BAR), après les demi-coups already (par défaut, ceux
|
||||
* déjà saisis) : les demi-coups qui le complètent, ou null s'il n'y en
|
||||
* a pas
|
||||
* (charte, ecrans-etape-1.md, 1i : le double toucher). Il existe quand
|
||||
* un seul coup légal complet commence par ce pion, ou quand le moteur ne
|
||||
* propose qu'un coup. Deux coups sont différents s'ils mènent à des
|
||||
* positions différentes, comme dans la liste des coups de gnubg.
|
||||
*
|
||||
* Aucune règle n'est jugée ici : chaque demi-coup essayé est soumis au
|
||||
* moteur (checkHalfMoves), qui dit s'il commence un coup légal et si le
|
||||
* coup est complet. On parcourt ses réponses et on s'arrête dès que deux
|
||||
* coups complets différents apparaissent.
|
||||
*/
|
||||
async obviousMove(
|
||||
from: number,
|
||||
already: readonly HalfMove[] = this.pendingHalfMoves,
|
||||
): Promise<HalfMove[] | null> {
|
||||
// Lus avant toute attente : la réponse vaut pour l'état de l'appel.
|
||||
const state = this.state;
|
||||
const pending = [...already];
|
||||
if (state.turn !== "player" || state.phase !== "moving") {
|
||||
return null;
|
||||
}
|
||||
const roll = rollToPlay(state);
|
||||
const limit = (roll[0] === roll[1] ? 4 : 2) - pending.length;
|
||||
if (limit <= 0) {
|
||||
return null;
|
||||
}
|
||||
let displayed = state.position;
|
||||
if (pending.length > 0) {
|
||||
const check = await this.#engine.checkHalfMoves(state.position, roll, pending);
|
||||
if (!check.valid || check.position === null) {
|
||||
return null;
|
||||
}
|
||||
displayed = check.position;
|
||||
}
|
||||
const legal = await this.#engine.legalMoves(state.position, roll);
|
||||
// Un seul coup légal : il est évident, quel que soit le pion.
|
||||
const anyChecker = legal.length === 1;
|
||||
const found = new Map<PositionId, HalfMove[]>();
|
||||
const explore = async (sequence: HalfMove[], position: PositionId): Promise<void> => {
|
||||
if (found.size > 1 || sequence.length >= limit) {
|
||||
return;
|
||||
}
|
||||
const { player } = await this.#sideBoards(position, "player", state);
|
||||
const starts = sequence.length === 0 && !anyChecker ? [from] : occupiedPoints(player);
|
||||
const candidates = new Map<string, HalfMove>();
|
||||
for (const start of starts) {
|
||||
for (const die of roll) {
|
||||
const to = Math.max(start - die, OFF);
|
||||
candidates.set(`${start}/${to}`, { from: start, to });
|
||||
}
|
||||
}
|
||||
for (const halfMove of candidates.values()) {
|
||||
if (found.size > 1) {
|
||||
return;
|
||||
}
|
||||
const next = [...sequence, halfMove];
|
||||
const check = await this.#engine.checkHalfMoves(state.position, roll, [
|
||||
...pending,
|
||||
...next,
|
||||
]);
|
||||
if (!check.valid || check.position === null) {
|
||||
continue;
|
||||
}
|
||||
if (check.complete && !found.has(check.position)) {
|
||||
found.set(check.position, next);
|
||||
}
|
||||
await explore(next, check.position);
|
||||
}
|
||||
};
|
||||
await explore([], displayed);
|
||||
return found.size === 1 ? ([...found.values()][0] ?? null) : null;
|
||||
}
|
||||
|
||||
/** Le joueur peut-il doubler maintenant ? (Le moteur dit si le videau le
|
||||
* permet.) */
|
||||
async canDouble(): Promise<boolean> {
|
||||
|
||||
@@ -1,184 +0,0 @@
|
||||
// La progression dans les leçons (ADR 0011, étape 1g) : où en est chaque
|
||||
// leçon, et laquelle proposer ensuite. Code pur, testé sous Node
|
||||
// (app/tests/lessons.test.ts).
|
||||
//
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
//
|
||||
// Le contenu des leçons vit dans docs/lecons/ (ADR 0008) ; ici, une leçon
|
||||
// n'est que son numéro et les étapes qui portent un exercice « à vous ».
|
||||
// La progression est enregistrée telle quelle (ADR 0007, amendement du
|
||||
// 2026-11-12) : son format est durable, et readLessonProgress relit toute
|
||||
// version enregistrée.
|
||||
|
||||
/** Version du format de la progression enregistrée. */
|
||||
export const LESSON_PROGRESS_VERSION = 1;
|
||||
|
||||
/** reached quand l'écran de fin d'une leçon a été vu. */
|
||||
export const LESSON_END = 99;
|
||||
|
||||
/** Où en est une leçon. */
|
||||
export interface LessonRecord {
|
||||
/** La dernière étape vue (0 : aucune ; LESSON_END : la fin). */
|
||||
readonly last: number;
|
||||
/** L'étape la plus loin déjà vue : la barre de progression ne mène
|
||||
* qu'aux étapes vues. */
|
||||
readonly reached: number;
|
||||
/** Les étapes dont l'exercice « à vous » a été réussi, dans l'ordre. */
|
||||
readonly solved: readonly number[];
|
||||
}
|
||||
|
||||
/** La progression dans toutes les leçons, par numéro de leçon. */
|
||||
export interface LessonProgress {
|
||||
readonly version: typeof LESSON_PROGRESS_VERSION;
|
||||
readonly lessons: Readonly<Record<string, LessonRecord>>;
|
||||
}
|
||||
|
||||
export const NO_PROGRESS: LessonProgress = { version: LESSON_PROGRESS_VERSION, lessons: {} };
|
||||
|
||||
const NO_RECORD: LessonRecord = { last: 0, reached: 0, solved: [] };
|
||||
|
||||
/** Ce qu'une leçon offre à la progression : son numéro et les étapes qui
|
||||
* ont un exercice « à vous ». */
|
||||
export interface LessonOutline {
|
||||
readonly number: number;
|
||||
readonly exercises: readonly number[];
|
||||
}
|
||||
|
||||
function step(value: unknown): number | null {
|
||||
return typeof value === "number" && Number.isInteger(value) && value >= 0 && value <= LESSON_END
|
||||
? value
|
||||
: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* La progression enregistrée, relue : absente (avant l'étape 1g) ou
|
||||
* illisible, elle est vide ; une leçon mal formée est oubliée, sans toucher
|
||||
* aux autres. Une version inconnue (enregistrée par une version future de
|
||||
* l'application) est lue comme vide plutôt que mal comprise.
|
||||
*/
|
||||
export function readLessonProgress(value: unknown): LessonProgress {
|
||||
if (value === null || typeof value !== "object") {
|
||||
return NO_PROGRESS;
|
||||
}
|
||||
const raw = value as { version?: unknown; lessons?: unknown };
|
||||
if (raw.version !== LESSON_PROGRESS_VERSION) {
|
||||
return NO_PROGRESS;
|
||||
}
|
||||
if (raw.lessons === null || typeof raw.lessons !== "object") {
|
||||
return NO_PROGRESS;
|
||||
}
|
||||
const lessons: Record<string, LessonRecord> = {};
|
||||
for (const [key, entry] of Object.entries(raw.lessons as Record<string, unknown>)) {
|
||||
if (!/^[1-9][0-9]?$/.test(key) || entry === null || typeof entry !== "object") {
|
||||
continue;
|
||||
}
|
||||
const record = entry as { last?: unknown; reached?: unknown; solved?: unknown };
|
||||
const last = step(record.last);
|
||||
const reached = step(record.reached);
|
||||
const solved = Array.isArray(record.solved)
|
||||
? record.solved.map(step).filter((s): s is number => s !== null && s > 0)
|
||||
: null;
|
||||
if (last === null || reached === null || solved === null) {
|
||||
continue;
|
||||
}
|
||||
lessons[key] = { last, reached, solved: [...new Set(solved)].sort((a, b) => a - b) };
|
||||
}
|
||||
return { version: LESSON_PROGRESS_VERSION, lessons };
|
||||
}
|
||||
|
||||
/** Où en est la leçon number (rien de fait si elle n'a jamais été ouverte). */
|
||||
export function lessonRecord(progress: LessonProgress, number: number): LessonRecord {
|
||||
return progress.lessons[String(number)] ?? NO_RECORD;
|
||||
}
|
||||
|
||||
function withRecord(
|
||||
progress: LessonProgress,
|
||||
number: number,
|
||||
change: (record: LessonRecord) => LessonRecord,
|
||||
): LessonProgress {
|
||||
return {
|
||||
version: LESSON_PROGRESS_VERSION,
|
||||
lessons: { ...progress.lessons, [String(number)]: change(lessonRecord(progress, number)) },
|
||||
};
|
||||
}
|
||||
|
||||
/** La progression après l'arrivée sur une étape d'une leçon (LESSON_END :
|
||||
* sa fin). */
|
||||
export function withVisit(progress: LessonProgress, number: number, at: number): LessonProgress {
|
||||
return withRecord(progress, number, (record) => ({
|
||||
...record,
|
||||
last: at,
|
||||
reached: Math.max(record.reached, at),
|
||||
}));
|
||||
}
|
||||
|
||||
/** La progression après la réussite de l'exercice d'une étape. */
|
||||
export function withSolved(progress: LessonProgress, number: number, at: number): LessonProgress {
|
||||
return withRecord(progress, number, (record) =>
|
||||
record.solved.includes(at)
|
||||
? record
|
||||
: { ...record, solved: [...record.solved, at].sort((a, b) => a - b) },
|
||||
);
|
||||
}
|
||||
|
||||
/** L'état d'une leçon, tel que l'affichent la liste et l'accueil : à
|
||||
* commencer, en cours (« 2 exercices sur 4 »), terminée (tous ses
|
||||
* exercices réussis). */
|
||||
export type LessonState =
|
||||
| { readonly kind: "toStart"; readonly total: number }
|
||||
| { readonly kind: "inProgress"; readonly solved: number; readonly total: number }
|
||||
| { readonly kind: "done"; readonly total: number };
|
||||
|
||||
export function lessonState(progress: LessonProgress, outline: LessonOutline): LessonState {
|
||||
const record = lessonRecord(progress, outline.number);
|
||||
const total = outline.exercises.length;
|
||||
const solved = outline.exercises.filter((s) => record.solved.includes(s)).length;
|
||||
if (solved === total && (total > 0 || record.reached === LESSON_END)) {
|
||||
return { kind: "done", total };
|
||||
}
|
||||
if (solved === 0 && record.reached === 0) {
|
||||
return { kind: "toStart", total };
|
||||
}
|
||||
return { kind: "inProgress", solved, total };
|
||||
}
|
||||
|
||||
/**
|
||||
* La leçon à proposer ensuite (carte de l'accueil, « Continuer » de la
|
||||
* liste) : la première en cours, sinon la première à commencer, sinon
|
||||
* aucune (toutes terminées). L'ordre est conseillé, jamais imposé.
|
||||
*/
|
||||
export function nextLesson(
|
||||
progress: LessonProgress,
|
||||
outlines: readonly LessonOutline[],
|
||||
): LessonOutline | null {
|
||||
const states = outlines.map((outline) => ({ outline, state: lessonState(progress, outline) }));
|
||||
return (
|
||||
states.find((s) => s.state.kind === "inProgress")?.outline ??
|
||||
states.find((s) => s.state.kind === "toStart")?.outline ??
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* L'étape où reprendre une leçon : là où on l'a quittée ; après sa fin, le
|
||||
* premier exercice pas encore réussi ; sinon le début.
|
||||
*/
|
||||
export function resumeStep(progress: LessonProgress, outline: LessonOutline): number {
|
||||
const record = lessonRecord(progress, outline.number);
|
||||
if (record.last > 0 && record.last < LESSON_END) {
|
||||
return record.last;
|
||||
}
|
||||
if (record.last === LESSON_END) {
|
||||
return outline.exercises.find((s) => !record.solved.includes(s)) ?? 1;
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
/** Le nombre de leçons terminées. */
|
||||
export function finishedLessons(
|
||||
progress: LessonProgress,
|
||||
outlines: readonly LessonOutline[],
|
||||
): number {
|
||||
return outlines.filter((outline) => lessonState(progress, outline).kind === "done").length;
|
||||
}
|
||||
+2
-10
@@ -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, lessons } = await openGameLibrary({
|
||||
const { library } = 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, lessons };
|
||||
return { library, session, saved };
|
||||
})();
|
||||
let stored: Awaited<typeof opening> | null = null;
|
||||
/** Le stockage, ouvert (toujours le cas une fois engineReady résolue). */
|
||||
@@ -154,14 +154,6 @@ 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
|
||||
|
||||
@@ -59,10 +59,6 @@ export interface LibraryBackend {
|
||||
remove(id: string): Promise<void>;
|
||||
currentId(): Promise<string | undefined>;
|
||||
setCurrentId(id: string): Promise<void>;
|
||||
/** La progression dans les leçons (étape 1g), telle qu'enregistrée
|
||||
* (undefined : jamais enregistrée) ; relue par readLessonProgress. */
|
||||
lessonProgress(): Promise<unknown>;
|
||||
saveLessonProgress(progress: unknown): Promise<void>;
|
||||
}
|
||||
|
||||
export interface LibraryOptions {
|
||||
|
||||
@@ -6,8 +6,7 @@
|
||||
// Base « ustagammon », trois magasins d'objets :
|
||||
// - games : les résumés des parties (SavedGameSummary), clé id ;
|
||||
// - journals : leurs journaux, { id, journal }, clé id ;
|
||||
// - settings : { key, value }, dont la partie en cours (key « current ») et
|
||||
// la progression dans les leçons (key « lessons », étape 1g).
|
||||
// - settings : { key, value }, dont la partie en cours (key « current »).
|
||||
// Le résumé et le journal d'une partie sont écrits dans la même transaction.
|
||||
// L'API IndexedDB est utilisée directement, sans bibliothèque.
|
||||
|
||||
@@ -20,9 +19,6 @@ const VERSION = 1;
|
||||
const GAMES = "games";
|
||||
const JOURNALS = "journals";
|
||||
const SETTINGS = "settings";
|
||||
/** Clé de la progression dans les leçons, dans le magasin settings (étape
|
||||
* 1g : nouvelle clé, même schéma, version 1 de la base). */
|
||||
const LESSONS = "lessons";
|
||||
|
||||
/** Le résultat d'une requête IndexedDB, en promesse. */
|
||||
function done<T>(request: IDBRequest<T>): Promise<T> {
|
||||
@@ -81,11 +77,5 @@ export async function openIndexedDbBackend(factory: IDBFactory): Promise<Library
|
||||
transaction.objectStore(SETTINGS).put({ key: "current", value: id });
|
||||
await committed(transaction);
|
||||
},
|
||||
lessonProgress: async () => (await get<{ value: unknown }>(SETTINGS, LESSONS))?.value,
|
||||
saveLessonProgress: async (progress) => {
|
||||
const transaction = db.transaction(SETTINGS, "readwrite");
|
||||
transaction.objectStore(SETTINGS).put({ key: LESSONS, value: progress });
|
||||
await committed(transaction);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -19,8 +19,6 @@ export interface KeyValueStorage {
|
||||
const SUMMARIES = "ustagammon.games";
|
||||
const CURRENT = "ustagammon.current-game-id";
|
||||
const journalKey = (id: string) => `ustagammon.game.${id}`;
|
||||
/** La progression dans les leçons (étape 1g). */
|
||||
const LESSONS = "ustagammon.lessons";
|
||||
|
||||
/** La partie unique des versions précédentes (étape 0), à migrer. */
|
||||
const LEGACY_GAME = "ustagammon.current-game";
|
||||
@@ -62,8 +60,6 @@ export function createKeyValueBackend(storage: KeyValueStorage): LibraryBackend
|
||||
return typeof id === "string" ? id : undefined;
|
||||
},
|
||||
setCurrentId: async (id) => storage.setItem(CURRENT, JSON.stringify(id)),
|
||||
lessonProgress: async () => read(storage, LESSONS),
|
||||
saveLessonProgress: async (progress) => storage.setItem(LESSONS, JSON.stringify(progress)),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
// La progression dans les leçons (étape 1g), dans le stockage des parties
|
||||
// (IndexedDB, ou localStorage en repli) : une seule valeur, relue par le
|
||||
// domaine (readLessonProgress), qui en connaît le format.
|
||||
//
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
|
||||
import { type LessonProgress, readLessonProgress } from "../domain/learning/lessons.js";
|
||||
import type { LibraryBackend } from "./game-library.js";
|
||||
|
||||
export interface LessonStore {
|
||||
/** La progression enregistrée (vide si rien n'est enregistré ou si le
|
||||
* stockage est illisible). */
|
||||
load(): Promise<LessonProgress>;
|
||||
/** Enregistre la progression ; les enregistrements se font dans l'ordre
|
||||
* des appels. Un stockage refusé ne fait pas échouer l'appel. */
|
||||
save(progress: LessonProgress): Promise<void>;
|
||||
}
|
||||
|
||||
export function createLessonStore(
|
||||
backend: Pick<LibraryBackend, "lessonProgress" | "saveLessonProgress">,
|
||||
): LessonStore {
|
||||
let queue: Promise<unknown> = Promise.resolve();
|
||||
const inTurn = <T>(task: () => Promise<T>): Promise<T> => {
|
||||
const run = queue.then(task, task);
|
||||
queue = run.catch(() => undefined);
|
||||
return run;
|
||||
};
|
||||
return {
|
||||
load: () =>
|
||||
inTurn(async () => {
|
||||
try {
|
||||
return readLessonProgress(await backend.lessonProgress());
|
||||
} catch {
|
||||
return readLessonProgress(undefined);
|
||||
}
|
||||
}),
|
||||
save: (progress) =>
|
||||
inTurn(async () => {
|
||||
try {
|
||||
await backend.saveLessonProgress(progress);
|
||||
} catch {
|
||||
// Stockage plein ou refusé : la progression reste en
|
||||
// mémoire pour cette visite.
|
||||
}
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -4,19 +4,13 @@
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
|
||||
import {
|
||||
createGameLibrary,
|
||||
type GameLibrary,
|
||||
type LibraryBackend,
|
||||
type LibraryOptions,
|
||||
} from "./game-library.js";
|
||||
import { createGameLibrary, type GameLibrary, type LibraryOptions } from "./game-library.js";
|
||||
import { openIndexedDbBackend } from "./indexeddb-backend.js";
|
||||
import {
|
||||
createKeyValueBackend,
|
||||
type KeyValueStorage,
|
||||
migrateLegacyGame,
|
||||
} from "./key-value-backend.js";
|
||||
import { createLessonStore, type LessonStore } from "./lesson-store.js";
|
||||
|
||||
/** Les stockages du navigateur ; chacun peut manquer ou être refusé. */
|
||||
export interface BrowserStorage {
|
||||
@@ -29,8 +23,6 @@ export interface OpenedLibrary {
|
||||
/** Le stockage retenu : IndexedDB, son repli localStorage, ou la seule
|
||||
* mémoire de la page (rien n'est gardé après sa fermeture). */
|
||||
readonly kind: "indexeddb" | "localStorage" | "memory";
|
||||
/** La progression dans les leçons, dans le même stockage. */
|
||||
readonly lessons: LessonStore;
|
||||
}
|
||||
|
||||
/** Au-delà, IndexedDB est tenu pour indisponible (une ouverture bloquée ne
|
||||
@@ -63,7 +55,7 @@ async function open(storage: BrowserStorage, options: LibraryOptions): Promise<O
|
||||
timer = setTimeout(() => reject(new Error("IndexedDB muet")), OPEN_TIMEOUT_MS);
|
||||
}),
|
||||
]);
|
||||
return opened(backend, options, "indexeddb");
|
||||
return { library: createGameLibrary(backend, options), kind: "indexeddb" };
|
||||
} catch {
|
||||
// Indisponible (navigation privée de certains navigateurs) : repli.
|
||||
} finally {
|
||||
@@ -71,21 +63,14 @@ async function open(storage: BrowserStorage, options: LibraryOptions): Promise<O
|
||||
}
|
||||
}
|
||||
if (storage.localStorage !== undefined && usable(storage.localStorage)) {
|
||||
return opened(createKeyValueBackend(storage.localStorage), options, "localStorage");
|
||||
return {
|
||||
library: createGameLibrary(createKeyValueBackend(storage.localStorage), options),
|
||||
kind: "localStorage",
|
||||
};
|
||||
}
|
||||
return opened(createKeyValueBackend(memoryStorage()), options, "memory");
|
||||
}
|
||||
|
||||
/** Les parties et la progression dans les leçons, sur le même stockage. */
|
||||
function opened(
|
||||
backend: LibraryBackend,
|
||||
options: LibraryOptions,
|
||||
kind: OpenedLibrary["kind"],
|
||||
): OpenedLibrary {
|
||||
return {
|
||||
library: createGameLibrary(backend, options),
|
||||
kind,
|
||||
lessons: createLessonStore(backend),
|
||||
library: createGameLibrary(createKeyValueBackend(memoryStorage()), options),
|
||||
kind: "memory",
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+348
-244
@@ -39,26 +39,18 @@ import type { Cube } from "../domain/game/cube.js";
|
||||
import type { MoveNotation } from "../domain/game/engine.js";
|
||||
import type { GameJournal } from "../domain/game/events.js";
|
||||
import type { Game, MoveSteps, NewGame, SideBoards } from "../domain/game/game.js";
|
||||
import type { HalfMove } from "../domain/game/position.js";
|
||||
import { BAR, type HalfMove } from "../domain/game/position.js";
|
||||
import { type PositionLink, type SharedPosition, setupOf } from "../domain/game/position-link.js";
|
||||
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";
|
||||
import { createAnalysisView } from "./analysis-view.js";
|
||||
import { createPromptView, type PromptResult } from "./ask-ai.js";
|
||||
import { ASK_AI_TEXTS } from "./ask-ai-texts.js";
|
||||
import { attachDrag, createDoubleTap, nextPlace } from "./board-gestures.js";
|
||||
import {
|
||||
boardModel,
|
||||
moveTargets,
|
||||
@@ -72,12 +64,16 @@ import {
|
||||
import {
|
||||
type BoardAdvice,
|
||||
type BoardDecor,
|
||||
type BoardDrag,
|
||||
checkerSpot,
|
||||
type DiceAction,
|
||||
type MatchScore,
|
||||
moveDraggedChecker,
|
||||
NO_MARKS,
|
||||
renderBoard,
|
||||
returnDraggedChecker,
|
||||
type ShownDie,
|
||||
type Spot,
|
||||
slideChecker,
|
||||
} from "./board-view.js";
|
||||
import { GAMES_TEXTS } from "./games-texts.js";
|
||||
@@ -91,10 +87,6 @@ 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";
|
||||
@@ -119,21 +111,8 @@ import {
|
||||
} from "./settings.js";
|
||||
import { createSummaryView } from "./summary-view.js";
|
||||
import { type PromptText, TEXTS } from "./texts.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 { seenAfter } from "./tutorial-nav.js";
|
||||
import { createTutorialView, type TutorialAccess } from "./tutorial-view.js";
|
||||
import { betweenTurns, type Updates, updateBannerShown } from "./updates.js";
|
||||
|
||||
/** Glissement d'un pion de l'ordinateur, et pause entre deux pions
|
||||
@@ -173,15 +152,8 @@ 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 ;
|
||||
* les leçons s'en servent aussi. */
|
||||
/** Le tutoriel : plateaux, coups illustrés, gestes jugés par le moteur. */
|
||||
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;
|
||||
@@ -259,6 +231,20 @@ type ActionName =
|
||||
| "fullscreen"
|
||||
| "close";
|
||||
|
||||
/** Le premier toucher d'un double toucher : la partie, le pion touché et
|
||||
* les demi-coups saisis à ce moment-là. */
|
||||
interface ObviousTap {
|
||||
readonly game: Game;
|
||||
readonly place: Place;
|
||||
readonly pending: readonly HalfMove[];
|
||||
}
|
||||
|
||||
/** Le numéro, pour le domaine, d'un endroit du joueur : la barre (BAR) ou
|
||||
* une case de 1 à 24. */
|
||||
function pointOf(place: Place): number {
|
||||
return place === "bar-player" ? BAR : Number(place.slice(1));
|
||||
}
|
||||
|
||||
/** Démarre l'écran de jeu dans la page (index.html). */
|
||||
export function startApp(options: AppOptions): void {
|
||||
const stripOf = (id: string) => {
|
||||
@@ -341,11 +327,8 @@ 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"),
|
||||
@@ -411,7 +394,57 @@ export function startApp(options: AppOptions): void {
|
||||
ui.confirm.textContent = TEXTS.confirm;
|
||||
label(ui.cancel, TEXTS.cancel, "undo");
|
||||
ui.skip.textContent = TEXTS.skip;
|
||||
ui.again.textContent = MATCH_TEXTS.replay;
|
||||
const againLabel = document.createElement("span");
|
||||
againLabel.textContent = MATCH_TEXTS.replay;
|
||||
ui.again.replaceChildren(againLabel);
|
||||
// Les raccourcis du clavier (interaction.md), en petite touche sur
|
||||
// chaque bouton ; visibles seulement quand un clavier est détecté.
|
||||
const shortcuts: [HTMLElement, string, string][] = [
|
||||
[ui.roll, "Espace", "Space"],
|
||||
[ui.confirm, "Espace", "Space"],
|
||||
[ui.skip, "Espace", "Space"],
|
||||
[ui.again, "Espace", "Space"],
|
||||
[ui.double, "D", "D"],
|
||||
[ui.take, "P", "P"],
|
||||
[ui.drop, "N", "N"],
|
||||
[ui.cancel, "Z", "Z"],
|
||||
[ui.helpButton, "?", "?"],
|
||||
[ui.fullscreenButton, "F", "F"],
|
||||
[ui.menuButton, "M", "M"],
|
||||
];
|
||||
for (const [button, key, aria] of shortcuts) {
|
||||
const kbd = document.createElement("kbd");
|
||||
kbd.className = "shortcut";
|
||||
kbd.textContent = key;
|
||||
kbd.setAttribute("aria-hidden", "true");
|
||||
button.append(kbd);
|
||||
button.setAttribute("aria-keyshortcuts", aria);
|
||||
}
|
||||
// Un clavier est détecté : sur un ordinateur (pointeur fin, écran
|
||||
// large), ou dès qu'une touche est frappée ; un toucher du doigt les
|
||||
// cache de nouveau.
|
||||
const desktop = window.matchMedia("(hover: hover) and (pointer: fine) and (min-width: 64rem)");
|
||||
const showShortcuts = (on: boolean) =>
|
||||
document.documentElement.classList.toggle("keyboard", on);
|
||||
showShortcuts(desktop.matches);
|
||||
document.addEventListener(
|
||||
"keydown",
|
||||
(event) => {
|
||||
if (!event.ctrlKey && !event.metaKey && !event.altKey && event.key !== "Unidentified") {
|
||||
showShortcuts(true);
|
||||
}
|
||||
},
|
||||
{ capture: true },
|
||||
);
|
||||
document.addEventListener(
|
||||
"pointerdown",
|
||||
(event) => {
|
||||
if (event.pointerType === "touch") {
|
||||
showShortcuts(false);
|
||||
}
|
||||
},
|
||||
{ capture: true },
|
||||
);
|
||||
ui.moveHint.textContent = TEXTS.moveHint;
|
||||
ui.doubleConfirm.textContent = TEXTS.double;
|
||||
ui.doubleCancel.textContent = TEXTS.cancel;
|
||||
@@ -443,7 +476,6 @@ 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;
|
||||
@@ -477,6 +509,20 @@ export function startApp(options: AppOptions): void {
|
||||
let sides: SideBoards | null = null;
|
||||
let targets = new Map<Place, Map<Place, HalfMove>>();
|
||||
let selected: Place | null = null;
|
||||
// Le pion qu'on fait glisser (charte, 1i), et celui qui vient d'être
|
||||
// lâché sur une case permise : il reste dessiné là où le doigt l'a
|
||||
// posé jusqu'à ce que le moteur ait accepté le demi-coup, puis glisse
|
||||
// à sa place (to).
|
||||
let drag: BoardDrag | null = null;
|
||||
let dropped: (BoardDrag & { readonly to: Place }) | null = null;
|
||||
// Le pion lâché hors d'une case permise rentre à sa place.
|
||||
let returning = false;
|
||||
// Clavier : la case d'arrivée choisie aux flèches haut et bas, pour le
|
||||
// pion choisi (selected) aux flèches gauche et droite.
|
||||
let keyTarget: Place | null = null;
|
||||
// Ceux qui attendent que l'écran soit libre (le double toucher qui suit
|
||||
// un pion parti dès le premier toucher).
|
||||
let idleWaiters: (() => void)[] = [];
|
||||
let canDouble = false;
|
||||
// Le joueur a lancé les dés mais ne peut pas jouer : il doit passer.
|
||||
let stuck = false;
|
||||
@@ -673,6 +719,8 @@ export function startApp(options: AppOptions): void {
|
||||
: null,
|
||||
controls: landscape ? controls() : null,
|
||||
advice: landscape && !panelShown ? boardAdvice(shownAdvice()) : null,
|
||||
drag: drag ?? dropped,
|
||||
focus: selected !== null && keyTarget !== null ? keyTarget : null,
|
||||
});
|
||||
}
|
||||
renderStrips(model.pipCount, state.phase === "over" ? null : state.turn);
|
||||
@@ -718,7 +766,7 @@ export function startApp(options: AppOptions): void {
|
||||
show(ui.take, doubled);
|
||||
show(ui.drop, doubled);
|
||||
show(ui.again, over && panelDismissed);
|
||||
ui.again.textContent = afterGameLabel(game);
|
||||
againLabel.textContent = afterGameLabel(game);
|
||||
show(ui.replay, shownMove !== null);
|
||||
// Le coup de l'ordinateur montré : sa position se copie en lien.
|
||||
show(ui.copyLink, shownMove !== null && currentLink !== null);
|
||||
@@ -1159,15 +1207,40 @@ export function startApp(options: AppOptions): void {
|
||||
async function settle(animate: boolean): Promise<void> {
|
||||
try {
|
||||
await update();
|
||||
await settleDropped();
|
||||
if (animate && shownMove !== null) {
|
||||
await animateMove(shownMove);
|
||||
}
|
||||
} finally {
|
||||
busy = false;
|
||||
dropped = null;
|
||||
render();
|
||||
const waiters = idleWaiters;
|
||||
idleWaiters = [];
|
||||
for (const resolve of waiters) {
|
||||
resolve();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Le pion lâché sur une case permise : une fois le demi-coup accepté,
|
||||
* il glisse de là où le doigt l'a posé jusqu'à sa place (duration-fast),
|
||||
* ou y apparaît, si le système demande moins de mouvement. */
|
||||
async function settleDropped(): Promise<void> {
|
||||
const landing = dropped;
|
||||
dropped = null;
|
||||
if (landing === null || reducedMotion.matches) {
|
||||
return;
|
||||
}
|
||||
render();
|
||||
await slideChecker(ui.board, landing.to, landing.at, SLIDE_MS);
|
||||
}
|
||||
|
||||
/** Résolue quand l'écran ne travaille plus (moteur, animation). */
|
||||
function whenIdle(): Promise<void> {
|
||||
return busy ? new Promise((resolve) => idleWaiters.push(resolve)) : Promise.resolve();
|
||||
}
|
||||
|
||||
/** Une action du joueur, puis le tour de l'ordinateur. Les touches sont
|
||||
* ignorées pendant ce temps. */
|
||||
async function act(action: (game: Game) => Promise<void> | void): Promise<void> {
|
||||
@@ -1176,6 +1249,8 @@ export function startApp(options: AppOptions): void {
|
||||
}
|
||||
busy = true;
|
||||
selected = null;
|
||||
keyTarget = null;
|
||||
drag = null;
|
||||
targets = new Map();
|
||||
shownMove = null;
|
||||
bubbleSide = null;
|
||||
@@ -1637,10 +1712,64 @@ export function startApp(options: AppOptions): void {
|
||||
if (await g.playHalfMove(halfMove)) {
|
||||
const after = (await g.displayedBoard()).computer.bar;
|
||||
haptic(after > before ? "checkerHit" : "checkerPlaced");
|
||||
} else {
|
||||
dropped = null;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Double toucher (charte, 1i) : joue le coup évident (le domaine le
|
||||
* cherche, le moteur le juge) qui commence par le pion touché, depuis
|
||||
* l'état du premier toucher. Ce premier toucher a pu faire partir le
|
||||
* pion tout de suite (une seule case d'arrivée) : il ne reste alors que
|
||||
* la suite du coup. Sans coup évident, le second toucher n'est qu'un
|
||||
* toucher : touchers rapides sur une même pile (un double, par exemple)
|
||||
* et pion repris (touché deux fois : plus rien de choisi).
|
||||
* L'appelant a pris l'écran (busy) : le moteur est seul à travailler.
|
||||
*/
|
||||
async function playObvious(tap: ObviousTap): Promise<void> {
|
||||
const g = game;
|
||||
const now = g?.pendingHalfMoves ?? [];
|
||||
const same = (a: HalfMove | undefined, b: HalfMove | undefined) =>
|
||||
a !== undefined && b !== undefined && a.from === b.from && a.to === b.to;
|
||||
let rest: HalfMove[] = [];
|
||||
if (
|
||||
g === tap.game &&
|
||||
g.state.phase === "moving" &&
|
||||
now.length >= tap.pending.length &&
|
||||
tap.pending.every((h, i) => same(h, now[i]))
|
||||
) {
|
||||
// Demandé seulement maintenant : un toucher simple ne coûte rien
|
||||
// au moteur.
|
||||
const obvious = await g.obviousMove(pointOf(tap.place), tap.pending).catch(() => null);
|
||||
const done = now.slice(tap.pending.length);
|
||||
if (obvious !== null && done.every((h, i) => same(h, obvious[i]))) {
|
||||
rest = obvious.slice(done.length);
|
||||
}
|
||||
}
|
||||
busy = false;
|
||||
if (game !== g || g === null) {
|
||||
render();
|
||||
return;
|
||||
}
|
||||
if (rest.length === 0) {
|
||||
tapPlace(tap.place);
|
||||
return;
|
||||
}
|
||||
void act(async (g) => {
|
||||
info = "";
|
||||
const before = (await g.displayedBoard()).computer.bar;
|
||||
for (const halfMove of rest) {
|
||||
if (!(await g.playHalfMove(halfMove))) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
const after = (await g.displayedBoard()).computer.bar;
|
||||
haptic(after > before ? "checkerHit" : "checkerPlaced");
|
||||
});
|
||||
}
|
||||
|
||||
const canAct = (): Game | null => (busy || game === null ? null : game);
|
||||
|
||||
const actions: Record<ActionName, () => void> = {
|
||||
@@ -1759,10 +1888,6 @@ 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");
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -1868,7 +1993,6 @@ 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;
|
||||
@@ -1920,12 +2044,9 @@ 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 || lessonList || lesson || choosing || analysing;
|
||||
const page = help || position || home || tutorial || choosing || analysing;
|
||||
const wasPage = ui.game.hidden;
|
||||
const wasScreen = ui.root.dataset.screen;
|
||||
ui.game.hidden = page;
|
||||
@@ -1933,18 +2054,13 @@ 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, 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)),
|
||||
);
|
||||
// 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)));
|
||||
if (opening) {
|
||||
newGameView.render(busy || game === null);
|
||||
newGameView.focus();
|
||||
@@ -1960,21 +2076,6 @@ 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;
|
||||
}
|
||||
@@ -2129,26 +2230,8 @@ 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.
|
||||
@@ -2171,158 +2254,17 @@ export function startApp(options: AppOptions): void {
|
||||
}
|
||||
}
|
||||
|
||||
const loadTutorial = cachedText(TUTORIAL_SOURCE, parseTutorial);
|
||||
const tutorialCourse: Course = {
|
||||
id: "tutorial",
|
||||
load: () => loadTutorial(),
|
||||
placeOf: tutorialPlaceOf,
|
||||
routeOf: (_, place) => tutorialPlaceRoute(place),
|
||||
const tutorialView = createTutorialView({
|
||||
section: ui.tutorial,
|
||||
access: options.tutorial,
|
||||
engineReady: options.engineReady,
|
||||
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",
|
||||
@@ -2593,9 +2535,6 @@ export function startApp(options: AppOptions): void {
|
||||
if (!ui.tutorial.hidden) {
|
||||
tutorialView.relayout();
|
||||
}
|
||||
if (!ui.lesson.hidden) {
|
||||
lessonView.relayout();
|
||||
}
|
||||
});
|
||||
};
|
||||
window.addEventListener("resize", relayout);
|
||||
@@ -2639,10 +2578,75 @@ export function startApp(options: AppOptions): void {
|
||||
ui.replay.onclick = actions.replay;
|
||||
ui.again.onclick = actions.again;
|
||||
|
||||
// Glisser un pion (charte, 1i) : il suit le doigt, agrandi et un peu
|
||||
// au-dessus ; un fantôme reste à son départ et ses cases d'arrivée (du
|
||||
// moteur) sont marquées. Lâché sur l'une d'elles, il s'y pose ; ailleurs,
|
||||
// il rentre à sa place, sans vibration ni message.
|
||||
const dragging = attachDrag(ui.board, {
|
||||
canStart: (place) =>
|
||||
!busy && !animating && !returning && game !== null && targets.has(place),
|
||||
start: (place, at) => {
|
||||
closeTransient();
|
||||
selected = place;
|
||||
keyTarget = null;
|
||||
shownMove = null;
|
||||
drag = { place, at };
|
||||
render();
|
||||
},
|
||||
move: (at) => {
|
||||
if (drag !== null) {
|
||||
drag = { place: drag.place, at };
|
||||
moveDraggedChecker(ui.board, at);
|
||||
}
|
||||
},
|
||||
drop: (over, at) => {
|
||||
if (drag === null) {
|
||||
return;
|
||||
}
|
||||
const from = drag.place;
|
||||
const halfMove = over === null ? undefined : targets.get(from)?.get(over);
|
||||
if (halfMove === undefined || over === null || busy) {
|
||||
void dragBack(at);
|
||||
return;
|
||||
}
|
||||
dropped = { place: from, at, to: over };
|
||||
drag = null;
|
||||
playHalfMove(halfMove);
|
||||
},
|
||||
cancel: (at) => void dragBack(at),
|
||||
});
|
||||
|
||||
/** Le pion glissé rentre à sa place (duration-fast), puis le plateau
|
||||
* redevient celui d'avant le glissement. */
|
||||
async function dragBack(at: Spot): Promise<void> {
|
||||
if (drag === null) {
|
||||
return;
|
||||
}
|
||||
drag = { place: drag.place, at };
|
||||
returning = true;
|
||||
try {
|
||||
if (!reducedMotion.matches) {
|
||||
await returnDraggedChecker(ui.board, at, SLIDE_MS);
|
||||
}
|
||||
} finally {
|
||||
returning = false;
|
||||
drag = null;
|
||||
selected = null;
|
||||
render();
|
||||
}
|
||||
}
|
||||
|
||||
// Double toucher : le premier toucher fait ce que fait un toucher, et
|
||||
// demande déjà au domaine le coup évident de ce pion ; le second le joue.
|
||||
const doubleTap = createDoubleTap<ObviousTap | null>();
|
||||
|
||||
// Toucher le plateau : une commande (dés, videau, colonne de gauche), ou
|
||||
// un pion jouable puis une de ses cases d'arrivée (ou aucune, s'il n'en a
|
||||
// qu'une : il y va tout de suite).
|
||||
ui.board.addEventListener("click", (event) => {
|
||||
if (dragging.takeClick()) {
|
||||
return;
|
||||
}
|
||||
const target = event.target as Element | null;
|
||||
if (!ui.bubble.hidden) {
|
||||
closeOverlays();
|
||||
@@ -2658,9 +2662,29 @@ export function startApp(options: AppOptions): void {
|
||||
return;
|
||||
}
|
||||
const place = target?.closest("[data-place]")?.getAttribute("data-place") as Place | null;
|
||||
if (busy || place == null) {
|
||||
if (place == null || game === null) {
|
||||
return;
|
||||
}
|
||||
const current = game;
|
||||
const second = doubleTap.tap(place, performance.now(), () =>
|
||||
busy || !targets.has(place)
|
||||
? null
|
||||
: { game: current, place, pending: [...current.pendingHalfMoves] },
|
||||
);
|
||||
if (second !== null) {
|
||||
doubleTapped(second);
|
||||
return;
|
||||
}
|
||||
if (!busy) {
|
||||
tapPlace(place);
|
||||
}
|
||||
});
|
||||
|
||||
/** Un toucher simple sur un endroit du plateau : le pion choisi va sur
|
||||
* cette case d'arrivée ; ou ce pion part tout de suite, s'il n'a qu'une
|
||||
* case d'arrivée ; ou il est choisi (touché de nouveau : plus rien). */
|
||||
function tapPlace(place: Place): void {
|
||||
keyTarget = null;
|
||||
const halfMove = selected === null ? undefined : targets.get(selected)?.get(place);
|
||||
if (halfMove !== undefined) {
|
||||
playHalfMove(halfMove);
|
||||
@@ -2676,7 +2700,83 @@ export function startApp(options: AppOptions): void {
|
||||
shownMove = null; // toucher un pion : le coup de l'ordinateur s'efface
|
||||
}
|
||||
render();
|
||||
});
|
||||
}
|
||||
|
||||
/** Le second toucher d'un double toucher : l'écran le prend tout de
|
||||
* suite (réponse immédiate), ou dès qu'il a fini l'effet du premier. */
|
||||
function doubleTapped(tap: ObviousTap): void {
|
||||
const claim = () => {
|
||||
if (busy) {
|
||||
return;
|
||||
}
|
||||
busy = true;
|
||||
render();
|
||||
void playObvious(tap);
|
||||
};
|
||||
if (busy) {
|
||||
void whenIdle().then(claim);
|
||||
} else {
|
||||
claim();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Le plateau au clavier (interaction.md) : les flèches gauche et droite
|
||||
* choisissent un pion jouable (dans le sens de la course), haut et bas
|
||||
* une de ses cases d'arrivée (anneau focus), Entrée l'y pose. Les cases
|
||||
* sont celles que le moteur a données. Renvoie vrai si la touche a
|
||||
* servi.
|
||||
*/
|
||||
function boardKey(key: string, onButton: boolean): boolean {
|
||||
const g = game;
|
||||
const open =
|
||||
!ui.game.hidden &&
|
||||
ui.menuVeil.hidden &&
|
||||
ui.doubleVeil.hidden &&
|
||||
ui.explainVeil.hidden &&
|
||||
g !== null &&
|
||||
g.state.turn === "player" &&
|
||||
g.state.phase === "moving";
|
||||
if (!open || targets.size === 0) {
|
||||
return false;
|
||||
}
|
||||
const landings = selected === null ? undefined : targets.get(selected);
|
||||
switch (key) {
|
||||
case "ArrowLeft":
|
||||
case "ArrowRight": {
|
||||
if (busy) {
|
||||
return true;
|
||||
}
|
||||
selected = nextPlace(targets.keys(), selected, key === "ArrowRight" ? 1 : -1);
|
||||
const tos = selected === null ? undefined : targets.get(selected);
|
||||
keyTarget = tos === undefined ? null : nextPlace(tos.keys(), null, 1);
|
||||
shownMove = null;
|
||||
render();
|
||||
return true;
|
||||
}
|
||||
case "ArrowUp":
|
||||
case "ArrowDown":
|
||||
if (busy || landings === undefined) {
|
||||
return landings !== undefined;
|
||||
}
|
||||
keyTarget = nextPlace(landings.keys(), keyTarget, key === "ArrowDown" ? 1 : -1);
|
||||
render();
|
||||
return true;
|
||||
case "Enter": {
|
||||
const halfMove =
|
||||
keyTarget === null || landings === undefined
|
||||
? undefined
|
||||
: landings.get(keyTarget);
|
||||
if (onButton || halfMove === undefined || busy) {
|
||||
return false;
|
||||
}
|
||||
keyTarget = null;
|
||||
playHalfMove(halfMove);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Clavier (interaction.md) : les mêmes actions que les touchers.
|
||||
document.addEventListener("keydown", (event) => {
|
||||
@@ -2703,6 +2803,10 @@ export function startApp(options: AppOptions): void {
|
||||
analysisView.step(event.key === "ArrowLeft" ? -1 : 1);
|
||||
return;
|
||||
}
|
||||
if (boardKey(event.key, onButton)) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
let name: ActionName | null = null;
|
||||
switch (event.key) {
|
||||
case " ":
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
// Les gestes sur le plateau : glisser-déposer et double toucher (charte,
|
||||
// docs/identite/charte/ecrans-etape-1.md, 1i), et l'ordre des pions et des
|
||||
// cases au clavier (interaction.md).
|
||||
//
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
//
|
||||
// Un seul code pour la souris, le doigt et le stylet : les Pointer Events.
|
||||
// Ce module ne connaît ni la partie ni le moteur : il dit où le pion est
|
||||
// pris, où il passe et où il est lâché ; l'écran décide (app.ts), avec les
|
||||
// cases d'arrivée que le moteur a données.
|
||||
|
||||
import type { Place } from "./board-model.js";
|
||||
import type { Spot } from "./board-view.js";
|
||||
|
||||
/** Au-delà de cette distance (px à l'écran), un appui devient un glissement :
|
||||
* en deçà, c'est un toucher. */
|
||||
const DRAG_THRESHOLD_PX = 8;
|
||||
/** Le pion glissé est dessiné au-dessus du doigt, pour rester visible
|
||||
* (charte, 1i). */
|
||||
const LIFT_PX = 12;
|
||||
/** Deux touchers sur le même pion à moins de tant de ms : un double
|
||||
* toucher. */
|
||||
export const DOUBLE_TAP_MS = 350;
|
||||
/** Après un glissement, le « click » que le navigateur envoie au lâcher
|
||||
* n'est pas un toucher. */
|
||||
const CLICK_AFTER_DRAG_MS = 400;
|
||||
|
||||
/** Ce que l'écran fait d'un glissement. */
|
||||
export interface DragHandlers {
|
||||
/** Ce pion peut-il partir maintenant (pion jouable, moteur libre) ? */
|
||||
canStart(place: Place): boolean;
|
||||
/** Le pion de place commence à glisser ; at : son centre, dans le repère
|
||||
* du dessin. */
|
||||
start(place: Place, at: Spot): void;
|
||||
move(at: Spot): void;
|
||||
/** Le pion est lâché en at, au-dessus de over (null : hors de toute
|
||||
* zone du plateau). */
|
||||
drop(over: Place | null, at: Spot): void;
|
||||
/** Le glissement est interrompu par le navigateur (appel, geste du
|
||||
* système) : le pion rentre. */
|
||||
cancel(at: Spot): void;
|
||||
}
|
||||
|
||||
/** L'endroit du plateau sous un élément touché (sa zone data-place). */
|
||||
function placeOf(target: EventTarget | null): Place | null {
|
||||
if (!(target instanceof Element)) {
|
||||
return null;
|
||||
}
|
||||
return (target.closest("[data-place]")?.getAttribute("data-place") as Place | null) ?? null;
|
||||
}
|
||||
|
||||
/** Le point du dessin sous le pointeur, remonté de LIFT_PX vers le haut du
|
||||
* plateau (null si le dessin n'est pas affiché). */
|
||||
function liftedPoint(svg: SVGSVGElement, event: PointerEvent): Spot | null {
|
||||
const matrix = svg.getScreenCTM();
|
||||
if (matrix === null) {
|
||||
return null;
|
||||
}
|
||||
const point = new DOMPoint(event.clientX, event.clientY).matrixTransform(matrix.inverse());
|
||||
const unit = 1 / (Math.hypot(matrix.a, matrix.b) || 1);
|
||||
return { x: point.x, y: point.y - LIFT_PX * unit };
|
||||
}
|
||||
|
||||
/** La zone du plateau (data-place) qui contient at, dans le repère du
|
||||
* dessin (null : aucune). */
|
||||
export function placeAtSpot(svg: SVGSVGElement, at: Spot): Place | null {
|
||||
for (const hit of svg.querySelectorAll(".hit[data-place]")) {
|
||||
const number = (name: string) => Number(hit.getAttribute(name));
|
||||
const x = number("x");
|
||||
const y = number("y");
|
||||
if (at.x >= x && at.x <= x + number("width") && at.y >= y && at.y <= y + number("height")) {
|
||||
return hit.getAttribute("data-place") as Place;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Branche le glisser-déposer sur le plateau svg. Renvoie takeClick : vrai
|
||||
* si le « click » reçu suit un glissement (il faut alors l'ignorer).
|
||||
*/
|
||||
export function attachDrag(svg: SVGSVGElement, handlers: DragHandlers): { takeClick(): boolean } {
|
||||
let press: { id: number; place: Place; x: number; y: number } | null = null;
|
||||
let dragging = false;
|
||||
let clickIgnoredUntil = 0;
|
||||
svg.addEventListener("pointerdown", (event) => {
|
||||
if (!event.isPrimary || event.button !== 0) {
|
||||
return;
|
||||
}
|
||||
const place = placeOf(event.target);
|
||||
press =
|
||||
place !== null && handlers.canStart(place)
|
||||
? { id: event.pointerId, place, x: event.clientX, y: event.clientY }
|
||||
: null;
|
||||
dragging = false;
|
||||
});
|
||||
svg.addEventListener("pointermove", (event) => {
|
||||
if (press === null || event.pointerId !== press.id) {
|
||||
return;
|
||||
}
|
||||
const at = liftedPoint(svg, event);
|
||||
if (at === null) {
|
||||
return;
|
||||
}
|
||||
if (dragging) {
|
||||
handlers.move(at);
|
||||
return;
|
||||
}
|
||||
const distance = Math.hypot(event.clientX - press.x, event.clientY - press.y);
|
||||
if (distance < DRAG_THRESHOLD_PX) {
|
||||
return;
|
||||
}
|
||||
if (!handlers.canStart(press.place)) {
|
||||
press = null;
|
||||
return;
|
||||
}
|
||||
dragging = true;
|
||||
// Le plateau est redessiné pendant le glissement : c'est lui, et non
|
||||
// la zone touchée, qui reçoit la suite du geste.
|
||||
try {
|
||||
svg.setPointerCapture(event.pointerId);
|
||||
} catch {
|
||||
// Sans capture, les événements remontent quand même au plateau.
|
||||
}
|
||||
handlers.start(press.place, at);
|
||||
});
|
||||
const end = (event: PointerEvent, cancelled: boolean) => {
|
||||
if (press === null || event.pointerId !== press.id) {
|
||||
return;
|
||||
}
|
||||
const wasDragging = dragging;
|
||||
press = null;
|
||||
dragging = false;
|
||||
if (!wasDragging) {
|
||||
return;
|
||||
}
|
||||
clickIgnoredUntil = performance.now() + CLICK_AFTER_DRAG_MS;
|
||||
const at = liftedPoint(svg, event) ?? { x: 0, y: 0 };
|
||||
if (cancelled) {
|
||||
handlers.cancel(at);
|
||||
} else {
|
||||
handlers.drop(placeAtSpot(svg, at), at);
|
||||
}
|
||||
};
|
||||
svg.addEventListener("pointerup", (event) => end(event, false));
|
||||
svg.addEventListener("pointercancel", (event) => end(event, true));
|
||||
return {
|
||||
takeClick: () => {
|
||||
const ignored = performance.now() < clickIgnoredUntil;
|
||||
clickIgnoredUntil = 0;
|
||||
return ignored;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconnaît le double toucher : tap(place, now, first) renvoie ce que first
|
||||
* a produit au premier toucher quand celui-ci est le second, sur le même
|
||||
* endroit, à moins de DOUBLE_TAP_MS ; sinon null, et ce toucher devient le
|
||||
* premier (first() n'est appelé que pour lui).
|
||||
*/
|
||||
export function createDoubleTap<T>(): {
|
||||
tap(place: Place, now: number, first: () => T): T | null;
|
||||
} {
|
||||
let last: { place: Place; time: number; data: T } | null = null;
|
||||
return {
|
||||
tap(place, now, first) {
|
||||
if (last !== null && last.place === place && now - last.time < DOUBLE_TAP_MS) {
|
||||
const data = last.data;
|
||||
last = null;
|
||||
return data;
|
||||
}
|
||||
last = { place, time: now, data: first() };
|
||||
return null;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Le numéro d'un endroit dans le sens de la course du joueur : la barre
|
||||
* (25), les cases de 24 à 1, la sortie (0). */
|
||||
function raceOrder(place: Place): number {
|
||||
if (place.startsWith("bar-")) {
|
||||
return 25;
|
||||
}
|
||||
if (place.startsWith("off-")) {
|
||||
return 0;
|
||||
}
|
||||
return Number(place.slice(1));
|
||||
}
|
||||
|
||||
/**
|
||||
* Clavier : l'endroit suivant (step 1) ou précédent (step -1) de places
|
||||
* après current, dans le sens de la course du joueur (de la barre vers la
|
||||
* sortie), en bouclant. Sans current (ou s'il n'en fait pas partie), le
|
||||
* premier dans ce sens. null si places est vide.
|
||||
*/
|
||||
export function nextPlace(
|
||||
places: Iterable<Place>,
|
||||
current: Place | null,
|
||||
step: 1 | -1,
|
||||
): Place | null {
|
||||
const sorted = [...places].sort((a, b) => raceOrder(b) - raceOrder(a));
|
||||
if (sorted.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const index = current === null ? -1 : sorted.indexOf(current);
|
||||
if (index === -1) {
|
||||
return (step === 1 ? sorted[0] : sorted.at(-1)) ?? null;
|
||||
}
|
||||
return sorted[(index + step + sorted.length) % sorted.length] ?? null;
|
||||
}
|
||||
+117
-10
@@ -86,8 +86,23 @@ export interface BoardDecor {
|
||||
/** Paysage : le conseil du maître, une pastille avant la consigne dans
|
||||
* la bande centrale (absent ou null : rien). */
|
||||
readonly advice?: BoardAdvice | null;
|
||||
/** Le pion qu'on fait glisser (absent ou null : aucun). */
|
||||
readonly drag?: BoardDrag | null;
|
||||
/** Clavier : l'endroit qui a le focus (anneau focus ; absent ou null :
|
||||
* aucun). */
|
||||
readonly focus?: Place | null;
|
||||
}
|
||||
|
||||
/** Un pion qu'on fait glisser : il a quitté place (un fantôme en
|
||||
* pointillés y reste) et son centre est en at, dans le repère du dessin. */
|
||||
export interface BoardDrag {
|
||||
readonly place: Place;
|
||||
readonly at: Spot;
|
||||
}
|
||||
|
||||
/** Le pion qu'on fait glisser est agrandi (charte, 1i : 115 %). */
|
||||
export const DRAG_SCALE = 1.15;
|
||||
|
||||
/** Le conseil du maître en paysage : son mot, sur une pastille qui mène à
|
||||
* la feuille d'explication. */
|
||||
export interface BoardAdvice {
|
||||
@@ -203,6 +218,9 @@ export function renderBoard(
|
||||
}
|
||||
svg.append(pieces);
|
||||
drawLandings(svg, g, board, decor.targets);
|
||||
if (decor.focus != null) {
|
||||
drawFocus(svg, g, board, decor.focus, decor.targets.has(decor.focus));
|
||||
}
|
||||
const advice = decor.band !== null ? (decor.advice ?? null) : null;
|
||||
const chip = advice === null ? 0 : adviceChipWidth(g, advice) + 8;
|
||||
if (decor.band !== null) {
|
||||
@@ -235,6 +253,9 @@ export function renderBoard(
|
||||
if (advice !== null) {
|
||||
drawAdviceChip(svg, g, hits, advice);
|
||||
}
|
||||
if (decor.drag != null) {
|
||||
drawDrag(svg, g, decor.drag);
|
||||
}
|
||||
// Les zones à toucher passent devant tout le reste.
|
||||
svg.append(hits);
|
||||
}
|
||||
@@ -359,18 +380,13 @@ function drawStack(
|
||||
classes.push("movable");
|
||||
}
|
||||
const { x, y } = slotCenter(g, place, i);
|
||||
const checker = add(svg, "circle", {
|
||||
class: classes.join(" "),
|
||||
cx: x,
|
||||
cy: y,
|
||||
r: g.radius,
|
||||
});
|
||||
const piece = addPiece(svg, g, side, classes.join(" "), { x, y });
|
||||
if (last) {
|
||||
checker.setAttribute("data-checker", place);
|
||||
piece.setAttribute("data-checker", place);
|
||||
}
|
||||
if (last && count > limit) {
|
||||
add(
|
||||
svg,
|
||||
piece,
|
||||
"text",
|
||||
{
|
||||
class: `checker-count count-${side}`,
|
||||
@@ -481,6 +497,23 @@ function drawTray(
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Un pion vu de dessus, centré en spot : son disque (classes cssClass) et
|
||||
* son filet intérieur, l'écho du logo (charte, board.js : rayon 12 pour un
|
||||
* pion de 20). Les deux sont groupés : c'est le groupe qui glisse.
|
||||
*/
|
||||
function addPiece(svg: Element, g: Geometry, side: Side, cssClass: string, spot: Spot): SVGElement {
|
||||
const piece = add(svg, "g", { class: "piece" });
|
||||
add(piece, "circle", { class: cssClass, cx: spot.x, cy: spot.y, r: g.radius });
|
||||
add(piece, "circle", {
|
||||
class: `checker-ring ring-${side}`,
|
||||
cx: spot.x,
|
||||
cy: spot.y,
|
||||
r: g.radius * 0.6,
|
||||
});
|
||||
return piece;
|
||||
}
|
||||
|
||||
/** Une tranche de pion sorti, centrée en spot. */
|
||||
function addSlab(svg: Element, g: Geometry, cssClass: string, spot: Spot): SVGElement {
|
||||
return add(svg, "rect", {
|
||||
@@ -810,13 +843,87 @@ export function checkerSpot(svg: SVGSVGElement, place: Place): Spot | null {
|
||||
if (checker === null) {
|
||||
return null;
|
||||
}
|
||||
const number = (name: string) => Number(checker.getAttribute(name));
|
||||
if (checker.tagName === "circle") {
|
||||
return shapeCenter(checker.tagName === "g" ? (checker.firstElementChild ?? checker) : checker);
|
||||
}
|
||||
|
||||
/** Le centre d'un disque (cx, cy) ou d'un rectangle (x, y, largeur, hauteur). */
|
||||
function shapeCenter(shape: Element): Spot {
|
||||
const number = (name: string) => Number(shape.getAttribute(name));
|
||||
if (shape.tagName === "circle") {
|
||||
return { x: number("cx"), y: number("cy") };
|
||||
}
|
||||
return { x: number("x") + number("width") / 2, y: number("y") + number("height") / 2 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Le pion qu'on fait glisser : le pion de départ devient un fantôme en
|
||||
* pointillés, et un pion agrandi est dessiné en drag.at, devant les autres.
|
||||
* Il se déplace par son attribut transform (moveDraggedChecker), sans
|
||||
* redessiner le plateau.
|
||||
*/
|
||||
function drawDrag(svg: SVGSVGElement, g: Geometry, drag: BoardDrag): void {
|
||||
svg.querySelector(`[data-checker="${drag.place}"]`)?.classList.add("drag-origin");
|
||||
const group = add(svg, "g", {
|
||||
class: "drag-piece",
|
||||
id: "drag-piece",
|
||||
transform: dragTransform(drag.at, DRAG_SCALE),
|
||||
});
|
||||
addPiece(group, g, "player", "checker checker-player dragged", { x: 0, y: 0 });
|
||||
}
|
||||
|
||||
function dragTransform(at: Spot, scale: number): string {
|
||||
return `translate(${at.x} ${at.y}) scale(${scale})`;
|
||||
}
|
||||
|
||||
/** Déplace le pion qu'on fait glisser en at (repère du dessin). */
|
||||
export function moveDraggedChecker(svg: SVGSVGElement, at: Spot): void {
|
||||
svg.querySelector("#drag-piece")?.setAttribute("transform", dragTransform(at, DRAG_SCALE));
|
||||
}
|
||||
|
||||
/**
|
||||
* Ramène le pion qu'on fait glisser à son départ (le centre du fantôme), en
|
||||
* ms millisecondes, et à sa taille ; résolue à la fin (animation du
|
||||
* navigateur, sans bibliothèque).
|
||||
*/
|
||||
export async function returnDraggedChecker(
|
||||
svg: SVGSVGElement,
|
||||
from: Spot,
|
||||
ms: number,
|
||||
): Promise<void> {
|
||||
const piece = svg.querySelector("#drag-piece");
|
||||
const origin = svg.querySelector(".drag-origin");
|
||||
if (piece === null || origin === null || typeof piece.animate !== "function") {
|
||||
return;
|
||||
}
|
||||
const to = shapeCenter(origin.firstElementChild ?? origin);
|
||||
const animation = piece.animate(
|
||||
[
|
||||
{ transform: `translate(${from.x}px, ${from.y}px) scale(${DRAG_SCALE})` },
|
||||
{ transform: `translate(${to.x}px, ${to.y}px) scale(1)` },
|
||||
],
|
||||
{ duration: ms, easing: EASING_STANDARD, fill: "forwards" },
|
||||
);
|
||||
await animation.finished.catch(() => undefined);
|
||||
}
|
||||
|
||||
/** Clavier : l'anneau focus autour de l'endroit choisi, sur la case
|
||||
* d'arrivée (target) ou sur le pion du dessus. */
|
||||
function drawFocus(
|
||||
svg: SVGSVGElement,
|
||||
g: Geometry,
|
||||
board: BoardModel,
|
||||
place: Place,
|
||||
target: boolean,
|
||||
): void {
|
||||
const count = countAt(board, "player", place);
|
||||
const index = target
|
||||
? place.startsWith("off-")
|
||||
? count
|
||||
: Math.min(count, MAX_STACK - 1)
|
||||
: Math.max(topIndex(g, board, "player", place), 0);
|
||||
addRing(svg, g, "key-focus", place, slotCenter(g, place, index), g.radius + 5);
|
||||
}
|
||||
|
||||
/** Courbe de tout ce qui se déplace (interaction.md, easing-standard). */
|
||||
export const EASING_STANDARD = "cubic-bezier(0.2, 0, 0, 1)";
|
||||
|
||||
|
||||
@@ -35,7 +35,6 @@ 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",
|
||||
|
||||
@@ -41,9 +41,7 @@ export const HOME_TEXTS = {
|
||||
rulesContinue: (step: number) => `Reprendre à l'étape ${step}`,
|
||||
rulesAgain: "Revoir",
|
||||
progress: (played: number, won: number) =>
|
||||
played === 0
|
||||
? "aucune partie jouée"
|
||||
: `${plural(played, "partie jouée", "parties jouées")} · ${plural(won, "gagnée", "gagnées")}`,
|
||||
`${plural(played, "partie jouée", "parties jouées")} · ${plural(won, "gagnée", "gagnées")}`,
|
||||
footer: "Né à l'Atelier du Huit, à Cugnaux. Logiciel libre, sans publicité ni mise.",
|
||||
|
||||
// Installation
|
||||
@@ -121,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, des leçons, de l'aide et du glossaire sont sous licence Creative Commons BY-SA 4.0.",
|
||||
"Les textes du tutoriel, de l'aide et du glossaire sont sous licence Creative Commons BY-SA 4.0.",
|
||||
"L'ordinateur joue et juge les coups avec GNU Backgammon (GPLv3), compilé pour le navigateur. Police Alegreya Sans, sous licence SIL Open Font License.",
|
||||
"Né à l'Atelier du Huit, café associatif à Cugnaux. Sans publicité, sans compte, sans mise : tout reste sur votre téléphone.",
|
||||
],
|
||||
|
||||
+7
-65
@@ -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, la prochaine leçon (1g), les règles et la progression résumée
|
||||
// installer, les règles et la progression résumée
|
||||
// (docs/identite/charte/accueil-tutoriel-aide.md, « Accueil »).
|
||||
//
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
//
|
||||
// La progression détaillée (1h) n'existe pas encore : l'accueil n'affiche
|
||||
// que ce qui existe.
|
||||
// Les leçons (1g) et la progression détaillée (1h) n'existent pas encore :
|
||||
// l'accueil n'affiche que ce qui existe.
|
||||
|
||||
import type { SideBoards } from "../domain/game/game.js";
|
||||
import { boardModel, OPENING_BOARDS } from "./board-model.js";
|
||||
@@ -16,9 +16,6 @@ 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";
|
||||
|
||||
@@ -41,13 +38,6 @@ 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;
|
||||
}
|
||||
@@ -196,23 +186,6 @@ 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 });
|
||||
@@ -260,41 +233,10 @@ export function createHomeView(options: HomeViewOptions): HomeView {
|
||||
: seen >= TUTORIAL_DONE
|
||||
? HOME_TEXTS.rulesAgain
|
||||
: HOME_TEXTS.rulesContinue(seen);
|
||||
// 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) {
|
||||
parts.push(HOME_TEXTS.progress(state.progress.played, state.progress.won));
|
||||
}
|
||||
const line = parts.join(" · ");
|
||||
progress.textContent = line.charAt(0).toUpperCase() + line.slice(1);
|
||||
progress.textContent =
|
||||
state.progress === null || state.progress.played === 0
|
||||
? ""
|
||||
: HOME_TEXTS.progress(state.progress.played, state.progress.won);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -25,7 +25,6 @@ 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",
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
// Lecture du texte des leçons (docs/lecons/lecon-<n>.md, format décrit dans
|
||||
// docs/lecons/README.md), copié tel quel dans le site par make site
|
||||
// (ADR 0008 : une seule source). Même format que le tutoriel, avec un titre
|
||||
// en tête. Fonctions pures, testées sous Node.
|
||||
//
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
|
||||
import type { LessonOutline } from "../domain/learning/lessons.js";
|
||||
import { parseTutorial, type Tutorial } from "./tutorial-text.js";
|
||||
|
||||
/** Le nombre de leçons (fichiers lecon-1.md à lecon-<n>.md). */
|
||||
export const LESSON_COUNT = 5;
|
||||
|
||||
/** Une leçon : son numéro, son titre, et ses écrans, comme le tutoriel. */
|
||||
export interface Lesson extends Tutorial {
|
||||
readonly number: number;
|
||||
readonly title: string;
|
||||
}
|
||||
|
||||
/** L'adresse, dans le site, du texte de la leçon number. */
|
||||
export function lessonSource(number: number): string {
|
||||
return `docs/lecons/lecon-${number}.md`;
|
||||
}
|
||||
|
||||
/** Lit le texte d'une leçon. Erreur si le titre manque ou si les écrans
|
||||
* sont mal formés (parseTutorial). */
|
||||
export function parseLesson(number: number, text: string): Lesson {
|
||||
const title = /^# (.+)$/m.exec(text)?.[1]?.trim();
|
||||
if (title === undefined || title === "") {
|
||||
throw new Error(`leçon ${number} : titre absent`);
|
||||
}
|
||||
return { ...parseTutorial(text), number, title };
|
||||
}
|
||||
|
||||
/** Ce que la progression retient d'une leçon : les étapes de ses
|
||||
* exercices. */
|
||||
export function outlineOf(lesson: Lesson): LessonOutline {
|
||||
return {
|
||||
number: lesson.number,
|
||||
exercises: lesson.steps.filter((s) => s.practice !== null).map((s) => s.number),
|
||||
};
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
// 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) =>
|
||||
finished === 0
|
||||
? `Aucune leçon terminée sur ${total}`
|
||||
: `${plural(finished, "leçon terminée", "leçons terminées")} sur ${total}`,
|
||||
} as const;
|
||||
@@ -1,66 +0,0 @@
|
||||
// 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;
|
||||
}
|
||||
@@ -1,135 +0,0 @@
|
||||
// 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 });
|
||||
// Le rappel des règles : toute la ligne est le lien, haute de 44 px.
|
||||
const review = element(section, "a", { id: "lessons-rules", className: "lessons-rules" });
|
||||
review.append(`${LESSON_TEXTS.rulesReminder} · `);
|
||||
element(review, "span", { 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);
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -24,19 +24,6 @@ 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>. */
|
||||
@@ -104,9 +91,6 @@ 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;
|
||||
}
|
||||
@@ -210,29 +194,6 @@ 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
|
||||
@@ -285,14 +246,6 @@ 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":
|
||||
|
||||
@@ -19,19 +19,14 @@ export function placeOf(tutorial: Tutorial, route: Route): TutorialPlace {
|
||||
if (route.screen === "tutorialEnd") {
|
||||
return "end";
|
||||
}
|
||||
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 (route.screen !== "tutorial" || route.step === null) {
|
||||
return { step: 1, practice: false };
|
||||
}
|
||||
const step = tutorial.steps[route.step - 1];
|
||||
if (step === undefined) {
|
||||
return { step: 1, practice: false };
|
||||
}
|
||||
return { step: step.number, practice: practice && step.practice !== null };
|
||||
return { step: step.number, practice: route.practice && step.practice !== null };
|
||||
}
|
||||
|
||||
/** L'adresse d'un écran du tutoriel. */
|
||||
@@ -41,24 +36,6 @@ 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") {
|
||||
|
||||
@@ -26,9 +26,6 @@ export interface TutorialScreen {
|
||||
readonly move: string | null;
|
||||
/** Explication : le sens du jeu, deux flèches circulaires. */
|
||||
readonly path: boolean;
|
||||
/** Les comptes de points de la position que cite le texte (vous,
|
||||
* l'ordinateur), vérifiés par les tests ; null s'il n'en cite pas. */
|
||||
readonly pips: readonly [number, number] | null;
|
||||
/** « À vous » : les coups justes, en notation de gnubg. */
|
||||
readonly expected: readonly string[];
|
||||
/** « À vous » de videau : la bonne réponse au double. */
|
||||
@@ -93,17 +90,6 @@ function rollOf(text: string | undefined, where: string): readonly [number, numb
|
||||
return [Number(match[1]), Number(match[2])];
|
||||
}
|
||||
|
||||
function pipsOf(text: string | undefined, where: string): readonly [number, number] | null {
|
||||
if (text === undefined) {
|
||||
return null;
|
||||
}
|
||||
const match = /^(\d+)\/(\d+)$/.exec(text);
|
||||
if (match === null) {
|
||||
throw new Error(`${where} : compte illisible « ${text} »`);
|
||||
}
|
||||
return [Number(match[1]), Number(match[2])];
|
||||
}
|
||||
|
||||
function screenOf(raw: RawScreen, where: string): TutorialScreen {
|
||||
const one = (key: string) => raw.keys.get(key)?.[0];
|
||||
const position = one("position");
|
||||
@@ -124,7 +110,6 @@ function screenOf(raw: RawScreen, where: string): TutorialScreen {
|
||||
roll: rollOf(one("jet"), where),
|
||||
move: one("coup") ?? null,
|
||||
path: one("chemin") === "oui",
|
||||
pips: pipsOf(one("compte"), where),
|
||||
expected: raw.keys.get("attendu") ?? [],
|
||||
answer: answer === undefined ? null : answer === "prendre" ? "take" : "pass",
|
||||
right: parseInline(one("juste") ?? ""),
|
||||
|
||||
+75
-137
@@ -2,14 +2,11 @@
|
||||
// #/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 ou de docs/lecons/ (copies du
|
||||
// site) ; les plateaux,
|
||||
// Le texte vient de docs/tutoriel/regles.md (copie du site) ; les plateaux,
|
||||
// les coups illustrés et le jugement des gestes viennent du domaine, branché
|
||||
// sur le moteur (TutorialAccess, assemblé dans main.ts). Aucune règle n'est
|
||||
// écrite ici.
|
||||
@@ -42,8 +39,20 @@ 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, previousPlace, progress, type TutorialPlace } from "./tutorial-nav.js";
|
||||
import type { Paragraph, Tutorial, TutorialScreen } from "./tutorial-text.js";
|
||||
import {
|
||||
nextPlace,
|
||||
placeOf,
|
||||
placeRoute,
|
||||
previousPlace,
|
||||
progress,
|
||||
type TutorialPlace,
|
||||
} from "./tutorial-nav.js";
|
||||
import {
|
||||
type Paragraph,
|
||||
parseTutorial,
|
||||
type Tutorial,
|
||||
type TutorialScreen,
|
||||
} from "./tutorial-text.js";
|
||||
|
||||
/** Le coup illustré se joue tout seul après une seconde. */
|
||||
const AUTOPLAY_MS = 1000;
|
||||
@@ -52,32 +61,7 @@ const PAUSE_MS = 120;
|
||||
/** Un pion refusé reste un instant à sa place avant de revenir. */
|
||||
const REFUSED_MS = 450;
|
||||
|
||||
/** 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;
|
||||
};
|
||||
}
|
||||
const SOURCE = "docs/tutoriel/regles.md";
|
||||
|
||||
/** Un écran « à vous » où l'on joue un coup (MovePractice du domaine). */
|
||||
export interface PracticeSession {
|
||||
@@ -103,53 +87,14 @@ 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>;
|
||||
readonly course: Course;
|
||||
/** La dernière étape vue, et son enregistrement à l'arrivée sur un
|
||||
* écran. */
|
||||
readonly seen: () => number;
|
||||
readonly visit: (place: TutorialPlace) => void;
|
||||
/** Un geste juste : la vibration « pion posé ». */
|
||||
readonly placed: () => void;
|
||||
readonly reducedMotion: MediaQueryList;
|
||||
@@ -276,30 +221,24 @@ 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, course } = options;
|
||||
const idOf = (name: string) => `${course.id}-${name}`;
|
||||
const { section, access } = options;
|
||||
section.replaceChildren();
|
||||
const head = element(section, "header", { className: "tutorial-head" });
|
||||
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" });
|
||||
const bar = element(head, "nav", { id: "tutorial-progress", className: "tutorial-progress" });
|
||||
bar.setAttribute("aria-label", HOME_TEXTS.tutorialProgress);
|
||||
const skip = element(head, "a", { id: "tutorial-skip", className: "tutorial-skip" });
|
||||
skip.href = "#/";
|
||||
skip.textContent = course.texts.quit;
|
||||
skip.setAttribute("aria-label", course.texts.quitLabel);
|
||||
const overline = element(section, "p", { id: idOf("overline"), className: "overline" });
|
||||
// Le titre, et « Revoir » à sa droite (en portrait comme en paysage).
|
||||
const titleRow = element(section, "div", { className: "tutorial-title-row" });
|
||||
const title = element(titleRow, "h2", { id: idOf("title"), className: "tutorial-title" });
|
||||
skip.textContent = HOME_TEXTS.tutorialSkip;
|
||||
skip.setAttribute("aria-label", HOME_TEXTS.tutorialSkipLabel);
|
||||
const overline = element(section, "p", { id: "tutorial-overline", className: "overline" });
|
||||
const title = element(section, "h2", { id: "tutorial-title", className: "tutorial-title" });
|
||||
const boardBox = element(section, "div", { className: "tutorial-board-box" });
|
||||
const svg = document.createElementNS(SVG_NS, "svg");
|
||||
svg.id = idOf("board");
|
||||
svg.id = "tutorial-board";
|
||||
svg.setAttribute("class", "board tutorial-board");
|
||||
svg.setAttribute("role", "img");
|
||||
boardBox.append(svg);
|
||||
const replay = element(titleRow, "button", {
|
||||
id: idOf("replay"),
|
||||
className: "icon-button tutorial-replay",
|
||||
});
|
||||
const replay = element(boardBox, "button", { id: "tutorial-replay", className: "icon-button" });
|
||||
replay.type = "button";
|
||||
replay.append(icon("replay"));
|
||||
replay.setAttribute("aria-label", HOME_TEXTS.replayLabel);
|
||||
@@ -307,43 +246,39 @@ 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: idOf("text"), className: "tutorial-text" });
|
||||
const task = element(body, "p", { id: idOf("task"), className: "tutorial-task" });
|
||||
const text = element(body, "div", { id: "tutorial-text", className: "tutorial-text" });
|
||||
const task = element(body, "p", { id: "tutorial-task", className: "tutorial-task" });
|
||||
const feedback = element(body, "p", {
|
||||
id: idOf("feedback"),
|
||||
id: "tutorial-feedback",
|
||||
className: "tutorial-feedback",
|
||||
});
|
||||
feedback.setAttribute("role", "status");
|
||||
const cube = element(section, "div", { id: idOf("cube"), className: "tutorial-cube" });
|
||||
const cube = element(section, "div", { id: "tutorial-cube", className: "tutorial-cube" });
|
||||
const drop = element(cube, "button", {
|
||||
id: idOf("drop"),
|
||||
id: "tutorial-drop",
|
||||
className: "secondary",
|
||||
text: HOME_TEXTS.drop,
|
||||
});
|
||||
drop.type = "button";
|
||||
const take = element(cube, "button", {
|
||||
id: idOf("take"),
|
||||
id: "tutorial-take",
|
||||
className: "secondary",
|
||||
text: HOME_TEXTS.take,
|
||||
});
|
||||
take.type = "button";
|
||||
const nav = element(section, "div", { className: "tutorial-nav" });
|
||||
const back = element(nav, "a", {
|
||||
id: idOf("back"),
|
||||
id: "tutorial-back",
|
||||
className: "button secondary",
|
||||
text: HOME_TEXTS.back,
|
||||
});
|
||||
const next = element(nav, "a", {
|
||||
id: idOf("next"),
|
||||
id: "tutorial-next",
|
||||
className: "button",
|
||||
text: HOME_TEXTS.next,
|
||||
});
|
||||
|
||||
// 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;
|
||||
let tutorial: Promise<Tutorial> | null = null;
|
||||
// Chaque affichage a son numéro : un affichage dépassé ne touche plus
|
||||
// à l'écran (le joueur est déjà ailleurs).
|
||||
let shown = 0;
|
||||
@@ -367,6 +302,22 @@ 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 {
|
||||
@@ -505,9 +456,6 @@ 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> {
|
||||
@@ -602,21 +550,17 @@ export function createTutorialView(options: TutorialViewOptions): TutorialView {
|
||||
take.addEventListener("click", () => void answer(true));
|
||||
drop.addEventListener("click", () => void answer(false));
|
||||
|
||||
function renderProgress(route: Route, at: TutorialPlace, loaded: Tutorial): void {
|
||||
const steps = loaded.steps;
|
||||
const total = steps.length;
|
||||
function renderProgress(total: number, at: TutorialPlace, steps: Tutorial["steps"]): void {
|
||||
bar.replaceChildren();
|
||||
for (const segment of progress(total, at, course.seen(route))) {
|
||||
for (const segment of progress(total, at, options.seen())) {
|
||||
const stepTitle = steps[segment.step - 1]?.title ?? "";
|
||||
const label = course.texts.stepLink(segment.step, stepTitle);
|
||||
const label = HOME_TEXTS.tutorialStepLink(segment.step, stepTitle);
|
||||
const item = segment.link
|
||||
? document.createElement("a")
|
||||
: document.createElement("span");
|
||||
item.className = `segment${segment.done ? " done" : ""}`;
|
||||
if (item instanceof HTMLAnchorElement) {
|
||||
item.href = routeHash(
|
||||
course.routeOf(route, { step: segment.step, practice: false }),
|
||||
);
|
||||
item.href = routeHash({ screen: "tutorial", step: segment.step, practice: false });
|
||||
item.setAttribute("aria-label", label);
|
||||
} else {
|
||||
item.setAttribute("aria-hidden", "true");
|
||||
@@ -629,8 +573,8 @@ export function createTutorialView(options: TutorialViewOptions): TutorialView {
|
||||
bar.setAttribute(
|
||||
"aria-description",
|
||||
at === "end"
|
||||
? course.texts.endOverline(loaded)
|
||||
: course.texts.overline(loaded, at.step, total, at.practice),
|
||||
? HOME_TEXTS.tutorialEndOverline
|
||||
: HOME_TEXTS.tutorialOverline(at.step, total, at.practice),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -659,48 +603,42 @@ export function createTutorialView(options: TutorialViewOptions): TutorialView {
|
||||
drop.disabled = false;
|
||||
let loaded: Tutorial;
|
||||
try {
|
||||
if (lastText === null) {
|
||||
title.textContent = course.texts.loading;
|
||||
if (tutorial === null) {
|
||||
title.textContent = HOME_TEXTS.tutorialLoading;
|
||||
text.replaceChildren();
|
||||
svg.replaceChildren();
|
||||
}
|
||||
loaded = await course.load(route);
|
||||
loaded = await load();
|
||||
} catch {
|
||||
if (id === shown) {
|
||||
title.textContent = course.texts.failed;
|
||||
title.textContent = HOME_TEXTS.tutorialFailed;
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (id !== shown) {
|
||||
return;
|
||||
}
|
||||
lastText = loaded;
|
||||
const at = course.placeOf(loaded, route);
|
||||
const wanted = routeHash(course.routeOf(route, at));
|
||||
const at = placeOf(loaded, route);
|
||||
const wanted = routeHash(placeRoute(at));
|
||||
if (routeHash(route) !== wanted) {
|
||||
options.replace(wanted);
|
||||
return;
|
||||
}
|
||||
place = at;
|
||||
shownRoute = route;
|
||||
course.visit(route, at);
|
||||
options.visit(at);
|
||||
const total = loaded.steps.length;
|
||||
renderProgress(route, at, loaded);
|
||||
skip.href = course.quitHref(route);
|
||||
renderProgress(total, at, loaded.steps);
|
||||
const before = previousPlace(loaded, at);
|
||||
back.href =
|
||||
before === null
|
||||
? course.firstBackHref(route)
|
||||
: routeHash(course.routeOf(route, before));
|
||||
back.href = before === null ? "#/accueil" : routeHash(placeRoute(before));
|
||||
if (at === "end") {
|
||||
overline.textContent = course.texts.endOverline(loaded);
|
||||
title.textContent = course.texts.endTitle(loaded);
|
||||
overline.textContent = HOME_TEXTS.tutorialEndOverline;
|
||||
title.textContent = HOME_TEXTS.tutorialEndTitle;
|
||||
text.replaceChildren();
|
||||
for (const paragraph of loaded.end.paragraphs) {
|
||||
fillParagraph(element(text, "p"), paragraph);
|
||||
}
|
||||
next.textContent = loaded.end.action;
|
||||
next.href = course.endHref(route);
|
||||
next.href = "#/";
|
||||
next.classList.remove("secondary");
|
||||
screen = loaded.steps[0]?.explain ?? null;
|
||||
} else {
|
||||
@@ -710,7 +648,7 @@ export function createTutorialView(options: TutorialViewOptions): TutorialView {
|
||||
return;
|
||||
}
|
||||
screen = { ...current, path: current.path && !at.practice };
|
||||
overline.textContent = course.texts.overline(loaded, at.step, total, at.practice);
|
||||
overline.textContent = HOME_TEXTS.tutorialOverline(at.step, total, at.practice);
|
||||
title.textContent = step.title;
|
||||
text.replaceChildren();
|
||||
const paragraphs = at.practice ? [] : current.paragraphs;
|
||||
@@ -731,7 +669,7 @@ export function createTutorialView(options: TutorialViewOptions): TutorialView {
|
||||
}
|
||||
const after = nextPlace(loaded, at);
|
||||
next.textContent = HOME_TEXTS.next;
|
||||
next.href = routeHash(course.routeOf(route, after));
|
||||
next.href = routeHash(placeRoute(after));
|
||||
next.classList.toggle("secondary", at.practice);
|
||||
}
|
||||
const currentScreen = screen;
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
// Les gestes du plateau qui se vérifient sans navigateur : le double toucher
|
||||
// et l'ordre des pions et des cases au clavier. Le glisser-déposer lui-même
|
||||
// est joué dans un navigateur (tests/browser/plateau.mjs).
|
||||
//
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
import { createDoubleTap, DOUBLE_TAP_MS, nextPlace } from "../src/ui/board-gestures.js";
|
||||
import type { Place } from "../src/ui/board-model.js";
|
||||
|
||||
test("double toucher : deux touchers rapprochés sur le même endroit", () => {
|
||||
const taps = createDoubleTap<string>();
|
||||
let firsts = 0;
|
||||
const first = () => {
|
||||
firsts++;
|
||||
return `premier ${firsts}`;
|
||||
};
|
||||
assert.equal(taps.tap("p8", 0, first), null);
|
||||
assert.equal(taps.tap("p8", DOUBLE_TAP_MS - 1, first), "premier 1");
|
||||
// Le troisième toucher recommence un double toucher.
|
||||
assert.equal(taps.tap("p8", DOUBLE_TAP_MS, first), null);
|
||||
// Trop lent : un nouveau premier toucher.
|
||||
assert.equal(taps.tap("p8", 3 * DOUBLE_TAP_MS, first), null);
|
||||
// Un autre endroit : un nouveau premier toucher.
|
||||
assert.equal(taps.tap("p6", 3 * DOUBLE_TAP_MS + 10, first), null);
|
||||
assert.equal(taps.tap("p6", 3 * DOUBLE_TAP_MS + 20, first), "premier 4");
|
||||
assert.equal(firsts, 4);
|
||||
});
|
||||
|
||||
test("clavier : les pions dans le sens de la course, en boucle", () => {
|
||||
const places: Place[] = ["p6", "bar-player", "p13", "p8"];
|
||||
assert.equal(nextPlace(places, null, 1), "bar-player");
|
||||
assert.equal(nextPlace(places, null, -1), "p6");
|
||||
assert.equal(nextPlace(places, "bar-player", 1), "p13");
|
||||
assert.equal(nextPlace(places, "p8", 1), "p6");
|
||||
assert.equal(nextPlace(places, "p6", 1), "bar-player");
|
||||
assert.equal(nextPlace(places, "bar-player", -1), "p6");
|
||||
// Un endroit qui n'est plus jouable : on repart du début.
|
||||
assert.equal(nextPlace(places, "p24", 1), "bar-player");
|
||||
assert.equal(nextPlace([], null, 1), null);
|
||||
// Les cases d'arrivée, sortie comprise.
|
||||
assert.equal(nextPlace(["off-player", "p2"], "p2", 1), "off-player");
|
||||
});
|
||||
@@ -1,440 +0,0 @@
|
||||
// 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",
|
||||
"Faire des points",
|
||||
"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);
|
||||
});
|
||||
|
||||
// Couvre aussi le « nettement devant tous les autres » de la leçon 1 (étape
|
||||
// 1) : chaque coup illustré a plus de MIN_GAP d'avance sur le deuxième.
|
||||
test("chaque coup illustré est le meilleur coup du moteur à 2 plis, avec plus de 0,04 d'avance", 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 });
|
||||
});
|
||||
@@ -0,0 +1,82 @@
|
||||
// Le coup évident du double toucher (charte, ecrans-etape-1.md, 1i), sur le
|
||||
// vrai moteur WebAssembly : un seul coup légal complet commence par le pion
|
||||
// touché, ou le moteur ne propose qu'un coup.
|
||||
//
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
import type { Engine } from "../src/domain/game/engine.js";
|
||||
import { Game } from "../src/domain/game/game.js";
|
||||
import { formatHalfMoves, type HalfMove } from "../src/domain/game/position.js";
|
||||
import { fixedDice } from "../src/domain/game/roll.js";
|
||||
import { loadNodeEngine } from "./support/node-engine.js";
|
||||
|
||||
// Le joueur, au trait : un pion sur sa case 6, un sur sa case 1.
|
||||
const SIX_AND_ONE = "BQAACAIAAAAAAA";
|
||||
// Le joueur, au trait : deux pions, sur ses cases 6 et 5.
|
||||
const PLAYER_ALMOST_DONE = "/38AAABQAAAAAA";
|
||||
|
||||
let engine: Engine | null = null;
|
||||
async function loaded(): Promise<Engine> {
|
||||
engine ??= await loadNodeEngine();
|
||||
return engine;
|
||||
}
|
||||
|
||||
/** Une partie à partir d'une position, joueur au trait, dés lancés. */
|
||||
async function from(position: string, dice: [number, number]): Promise<Game> {
|
||||
const game = Game.start(await loaded(), {
|
||||
setup: { position, firstTurn: "player" },
|
||||
dice: fixedDice(dice),
|
||||
});
|
||||
await game.roll();
|
||||
return game;
|
||||
}
|
||||
|
||||
function text(halfMoves: readonly HalfMove[] | null): string | null {
|
||||
return halfMoves === null ? null : formatHalfMoves(halfMoves);
|
||||
}
|
||||
|
||||
test("au départ avec 3-1 : plusieurs coups commencent par chaque pion, rien d'évident", async () => {
|
||||
const game = Game.start(await loaded(), { dice: fixedDice([3, 1]) });
|
||||
for (const point of [24, 13, 8, 6]) {
|
||||
assert.equal(await game.obviousMove(point), null, `pion de la ${point}`);
|
||||
}
|
||||
// Rien n'a été saisi.
|
||||
assert.deepEqual(game.pendingHalfMoves, []);
|
||||
});
|
||||
|
||||
test("6-1 avec des pions sur 6 et 1 : seul le pion de la 1 a un coup évident", async () => {
|
||||
const game = await from(SIX_AND_ONE, [6, 1]);
|
||||
// 1/off, puis le 6 sort l'autre pion : un seul coup.
|
||||
assert.equal(text(await game.obviousMove(1)), "1/off 6/off");
|
||||
// Depuis la 6 : 6/off 1/off, ou 6/5/off (le pion de la 1 reste).
|
||||
assert.equal(await game.obviousMove(6), null);
|
||||
// Une case sans pion n'a pas de coup.
|
||||
assert.equal(await game.obviousMove(3), null);
|
||||
});
|
||||
|
||||
test("un seul coup légal : il est évident depuis chaque pion, même à moitié saisi", async () => {
|
||||
const game = await from(PLAYER_ALMOST_DONE, [6, 6]);
|
||||
assert.equal((await (await loaded()).legalMoves(game.state.position, [6, 6])).length, 1);
|
||||
const fromFive = await game.obviousMove(5);
|
||||
const fromSix = await game.obviousMove(6);
|
||||
assert.ok(fromFive !== null && fromSix !== null);
|
||||
assert.equal(fromFive.length, 2);
|
||||
assert.equal(fromSix.length, 2);
|
||||
// Une fois 6/off saisi, il ne reste que 5/off.
|
||||
assert.equal(await game.playHalfMove({ from: 6, to: 0 }), true);
|
||||
assert.equal(text(await game.obviousMove(5)), "5/off");
|
||||
// Le coup évident se joue tel quel, demi-coup par demi-coup.
|
||||
for (const halfMove of (await game.obviousMove(5)) ?? []) {
|
||||
assert.equal(await game.playHalfMove(halfMove), true);
|
||||
}
|
||||
assert.equal(game.moveComplete, true);
|
||||
});
|
||||
|
||||
test("hors du tour de jeu du joueur, aucun coup évident", async () => {
|
||||
const game = Game.start(await loaded(), { dice: fixedDice([1, 3]) });
|
||||
assert.equal(game.state.turn, "computer");
|
||||
assert.equal(await game.obviousMove(13), null);
|
||||
});
|
||||
@@ -34,12 +34,6 @@ 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" }],
|
||||
@@ -105,14 +99,6 @@ 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",
|
||||
|
||||
@@ -511,3 +511,20 @@ Then("le résultat indique le gagnant et les points gagnés", () => {
|
||||
// règle Jacoby, le videau n'a pas bougé.
|
||||
assert.deepEqual(game.state.result, { winner: "player", kind: "gammon", points: 1 });
|
||||
});
|
||||
|
||||
// ------------------------------------------------- Glisser, double toucher
|
||||
|
||||
When("vous demandez le coup évident de votre pion de la flèche {int}", async (point: number) => {
|
||||
positionBefore = game.displayedPosition;
|
||||
const obvious = await game.obviousMove(point);
|
||||
accepted = obvious !== null;
|
||||
for (const halfMove of obvious ?? []) {
|
||||
accepted &&= await game.playHalfMove(halfMove);
|
||||
}
|
||||
});
|
||||
|
||||
Then("aucun pion n'a bougé", () => {
|
||||
assert.equal(accepted, false);
|
||||
assert.deepEqual(game.pendingHalfMoves, []);
|
||||
assert.equal(game.displayedPosition, positionBefore);
|
||||
});
|
||||
|
||||
@@ -1,205 +0,0 @@
|
||||
// É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");
|
||||
});
|
||||
@@ -202,28 +202,3 @@ le panneau d'analyse de gnubg de bureau.
|
||||
à la reprise et recalculé en arrière-plan ; les analyses de l'ordinateur
|
||||
(`review.computer`), du même format, aussi.
|
||||
- Ni les événements (version 2) ni le schéma IndexedDB ne changent.
|
||||
|
||||
### 2026-11-12 — La progression dans les leçons
|
||||
|
||||
Avec les leçons (étape 1g), la progression rejoint les formats durables. Elle
|
||||
est rangée dans le stockage des parties (étape 1b), à côté de la partie en
|
||||
cours :
|
||||
|
||||
- IndexedDB : une nouvelle clé `lessons` dans le magasin `settings`
|
||||
existant. Le schéma ne change pas (base « ustagammon », version 1, mêmes
|
||||
magasins) : aucune montée de version n'est nécessaire, et une base
|
||||
enregistrée avant l'étape 1g se lit telle quelle, sans progression.
|
||||
- Repli localStorage (et mémoire) : la clé `ustagammon.lessons`.
|
||||
- La valeur, en JSON, porte sa version (`LESSON_PROGRESS_VERSION`, 1) et,
|
||||
par numéro de leçon, la dernière étape vue (`last`), la plus lointaine
|
||||
(`reached`, 99 pour la fin) et les étapes dont l'exercice est réussi
|
||||
(`solved`) (`app/src/domain/learning/lessons.ts`).
|
||||
- `readLessonProgress` relit toute valeur enregistrée : absente ou
|
||||
illisible, la progression est vide ; une leçon mal formée est oubliée sans
|
||||
toucher aux autres ; une version inconnue est lue comme vide plutôt que
|
||||
mal comprise. Toute évolution du format passera par une nouvelle version
|
||||
et sa migration dans cette fonction, testées dans
|
||||
`app/tests/lessons.test.ts` comme ici.
|
||||
- Une leçon est terminée quand tous ses exercices sont réussis ; ce n'est
|
||||
pas enregistré à part, mais déduit des exercices réussis et du texte de la
|
||||
leçon : ajouter un exercice à une leçon la fait repasser « en cours ».
|
||||
|
||||
@@ -57,30 +57,3 @@ aide » et « Fin de partie » du canevas ci-dessus.
|
||||
- Les leçons sont des contenus sous CC BY-SA 4.0, à écrire et à faire relire
|
||||
par un joueur fort (cadrage, risque « contenus longs à produire ») ; leur
|
||||
progression est stockée localement.
|
||||
|
||||
## Amendements
|
||||
|
||||
### 2026-11-12 — Les cinq premières leçons, sans relecture préalable
|
||||
|
||||
Décision de Pierre du 2026-11-09 (`docs/etape-1.md`, « Décisions de
|
||||
Pierre ») : les leçons ne sont pas relues par un joueur fort avant d'être
|
||||
livrées ; elles le sont telles quelles et améliorées sur retours d'experts.
|
||||
La relecture prévue ci-dessus est remplacée par des garde-fous vérifiés par
|
||||
`make test` (`app/tests/lessons.test.ts`) :
|
||||
|
||||
- chaque exercice « à vous » attend un seul coup, le meilleur coup du
|
||||
moteur à 2 plis, avec plus de 0,04 d'équité d'avance sur le deuxième ; il
|
||||
accepte ce coup et refuse tous les autres ;
|
||||
- chaque coup illustré est aussi le meilleur coup du moteur à 2 plis ;
|
||||
- chaque compte de points cité par le texte est celui du moteur ;
|
||||
- le texte n'affirme rien que le moteur ne confirme : les coups, les points
|
||||
faits, les pions frappés ou laissés seuls, lisibles dans la position ;
|
||||
aucune explication stratégique inventée.
|
||||
|
||||
Les cinq leçons (étape 1g) vivent dans `docs/lecons/` (format et
|
||||
garde-fous : `docs/lecons/README.md`), au format du tutoriel, et
|
||||
réutilisent son écran. L'état d'une leçon est « à commencer », « 2
|
||||
exercices sur 4 » ou « terminée » (tous ses exercices réussis) ; toutes
|
||||
restent ouvertes, l'ordre est conseillé. La progression est enregistrée
|
||||
avec les parties (ADR 0007, amendement du 2026-11-12) ; leurs adresses sont
|
||||
dans l'ADR 0013 (amendement du 2026-11-12).
|
||||
|
||||
@@ -192,29 +192,3 @@ 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.
|
||||
|
||||
+3
-3
@@ -23,10 +23,10 @@ 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 (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 |
|
||||
| 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 |
|
||||
| 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 (fait) | `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 |
|
||||
| 1i. Plateau soigné (fait) | 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 |
|
||||
|
||||
## Orientation donnée par Pierre
|
||||
|
||||
|
||||
@@ -1,154 +0,0 @@
|
||||
# Guide de test de l'étape 1 : « Jouer et comprendre »
|
||||
|
||||
Document temporaire : il sert à la vérification de l'étape 1 avec Pierre, sur
|
||||
le Fairphone 4, et sera supprimé à la clôture de l'étape (ce qui en reste de
|
||||
durable va dans le journal).
|
||||
|
||||
**Critère de fin de l'étape** (cadrage) : un débutant apprend et joue seul ; un
|
||||
joueur de club comprend ses erreurs.
|
||||
|
||||
Le guide suit deux parcours : celui du **débutant** (parties A à C) et celui du
|
||||
**joueur de club** (parties D à F). Une partie G regroupe ce qui est commun
|
||||
(installation, hors ligne, mise à jour, affichage). Comptez environ une heure
|
||||
pour l'ensemble, en plusieurs fois si besoin : l'application reprend là où
|
||||
vous en étiez.
|
||||
|
||||
Pour chaque point : cochez ce qui marche, et notez ce qui coince avec le
|
||||
numéro du point (par exemple « C4 : le bilan s'ouvre deux fois »). Une capture
|
||||
d'écran aide beaucoup.
|
||||
|
||||
## Avant de commencer
|
||||
|
||||
- Adresse : https://ustagammon.sans.pub, dans Chrome sur le Fairphone 4.
|
||||
- Version : le menu, puis « Aide et glossaire », onglet « À propos », donne le
|
||||
numéro de version (un commit). Notez-le en tête de vos retours.
|
||||
- Pour repartir de zéro (comme un nouveau joueur) : dans Chrome, Paramètres du
|
||||
site pour ustagammon.sans.pub, puis « Effacer et réinitialiser ». Toutes les
|
||||
parties et la progression sont alors perdues.
|
||||
|
||||
## A. Premier contact (débutant)
|
||||
|
||||
1. Ouvrez l'application après l'avoir réinitialisée. L'écran de premier
|
||||
lancement propose « Je découvre » et « Je connais les règles ».
|
||||
2. Touchez « Je découvre » : le tutoriel des règles s'ouvre à l'étape 1 sur 8.
|
||||
3. Suivez les huit étapes jusqu'au bout. À chaque écran « à vous » : jouez
|
||||
d'abord un mauvais coup (le pion revient, une phrase explique sans
|
||||
reproche), puis le bon (coche, « Continuer »).
|
||||
4. Étape 2 : deux flèches circulaires montrent le sens de chaque camp, sous
|
||||
les pions.
|
||||
5. Le texte est-il clair pour quelqu'un qui n'a jamais joué ? Notez les mots
|
||||
ou phrases qui bloquent. Les mots soulignés ouvrent leur fiche du glossaire.
|
||||
6. À la fin, l'accueil propose la suite (partie, leçons).
|
||||
|
||||
**À rapporter** : une étape incompréhensible, un coup juste refusé ou un coup
|
||||
faux accepté, un texte coupé ou qui déborde.
|
||||
|
||||
## B. Première partie (débutant)
|
||||
|
||||
1. Depuis l'accueil, « Nouvelle partie » : choisissez « Partie libre » et le
|
||||
niveau « Débutant », conseil du maître activé, puis « Commencer la partie ».
|
||||
2. Touchez les dés pour les lancer. Touchez un pion : s'il n'a qu'une case
|
||||
d'arrivée, il y va tout seul ; sinon, ses cases d'arrivée s'allument.
|
||||
3. Essayez aussi de glisser un pion avec le doigt jusqu'à sa case ; lâché
|
||||
ailleurs, il revient à sa place.
|
||||
4. Un double toucher sur un pion joue le coup évident s'il n'y en a qu'un.
|
||||
5. Avant de valider, « Annuler » reprend le dernier pion, un à la fois. Puis
|
||||
validez en touchant les dés ou « Jouer ce coup ».
|
||||
6. Après votre coup, une ligne discrète du maître apparaît (« Bon coup. »,
|
||||
« Erreur. J'aurais joué… »). Touchez-la : la feuille montre votre coup et
|
||||
celui du maître, joués pion par pion.
|
||||
7. Regardez le coup de l'ordinateur : ses pions glissent, des traces restent,
|
||||
« Revoir » le rejoue. Touchez la notation du coup : la fiche « Notation des
|
||||
coups » s'ouvre.
|
||||
8. Quand aucun coup n'est possible, les dés sont barrés : touchez-les pour
|
||||
passer.
|
||||
9. Jouez la partie jusqu'au bout.
|
||||
|
||||
**À rapporter** : un pion qui ne bouge pas comme prévu, un geste raté, une
|
||||
phrase du maître fausse ou blessante, la durée d'un tour de l'ordinateur si
|
||||
elle dépasse 2 s.
|
||||
|
||||
## C. Bilan, leçons et retour (débutant)
|
||||
|
||||
1. En fin de partie, le bilan s'affiche : l'issue, ce qui a fait la
|
||||
différence, vos décisions comptées, votre jeu et vos dés, le coup à revoir.
|
||||
2. « Rejouer » relance une partie avec les mêmes réglages ; « Changer les
|
||||
réglages » ouvre l'écran « Nouvelle partie ».
|
||||
3. Depuis l'accueil, ouvrez « Toutes les leçons » puis la leçon 1. Suivez-la,
|
||||
en faisant au moins un exercice faux puis juste.
|
||||
4. Revenez à la liste : la leçon affiche « k exercices sur n ». Fermez
|
||||
l'application, rouvrez-la : la progression est gardée.
|
||||
5. Faites les cinq leçons si vous avez le temps : chaque réponse attendue est
|
||||
le meilleur coup du moteur.
|
||||
|
||||
**À rapporter** : une explication de leçon qui vous semble fausse ou trop
|
||||
difficile pour un débutant, un exercice où le refus ne met pas sur la voie.
|
||||
|
||||
## D. Match et niveaux (joueur de club)
|
||||
|
||||
1. « Nouvelle partie » : choisissez « Match », 3 points, niveau « Avancé »,
|
||||
puis « Commencer le match ».
|
||||
2. Le score du match s'affiche (« Match en 3 points ») ; à 2 points d'un
|
||||
joueur, la partie Crawford est signalée et le videau disparaît.
|
||||
3. Doublez au moins une fois (le videau demande confirmation). Répondez au
|
||||
moins une fois à un double de l'ordinateur (Prendre ou Passer).
|
||||
4. Entre deux parties, « Partie suivante » ; à la fin du match, le score final.
|
||||
5. Le niveau de l'ordinateur paraît-il juste ? « Champion » est le plus fort.
|
||||
|
||||
**À rapporter** : une décision de videau de l'ordinateur qui vous surprend
|
||||
(notez la position avec « Copier le lien de la position » dans le menu), un
|
||||
score de match faux.
|
||||
|
||||
## E. Comprendre ses erreurs (joueur de club)
|
||||
|
||||
1. Après une partie, depuis le bilan, « Voir l'analyse ».
|
||||
2. La courbe de vos chances de gagner se parcourt du doigt ; toucher un point
|
||||
ouvre le coup.
|
||||
3. Le visualiseur montre « Votre coup » et « Le coup du maître ». Les chiffres
|
||||
du moteur sont repliés en dessous.
|
||||
4. Filtrez la liste des coups par appréciation (erreurs, grosses erreurs).
|
||||
5. Sur un coup, « Rejouer d'ici » : une branche part de ce coup, la partie
|
||||
d'origine reste dans « Mes parties ».
|
||||
6. Sur une erreur, « Demander à une IA » : le prompt s'affiche, « Copier le
|
||||
prompt » confirme par « ✓ Copié ». Collez-le dans l'IA de votre choix et
|
||||
jugez la réponse.
|
||||
7. Ouvrez la progression (la ligne de l'accueil) après trois parties : la
|
||||
courbe du taux d'erreur, la liste des parties, « Créer des exercices ».
|
||||
8. « Mes parties » (menu) : reprenez une ancienne partie, exportez-en une en
|
||||
SGF et ouvrez-la dans GNU Backgammon de bureau (`load game fichier.sgf`).
|
||||
|
||||
**À rapporter** : une analyse qui contredit ce que vous pensiez (c'est peut-être
|
||||
juste, mais notez-la), un prompt incomplet, une partie absente de la liste.
|
||||
|
||||
## F. Liens et positions (joueur de club)
|
||||
|
||||
1. Dans le menu, « Copier le lien de la position ». Ouvrez ce lien dans un
|
||||
nouvel onglet : la même position s'affiche, avec « Jouer à partir d'ici ».
|
||||
2. L'aide, une question de l'aide, un terme du glossaire, la progression : chaque
|
||||
écran a son adresse ; le bouton retour du téléphone ferme d'abord la feuille
|
||||
ouverte, puis revient à l'écran précédent.
|
||||
|
||||
## G. Commun : installation, hors ligne, affichage
|
||||
|
||||
1. Après une partie terminée, l'accueil propose « Installer l'application » :
|
||||
installez-la, puis ouvrez-la depuis l'écran d'accueil du téléphone.
|
||||
2. Mode avion : l'application s'ouvre et se joue normalement.
|
||||
3. Tournez le téléphone : en paysage, le plateau prend l'écran, la partie
|
||||
continue sans perte. Essayez « Plein écran » dans le menu.
|
||||
4. Menu, thème clair puis sombre : les pions blancs et noirs se distinguent
|
||||
bien partout, y compris sur la barre.
|
||||
5. Vibrations : pion posé, pion frappé, double de l'ordinateur, fin de partie.
|
||||
Coupez-les dans le menu.
|
||||
6. Mise à jour : à la prochaine publication, le bandeau « Nouvelle version
|
||||
prête » apparaît en bas ; « Mettre à jour » vous ramène à votre partie.
|
||||
7. Performances : notez si l'application ralentit, chauffe ou se recharge
|
||||
seule (elle fait tourner deux moteurs : un pour jouer, un pour analyser).
|
||||
|
||||
## Ce qu'il faut rapporter
|
||||
|
||||
- Le numéro de version (onglet « À propos »).
|
||||
- Pour chaque problème : le numéro du point, ce que vous avez fait, ce que vous
|
||||
attendiez, ce qui s'est passé, et si possible une capture ou le lien de la
|
||||
position.
|
||||
- Votre avis sur les deux critères : un débutant peut-il apprendre et jouer
|
||||
seul ? Un joueur de club comprend-il ses erreurs ?
|
||||
@@ -236,18 +236,8 @@ l'ordre, chacune avec une pastille ronde à son numéro :
|
||||
|
||||
Toutes les leçons restent ouvertes : l'ordre est conseillé, jamais imposé.
|
||||
Les écrans d'une leçon suivent la forme du tutoriel (barre de progression,
|
||||
« Quitter », « Retour », « Continuer », écrans « à vous » contrôlés par le
|
||||
moteur), aux adresses `#/lecons/<n>/<écran>`, `…/a-vous` et `…/fin`.
|
||||
|
||||
- Une leçon est **terminée** quand tous ses exercices sont réussis.
|
||||
- « Retour » et « Continuer » restent **en bas de l'écran**, sous le pouce,
|
||||
comme dans le tutoriel, quelle que soit la longueur du texte.
|
||||
- « Revoir » (rejouer le coup illustré) se place sur la ligne du titre, à
|
||||
droite, en portrait comme en paysage : le texte garde toute la largeur.
|
||||
- En paysage, la colonne de droite garde la ligne `caption` « Leçon 1 · 1 sur
|
||||
4 · À vous » au-dessus du titre.
|
||||
- Les titres et les textes n'emploient que les mots du glossaire : « point »
|
||||
et « flèche », jamais « case ».
|
||||
« Passer », « Retour », « Continuer », écrans « à vous » contrôlés par le
|
||||
moteur).
|
||||
|
||||
## 1h. Progression (`#/progression`)
|
||||
|
||||
|
||||
@@ -47,3 +47,6 @@ Référence : `interaction.md`, maquettes « Révision UX » du canevas.
|
||||
- [ ] « Nouvelle partie » depuis le menu : effet immédiat, bandeau « Reprendre l'ancienne » 10 s, et la reprise restaure la partie exacte.
|
||||
- [ ] Vibrations aux quatre moments, coupées par l'interrupteur du menu (test unitaire sur `haptic()` ; ressenti à la main).
|
||||
- [ ] Écran maintenu allumé pendant une partie, interrupteur dans le menu.
|
||||
- [ ] Glisser-déposer : le pion suit le doigt, agrandi et au-dessus de lui, un fantôme en pointillés à son départ ; lâché hors d'une case permise, il revient à sa place sans vibration ni message (`tests/browser/plateau.mjs`, captures `plateau/glissement-*`).
|
||||
- [ ] Double toucher : le coup évident seulement (un seul coup commence par ce pion, ou un seul coup possible) ; sinon rien de plus qu'un toucher.
|
||||
- [ ] Clavier : `←` `→` choisissent un pion, `↑` `↓` sa case (anneau `focus`), `Entrée` le pose ; les raccourcis s'affichent en `kbd` sur les boutons dès qu'un clavier est détecté (capture `plateau/clavier-*`).
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
# Leçons
|
||||
|
||||
Les cinq premières leçons d'Ustagammon (ADR 0011, étape 1g), une leçon par
|
||||
fichier, en une seule source (ADR 0008) : `lecon-1.md` à `lecon-5.md`.
|
||||
`make site` copie ce dossier dans le site publié (`docs/lecons/`) ;
|
||||
l'application le lit à l'exécution (`app/src/ui/lesson-text.ts`), et
|
||||
`make test` vérifie chaque position, chaque coup et chaque compte de points
|
||||
avec le vrai moteur (`app/tests/lessons.test.ts`).
|
||||
|
||||
Textes sous licence **CC BY-SA 4.0**. Première version, rédigée par Claude
|
||||
Code ; décision de Pierre du 2026-11-09 : pas de relecture préalable par un
|
||||
joueur fort, les leçons sont livrées telles quelles et améliorées sur
|
||||
retours d'experts.
|
||||
|
||||
## Garde-fous
|
||||
|
||||
- Chaque coup attendu d'un exercice « à vous » est le meilleur coup du
|
||||
moteur à 2 plis, avec plus de 0,04 d'équité d'avance sur le deuxième :
|
||||
un exercice n'a qu'une bonne réponse nette. `make test` le vérifie.
|
||||
- Chaque coup illustré d'un écran d'explication est aussi le meilleur coup
|
||||
du moteur à 2 plis.
|
||||
- Le texte n'affirme rien que le moteur ne confirme : les coups du maître,
|
||||
les points faits, les pions frappés ou laissés seuls (lisibles dans la
|
||||
position), les comptes de points (`compte`, vérifiés par le test). Aucune
|
||||
explication stratégique inventée.
|
||||
- Le vocabulaire est celui du glossaire (`docs/glossaire/`) : flèche, point,
|
||||
jan intérieur, jan extérieur, blot, barre ; vos pions sont blancs, ceux de
|
||||
l'ordinateur noirs.
|
||||
- Le ton est celui du maître (`docs/maitre/phrases.md`) : première
|
||||
personne, vouvoiement, sobre, sans point d'exclamation ni reproche.
|
||||
|
||||
## Format
|
||||
|
||||
Celui du tutoriel (`docs/tutoriel/README.md`), avec deux ajouts :
|
||||
|
||||
- la première ligne, `# <titre>`, donne le titre de la leçon ; la notice qui
|
||||
suit, jusqu'à la première étape, n'est pas affichée ;
|
||||
- `compte: <vous>/<ordinateur>`, en tête d'un écran, donne les comptes de
|
||||
points de sa position, que le texte cite ; le test les compare à ceux du
|
||||
moteur.
|
||||
|
||||
Chaque leçon a de deux à quatre exercices « à vous » (`### À vous`), et un
|
||||
écran de fin (`## Fin`) dont l'action mène à la leçon suivante, ou à la
|
||||
liste des leçons après la dernière.
|
||||
@@ -1,84 +0,0 @@
|
||||
# Les ouvertures
|
||||
|
||||
Leçon 1 d'Ustagammon : bien jouer ses premiers dés. Texte sous licence
|
||||
CC BY-SA 4.0. Format : voir README.md.
|
||||
|
||||
## 1. Un point dès le premier coup
|
||||
|
||||
- position: 4HPwATDgc/ABMA
|
||||
- jet: 3-1
|
||||
- coup: 8/5 6/5
|
||||
|
||||
Au premier coup, avec 3 et 1, je joue 8/5 6/5 : deux pions blancs se posent sur la flèche 5. Ce [point](point.md) est fait, l'ordinateur ne peut plus s'y poser.
|
||||
|
||||
La flèche 5 est dans votre [jan intérieur](jan-interieur.md), là où vous rassemblerez vos pions avant de les sortir. Le moteur classe ce coup premier, nettement devant tous les autres.
|
||||
|
||||
### À vous
|
||||
|
||||
- position: 4HPwATDgc/ABMA
|
||||
- jet: 4-2
|
||||
- attendu: 8/4 6/4
|
||||
- juste: C'est aussi mon coup : 8/4 6/4 fait le point 4.
|
||||
- refus: Ce coup est permis, mais je préfère faire un point. Cherchez la flèche de votre jan intérieur où le 4 et le 2 amènent chacun un pion.
|
||||
|
||||
Vous avez 4 et 2. Faites un point dans votre jan intérieur.
|
||||
|
||||
## 2. La flèche 7
|
||||
|
||||
- position: 4HPwATDgc/ABMA
|
||||
|
||||
La flèche 7 ouvre votre [jan extérieur](jan-exterieur.md), juste à côté de la barre. Fermée, elle formerait avec la flèche 6, où sont déjà vos cinq pions, deux points côte à côte.
|
||||
|
||||
Les deux pions noirs de votre flèche 1 doivent passer par là : depuis la flèche 1, un 6 les mène sur la flèche 7, et un 5 est déjà bloqué par votre point 6.
|
||||
|
||||
### À vous
|
||||
|
||||
- position: 4HPwATDgc/ABMA
|
||||
- jet: 6-1
|
||||
- attendu: 13/7 8/7
|
||||
- juste: C'est mon coup : 13/7 8/7 fait le point 7.
|
||||
- refus: Ce coup est permis, mais je préfère fermer la flèche 7 : un pion y arrive avec le 6, un autre avec le 1.
|
||||
|
||||
Vous avez 6 et 1. Fermez une flèche de votre jan extérieur.
|
||||
|
||||
## 3. Répondre avec un double
|
||||
|
||||
- position: sGfwATDgc/ABMA
|
||||
|
||||
L'ordinateur a ouvert avec 3-1 : il a fait le point 5 de son jan intérieur, votre flèche 20. À vous de lancer les dés.
|
||||
|
||||
Un double se joue quatre fois. Dans la notation des coups, « (2) » veut dire que deux pions font le même trajet.
|
||||
|
||||
### À vous
|
||||
|
||||
- position: sGfwATDgc/ABMA
|
||||
- jet: 6-6
|
||||
- attendu: 24/18(2) 13/7(2)
|
||||
- juste: C'est mon coup : 24/18(2) 13/7(2) fait deux points, sur la flèche 18 et sur la flèche 7.
|
||||
- refus: Ce coup est permis, mais je préfère faire deux points : un avec les deux pions de la flèche 24, un autre avec deux pions de la flèche 13.
|
||||
|
||||
Vous avez 6-6, quatre fois 6. Faites deux points avec ce jet.
|
||||
|
||||
## 4. Un double qui ne passe pas partout
|
||||
|
||||
- position: sGfwATDgc/ABMA
|
||||
|
||||
Même position. Les pions de votre flèche 24 ne peuvent pas jouer de 5 : la flèche 19 est fermée par quatre pions noirs.
|
||||
|
||||
Avec 5-5, il faut donc jouer les quatre 5 avec vos autres pions.
|
||||
|
||||
### À vous
|
||||
|
||||
- position: sGfwATDgc/ABMA
|
||||
- jet: 5-5
|
||||
- attendu: 13/3(2)
|
||||
- juste: C'est mon coup : 13/3(2) amène deux pions de la flèche 13 jusqu'à la flèche 3, où ils font un point.
|
||||
- refus: Ce coup est permis, mais je préfère amener deux pions de la flèche 13 jusque dans votre jan intérieur, où ils font un point ensemble.
|
||||
|
||||
Vous avez 5-5. Faites un point dans votre jan intérieur.
|
||||
|
||||
## Fin
|
||||
|
||||
- action: Leçon suivante
|
||||
|
||||
Avec 3-1, 4-2 et 6-1, le moteur fait un point dès le premier coup. Pour les autres jets, je vous dirai en partie ce que j'aurais joué.
|
||||
@@ -1,66 +0,0 @@
|
||||
# Faire des points
|
||||
|
||||
Leçon 2 d'Ustagammon : construire son jeu en faisant des points. Texte sous
|
||||
licence CC BY-SA 4.0. Format : voir README.md.
|
||||
|
||||
## 1. Deux pions font un point
|
||||
|
||||
- position: 4HPwATDQc+IBMA
|
||||
- jet: 6-4
|
||||
- coup: 10/4 8/4
|
||||
|
||||
Une flèche où vous avez deux pions ou plus est un [point](point.md) fait : un pion noir ne peut pas s'y poser. Un pion noir frappé doit rentrer dans votre [jan intérieur](jan-interieur.md) : chaque point que vous y fermez lui retire une flèche d'entrée.
|
||||
|
||||
Avec 6 et 4, je joue 10/4 8/4 : les deux pions arrivent ensemble sur la flèche 4.
|
||||
|
||||
### À vous
|
||||
|
||||
- position: 4HPwATDQc+IBMA
|
||||
- jet: 6-2
|
||||
- attendu: 13/5
|
||||
- juste: C'est mon coup : 13/5 rejoint le pion de la flèche 5 et fait le point.
|
||||
- refus: Ce coup est permis, mais je préfère couvrir le pion seul de la flèche 5 : un pion de la flèche 13 y arrive avec le 6 et le 2.
|
||||
|
||||
Même position, vous avez 6 et 2. Votre pion de la flèche 5 est seul : couvrez-le.
|
||||
|
||||
## 2. Des pions prêts à construire
|
||||
|
||||
- position: 4HPwATDQZ+EBMA
|
||||
|
||||
Un pion posé sur les flèches 7 à 12, dans votre [jan extérieur](jan-exterieur.md), peut entrer dans votre jan intérieur avec un seul dé.
|
||||
|
||||
Ici, un pion blanc est seul sur la flèche 5, un autre sur la flèche 9.
|
||||
|
||||
### À vous
|
||||
|
||||
- position: 4HPwATDQZ+EBMA
|
||||
- jet: 4-1
|
||||
- attendu: 13/9 6/5
|
||||
- juste: C'est mon coup : 6/5 fait le point 5, et 13/9 amène un deuxième pion sur la flèche 9.
|
||||
- refus: Ce coup est permis, mais ce n'est pas le mien. Je couvre la flèche 5 avec le 1, depuis la flèche 6, et je joue le 4 depuis la flèche 13.
|
||||
|
||||
Vous avez 4 et 1. Couvrez votre pion de la flèche 5 et jouez l'autre dé au mieux.
|
||||
|
||||
## 3. Les doubles construisent vite
|
||||
|
||||
- position: 4HPwATDgc/ABMA
|
||||
- jet: 2-2
|
||||
- coup: 13/11(2) 6/4(2)
|
||||
|
||||
Un double se joue quatre fois : de quoi faire deux points d'un coup. Au départ, avec 2-2, je joue 13/11(2) 6/4(2) : un point sur la flèche 4, un autre sur la flèche 11.
|
||||
|
||||
### À vous
|
||||
|
||||
- position: 4HPwATDgc/ABMA
|
||||
- jet: 3-3
|
||||
- attendu: 8/5(2) 6/3(2)
|
||||
- juste: C'est mon coup : 8/5(2) 6/3(2) fait deux points dans votre jan intérieur, sur les flèches 5 et 3.
|
||||
- refus: Ce coup est permis, mais je préfère faire deux points dans votre jan intérieur : deux pions de la flèche 8 et deux pions de la flèche 6 avancent de 3.
|
||||
|
||||
Vous avez 3-3. Faites deux points dans votre jan intérieur.
|
||||
|
||||
## Fin
|
||||
|
||||
- action: Leçon suivante
|
||||
|
||||
Un point se fait à deux pions, et un double peut en faire deux d'un coup. Dans la leçon suivante : frapper un pion noir, ou ne pas le frapper.
|
||||
@@ -1,62 +0,0 @@
|
||||
# Frapper ou ne pas frapper
|
||||
|
||||
Leçon 3 d'Ustagammon : frapper ou ne pas frapper, sécurité et risque. Texte
|
||||
sous licence CC BY-SA 4.0. Format : voir README.md.
|
||||
|
||||
## 1. Frapper en faisant un point
|
||||
|
||||
- position: 4HPwQSDgc/ABMA
|
||||
- jet: 6-1
|
||||
- coup: 13/7* 8/7
|
||||
|
||||
Un pion noir est seul sur votre flèche 7 : c'est un [blot](blot.md). Avec 6 et 1, je joue 13/7* 8/7. L'astérisque marque le pion frappé : il part sur la [barre](barre.md).
|
||||
|
||||
Les deux pions blancs restent ensemble sur la flèche 7 : le point est fait, aucun pion noir ne peut plus s'y poser.
|
||||
|
||||
### À vous
|
||||
|
||||
- position: 4HPwASHgc/ABMA
|
||||
- jet: 3-1
|
||||
- attendu: 8/5* 6/5
|
||||
- juste: C'est mon coup : 8/5* 6/5 frappe le pion noir et fait le point 5.
|
||||
- refus: Ce coup est permis, mais je préfère frapper le pion noir de la flèche 5 avec deux pions à la fois : l'un avec le 3, l'autre avec le 1.
|
||||
|
||||
Cette fois, le pion noir est seul sur votre flèche 5. Vous avez 3 et 1.
|
||||
|
||||
## 2. Un point plutôt qu'un coup risqué
|
||||
|
||||
- position: 4HPwASHgc/ABMA
|
||||
|
||||
Même position. Un pion blanc qui frappe seul devient lui-même un blot : si un pion noir se pose dessus, c'est lui qui part sur la barre.
|
||||
|
||||
### À vous
|
||||
|
||||
- position: 4HPwASHgc/ABMA
|
||||
- jet: 6-1
|
||||
- attendu: 13/7 8/7
|
||||
- juste: C'est mon coup : 13/7 8/7 fait le point 7, sans frapper. Le moteur classe tous les coups qui frappent derrière celui-ci.
|
||||
- refus: Ce coup est permis, mais ce n'est pas le mien. Avec ce jet, je préfère faire un point plutôt que frapper : cherchez la flèche où le 6 et le 1 se rejoignent.
|
||||
|
||||
Vous avez 6 et 1. Le 1 frapperait depuis la flèche 6, mais je joue autrement. Trouvez mon coup.
|
||||
|
||||
## 3. Ne pas frapper avec un seul pion
|
||||
|
||||
- position: 4HPwQSDgc/ABMA
|
||||
|
||||
Retour au pion noir de votre flèche 7, avec un autre jet : 3 et 1. Le 1 le frapperait depuis la flèche 8, mais le pion qui frappe resterait seul sur la flèche 7.
|
||||
|
||||
### À vous
|
||||
|
||||
- position: 4HPwQSDgc/ABMA
|
||||
- jet: 3-1
|
||||
- attendu: 8/5 6/5
|
||||
- juste: C'est mon coup : 8/5 6/5 fait le point 5, sans frapper.
|
||||
- refus: Ce coup est permis, mais je préfère ne pas frapper ici et faire un point dans votre jan intérieur, comme à l'ouverture.
|
||||
|
||||
Vous avez 3 et 1. Frappez-vous ?
|
||||
|
||||
## Fin
|
||||
|
||||
- action: Leçon suivante
|
||||
|
||||
Dans ces positions, je frappe quand je fais un point en même temps, et je préfère faire un point quand le pion qui frappe resterait seul. En partie, je vous dirai ce que j'aurais joué.
|
||||
@@ -1,76 +0,0 @@
|
||||
# La course et le compte de points
|
||||
|
||||
Leçon 4 d'Ustagammon : la course et le compte de points. Texte sous licence
|
||||
CC BY-SA 4.0. Format : voir README.md.
|
||||
|
||||
## 1. Compter ses points
|
||||
|
||||
- position: 4HPwATDgc/ABMA
|
||||
- compte: 167/167
|
||||
- jet: 6-1
|
||||
- coup: 13/7 8/7
|
||||
|
||||
Votre [compte de points](compte-de-points.md) est le nombre de flèches que vos pions doivent encore parcourir pour sortir : un pion sur la flèche 13 compte pour 13, un pion sur la barre pour 25. Au départ, chacun en a 167.
|
||||
|
||||
Chaque coup le fait baisser de la somme des dés : après 6-1, le vôtre passe à 160. Le plus petit compte est en avance dans la [course](course.md).
|
||||
|
||||
## 2. En avance, j'avance
|
||||
|
||||
- position: cM8NBwC49xkAIA
|
||||
- compte: 102/120
|
||||
|
||||
Ici, vos pions ont 102 flèches à parcourir, ceux de l'ordinateur 120 : vous avez 18 points d'avance.
|
||||
|
||||
Il vous reste un pion dans le jan intérieur de l'ordinateur, sur votre flèche 24.
|
||||
|
||||
### À vous
|
||||
|
||||
- position: cM8NBwC49xkAIA
|
||||
- jet: 6-5
|
||||
- attendu: 24/13
|
||||
- juste: C'est mon coup : 24/13. Avec 18 points d'avance, mon dernier pion quitte le jan intérieur de l'ordinateur.
|
||||
- refus: Ce coup est permis, mais je préfère faire avancer le pion de la flèche 24 avec les deux dés : il quitte le jan intérieur de l'ordinateur.
|
||||
|
||||
Vous avez 6 et 5. Que faites-vous de votre dernier pion ?
|
||||
|
||||
## 3. Encore en avance
|
||||
|
||||
- position: cM8ZBwC49xkAEA
|
||||
- compte: 101/122
|
||||
|
||||
Autre position : 101 contre 122, vous avez 21 points d'avance. Un de vos pions est encore sur la flèche 23.
|
||||
|
||||
### À vous
|
||||
|
||||
- position: cM8ZBwC49xkAEA
|
||||
- jet: 5-2
|
||||
- attendu: 23/16
|
||||
- juste: C'est mon coup : 23/16. Le pion de la flèche 23 avance avec les deux dés.
|
||||
- refus: Ce coup est permis, mais je préfère faire avancer le pion de la flèche 23 avec les deux dés.
|
||||
|
||||
Vous avez 5 et 2.
|
||||
|
||||
## 4. En retard, je frappe
|
||||
|
||||
- position: 3L0HIADYPQcHAA
|
||||
- compte: 110/81
|
||||
|
||||
Ici, les pions de l'ordinateur ont 81 flèches à parcourir, les vôtres 110 : il a 29 points d'avance. Son dernier pion est seul sur votre flèche 9.
|
||||
|
||||
Frappé, ce pion part sur la barre : pour l'ordinateur, il comptait 16 et compte maintenant 25, soit 9 de plus.
|
||||
|
||||
### À vous
|
||||
|
||||
- position: 3L0HIADYPQcHAA
|
||||
- jet: 4-3
|
||||
- attendu: 13/10 13/9*
|
||||
- juste: C'est mon coup : 13/10 13/9* frappe le dernier pion noir. Sur la barre, il compte 25 : le compte de l'ordinateur passe de 81 à 90.
|
||||
- refus: Ce coup est permis, mais je préfère frapper le pion noir seul de la flèche 9 : un pion de la flèche 13 y arrive avec le 4.
|
||||
|
||||
Vous avez 4 et 3.
|
||||
|
||||
## Fin
|
||||
|
||||
- action: Leçon suivante
|
||||
|
||||
Comptez avant de choisir. Dans ces positions, en avance, je fais avancer mon dernier pion ; en retard, je frappe celui de l'ordinateur.
|
||||
@@ -1,64 +0,0 @@
|
||||
# Sortir sans se faire frapper
|
||||
|
||||
Leçon 5 d'Ustagammon : sortir ses pions sans se faire frapper. Texte sous
|
||||
licence CC BY-SA 4.0. Format : voir README.md.
|
||||
|
||||
## 1. Un pion noir encore chez vous
|
||||
|
||||
- position: trsHACC2uwMAAA
|
||||
- jet: 6-3
|
||||
- coup: 6/off 5/2
|
||||
|
||||
Tous vos pions sont dans votre [jan intérieur](jan-interieur.md) : vous pouvez les [sortir](sortie.md). Mais un pion noir est encore sur votre flèche 1, et il peut frapper un pion blanc laissé seul.
|
||||
|
||||
Avec 6 et 3, je joue 6/off 5/2 : un pion sort, et aucun pion blanc ne reste seul. 6/off 3/off en sortirait deux, mais laisserait un pion seul sur la flèche 3.
|
||||
|
||||
### À vous
|
||||
|
||||
- position: trsHACC2uwMAAA
|
||||
- jet: 6-4
|
||||
- attendu: 6/off 4/off
|
||||
- juste: C'est mon coup : 6/off 4/off sort deux pions et ne laisse aucun pion seul.
|
||||
- refus: Ce coup est permis, mais il laisse un pion blanc seul, ou sort moins de pions que le mien. Cherchez à sortir deux pions sans laisser de pion seul.
|
||||
|
||||
Même position, vous avez 6 et 4.
|
||||
|
||||
## 2. Un pion noir sur la barre
|
||||
|
||||
- position: trsHAEBtdw8AAA
|
||||
|
||||
Ici, le pion noir est sur la [barre](barre.md) : il doit rentrer dans votre jan intérieur, et s'il se pose sur un pion blanc seul, il le frappe.
|
||||
|
||||
Votre pion de la flèche 1 est seul.
|
||||
|
||||
### À vous
|
||||
|
||||
- position: trsHAEBtdw8AAA
|
||||
- jet: 6-1
|
||||
- attendu: 6/off 1/off
|
||||
- juste: C'est mon coup : 6/off 1/off sort deux pions, dont le pion seul de la flèche 1.
|
||||
- refus: Ce coup est permis, mais je préfère sortir deux pions, dont celui qui est seul sur la flèche 1.
|
||||
|
||||
Vous avez 6 et 1.
|
||||
|
||||
## 3. Deux pions noirs en embuscade
|
||||
|
||||
- position: uHsOADC23QMAAA
|
||||
|
||||
Deux pions noirs tiennent votre flèche 1 : ils peuvent frapper tout pion blanc laissé seul à leur portée.
|
||||
|
||||
### À vous
|
||||
|
||||
- position: uHsOADC23QMAAA
|
||||
- jet: 5-2
|
||||
- attendu: 6/4 5/off
|
||||
- juste: C'est mon coup : 6/4 5/off sort un pion et ne laisse aucun pion seul.
|
||||
- refus: Ce coup est permis, mais il laisse un pion blanc seul, ou ne sort aucun pion. Le mien sort un pion sans en laisser un seul.
|
||||
|
||||
Vous avez 5 et 2.
|
||||
|
||||
## Fin
|
||||
|
||||
- action: Toutes les leçons
|
||||
|
||||
Dans ces positions, avec des pions noirs encore dans votre jan intérieur, je sors mes pions sans en laisser un seul. Les leçons suivantes viendront avec les prochaines versions.
|
||||
@@ -0,0 +1,36 @@
|
||||
# Cinq premières leçons et accueil complet (1g)
|
||||
|
||||
- **Date** : 2026-11-03
|
||||
- **Étape** : 1g (`docs/etape-1.md`) ; le tutoriel des règles est traité en 0e.5
|
||||
- **Statut** : prévu (après 0e.5 et 1d)
|
||||
- **Exécutant** : sous-agent general-purpose, dans un worktree
|
||||
|
||||
## Objectif
|
||||
|
||||
Mettre en place les cinq premières leçons numérotées de l'ADR 0011. Chaque leçon est faite d'écrans courts : une explication, une position animée, puis « à vous de jouer ». La progression est stockée localement, et l'accueil propose « Continuer la leçon ».
|
||||
|
||||
## Consignes
|
||||
|
||||
1. **Contenus.** Ils vivent dans une source unique sous `docs/lecons/`, sous licence CC BY-SA 4.0, au même format que le tutoriel de 0e.5 (réutiliser son moteur d'écrans). Cinq leçons du parcours 2 du cadrage, au niveau débutant :
|
||||
1. Les ouvertures : bien jouer ses premiers dés.
|
||||
2. Construire ses cases et faire des points.
|
||||
3. Frapper ou ne pas frapper : sécurité et risque.
|
||||
4. La course et le compte de points.
|
||||
5. Sortir ses pions sans se faire frapper.
|
||||
|
||||
Chaque leçon compte deux à quatre exercices « à vous ». Leurs positions sont données par identifiants gnubg. La réponse attendue est le meilleur coup du moteur à 2 plis, vérifié par un test. Le texte n'affirme rien que le moteur ne confirme. Décision de Pierre du 2026-11-09 : pas de relecture préalable par un joueur fort ; les leçons sont livrées telles quelles et améliorées sur retours d'experts. Les garde-fous restent : chaque réponse attendue est le meilleur coup du moteur, vérifié par un test, et le texte n'affirme rien que le moteur ne confirme.
|
||||
2. **Progression.** Elle est enregistrée dans le stockage de 1b, dans le magasin `settings` ou dans un nouveau magasin avec montée de version (ADR 0007 amendé). Chaque leçon a son état : à commencer, « 2 exercices sur 4 », terminée.
|
||||
3. **Adresses** : `#/lecons` et `#/lecons/<n>/<écran>`, à ajouter au tableau de l'ADR 0013.
|
||||
4. **Accueil.** Il reçoit la carte « prochaine leçon » prévue par la charte.
|
||||
5. **Tests.**
|
||||
- Pour chaque exercice, la position est valide et la réponse attendue est bien le meilleur coup du moteur.
|
||||
- Scénarios Gherkin.
|
||||
- `make lint`, `make test`, `make test-browser`, avec captures.
|
||||
|
||||
## Critères de réussite
|
||||
|
||||
Les cinq leçons se suivent jusqu'au bout. Chaque exercice accepte le coup du moteur et commente les autres avec bienveillance. Les trois commandes passent.
|
||||
|
||||
## Résultat
|
||||
|
||||
À remplir à la clôture.
|
||||
@@ -1,32 +0,0 @@
|
||||
# Plateau soigné : gestes et finitions (1i)
|
||||
|
||||
- **Date** : 2026-11-03
|
||||
- **Étape** : 1i (`docs/etape-1.md`)
|
||||
- **Statut** : prévu
|
||||
- **Exécutant** : sous-agent general-purpose, dans un worktree
|
||||
|
||||
## Objectif
|
||||
|
||||
Terminer le plateau soigné du cadrage, une fois la révision UX en place :
|
||||
- les gestes naturels (principe d'expérience 2) ;
|
||||
- les écarts restants avec `docs/identite/charte/board.js`.
|
||||
|
||||
## Consignes
|
||||
|
||||
1. **Glisser-déposer.** On peut glisser un pion en plus du toucher pion puis destination. Le pion suit le doigt, et le lâcher hors d'une case permise le ramène à sa place. Les cibles restent celles du moteur.
|
||||
2. **Double toucher.** Un double toucher sur un pion joue le seul coup évident (cadrage). Un « coup évident » est défini ainsi : un seul coup légal complet commence par ce pion, ou le moteur ne propose qu'un coup. Sinon, rien ne se passe.
|
||||
3. **Écarts restants avec board.js.** Les relever par comparaison visuelle (filet des pions, numéros sur le cadre, videau…) et les corriger.
|
||||
4. **Clavier.** Faire ce que la révision UX a laissé :
|
||||
- le choix des pions aux flèches ;
|
||||
- l'affichage des raccourcis en `kbd` quand un clavier est détecté (interaction.md).
|
||||
5. **Tests.**
|
||||
- Scénarios Gherkin si un nouveau comportement est visible.
|
||||
- `make lint`, `make test`, `make test-browser` (glisser-déposer simulé, double toucher), avec captures pour la recette design.
|
||||
|
||||
## Critères de réussite
|
||||
|
||||
Un coup se joue entièrement au glisser-déposer. Le double toucher joue le coup évident. Les trois commandes passent.
|
||||
|
||||
## Résultat
|
||||
|
||||
À remplir à la clôture.
|
||||
@@ -65,5 +65,4 @@ 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 |
|
||||
|
||||
@@ -40,6 +40,24 @@ Fonctionnalité: Jouer une partie contre l'ordinateur
|
||||
Alors seul votre 3 est marqué comme joué
|
||||
Et chacun de vos pions jouables n'a plus qu'une case d'arrivée
|
||||
|
||||
@position:BQAACAIAAAAAAA
|
||||
Scénario: Puis-je faire glisser un pion, ou jouer au clavier ?
|
||||
Vous pouvez aussi faire glisser un pion jusqu'à sa case d'arrivée : il
|
||||
suit votre doigt, juste au-dessus pour rester visible, et un pion en
|
||||
pointillés marque sa place de départ. Lâché ailleurs que sur une case
|
||||
permise, il revient à sa place. Touchez deux fois de suite un pion pour
|
||||
jouer le coup évident : quand un seul coup commence par ce pion, ou que
|
||||
vous n'avez qu'un coup possible. Sinon, rien de plus ne se passe. Votre
|
||||
coup attend ensuite votre validation, comme d'habitude. Au clavier, les
|
||||
flèches gauche et droite choisissent un pion, les flèches haut et bas sa
|
||||
case d'arrivée, et Entrée le pose.
|
||||
|
||||
Étant donné que vous avez un pion sur la flèche 6 et un sur la flèche 1, avec le jet 6-1
|
||||
Quand vous demandez le coup évident de votre pion de la flèche 6
|
||||
Alors aucun pion n'a bougé
|
||||
Quand vous demandez le coup évident de votre pion de la flèche 1
|
||||
Alors vos deux pions sont sortis
|
||||
|
||||
Scénario: Je me suis trompé de pion
|
||||
Tant que vous n'avez pas validé votre coup, « Annuler » reprend le
|
||||
dernier pion déplacé ; touchez-le encore pour reprendre le précédent.
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
# language: fr
|
||||
Fonctionnalité: Progresser avec les leçons
|
||||
|
||||
Cinq leçons courtes, sur le vrai plateau : les ouvertures, faire des
|
||||
points, 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
|
||||
+10
-10
@@ -18,12 +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/. 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/.
|
||||
# build/browser-shots/retours-joueurs/. Les gestes du plateau soigné
|
||||
# (tests/browser/plateau.mjs : glisser-déposer, pion lâché hors cible, double
|
||||
# toucher, clavier, filet des pions), captures dans build/browser-shots/plateau/.
|
||||
#
|
||||
# BROWSERS=chromium et SCRIPTS=accueil (ou play, match, maitre, bilan,
|
||||
# prompts, nouvelle-partie, retours-joueurs, lecons) restreignent le test.
|
||||
# prompts, nouvelle-partie, retours-joueurs, plateau) 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
|
||||
@@ -36,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 lecons}"
|
||||
scripts="${SCRIPTS:-play accueil match maitre bilan prompts nouvelle-partie retours-joueurs plateau}"
|
||||
|
||||
unset MAKEFLAGS MFLAGS MAKELEVEL
|
||||
make -s package >/dev/null
|
||||
@@ -75,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 tests/browser/lecons.mjs "$tools/"
|
||||
tests/browser/retours-joueurs.mjs tests/browser/plateau.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
|
||||
@@ -138,12 +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/.
|
||||
if [[ " $scripts " == *" plateau "* ]]; then
|
||||
# Gestes du plateau : glisser-déposer, lâcher hors cible, double
|
||||
# toucher, clavier ; captures dans build/browser-shots/plateau/.
|
||||
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
|
||||
node plateau.mjs "http://localhost:$port/" "$browser" /shots || status=1
|
||||
fi
|
||||
done
|
||||
exit "$status"
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
|
||||
import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
|
||||
import { chromium, firefox } from "playwright";
|
||||
import { hiddenUnderActionBar, missingTopbar, tutorialWidthProblems, tutorialWidths } from "./recette.mjs";
|
||||
import { hiddenUnderActionBar, tutorialWidthProblems, tutorialWidths } from "./recette.mjs";
|
||||
|
||||
const [base, browserName, shots, updateBase, updateDir] = process.argv.slice(2);
|
||||
const browserType = { chromium, firefox }[browserName];
|
||||
@@ -117,8 +117,6 @@ async function capture(page, name, settle = async () => {}, sizes = SIZES) {
|
||||
await settle();
|
||||
const hidden = await hiddenUnderActionBar(page);
|
||||
check(hidden.length === 0, `${name} ${width}x${height} : caché sous la barre d'action : ${hidden.join(", ")}`);
|
||||
const topbar = await missingTopbar(page);
|
||||
check(topbar === null, `${name} : ${topbar}`);
|
||||
await page.screenshot({ path: `${dir}/${name}-${width}x${height}.png`, fullPage: true });
|
||||
captures++;
|
||||
}
|
||||
|
||||
@@ -1,301 +0,0 @@
|
||||
// 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, missingTopbar, 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 topbar = await missingTopbar(page);
|
||||
check(topbar === null, `${name} : ${topbar}`);
|
||||
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 &&
|
||||
document.getElementById("home-progress").textContent.includes(" · ");
|
||||
});
|
||||
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("Aucune 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");
|
||||
const rulesHeight = await page.evaluate(() => document.getElementById("lessons-rules").getBoundingClientRect().height);
|
||||
check(rulesHeight >= 44, `liste : rappel des règles haut de ${rulesHeight} px`);
|
||||
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)}`);
|
||||
// « Revoir » sur la ligne du titre ; « Retour » et « Continuer » en bas
|
||||
// de l'écran, sous le pouce (recette 1g).
|
||||
const layout = await page.evaluate(() => {
|
||||
const title = document.getElementById("lesson-title").getBoundingClientRect();
|
||||
const replay = document.getElementById("lesson-replay").getBoundingClientRect();
|
||||
const next = document.getElementById("lesson-next").getBoundingClientRect();
|
||||
return { titleTop: title.top, titleBottom: title.bottom, replayTop: replay.top,
|
||||
replayBottom: replay.bottom, nextBottom: next.bottom, height: innerHeight };
|
||||
});
|
||||
check(layout.replayTop < layout.titleBottom && layout.replayBottom > layout.titleTop,
|
||||
`« Revoir » hors de la ligne du titre : ${JSON.stringify(layout)}`);
|
||||
check(layout.nextBottom > layout.height - 64, `« Continuer » loin du bas : ${JSON.stringify(layout)}`);
|
||||
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();
|
||||
@@ -329,8 +329,8 @@ for (;;) {
|
||||
await page.click("#summary-close");
|
||||
await until("retour au résultat du match", () => location.hash === "#/" &&
|
||||
!document.getElementById("again").hidden);
|
||||
check((await page.textContent("#again")) === "Rejouer",
|
||||
`après le match, barre d'actions : « ${await page.textContent("#again")} »`);
|
||||
check((await page.textContent("#again > span")) === "Rejouer",
|
||||
`après le match, barre d'actions : « ${await page.textContent("#again > span")} »`);
|
||||
log(`match ${matches} fini : ${games} parties, ${crawfordGames} Crawford au total`);
|
||||
await page.click("#again");
|
||||
await until("match rejoué", () => location.hash === "#/" &&
|
||||
|
||||
@@ -0,0 +1,328 @@
|
||||
// plateau.mjs -- les gestes du plateau soigné (étape 1i ; charte,
|
||||
// ecrans-etape-1.md, 1i ; interaction.md), joués dans un vrai navigateur sans
|
||||
// écran (Playwright), sur le site servi par l'image de production :
|
||||
//
|
||||
// - un coup joué entièrement au glisser-déposer (pion fantôme au départ, pion
|
||||
// glissé agrandi, cases d'arrivée du moteur) ;
|
||||
// - un pion lâché hors d'une case permise revient à sa place, sans rien
|
||||
// jouer ;
|
||||
// - le double toucher : rien quand plusieurs coups commencent par le pion,
|
||||
// le coup évident sinon ;
|
||||
// - un coup joué au clavier (flèches, Entrée, Espace), raccourcis en kbd
|
||||
// affichés une fois le clavier détecté ;
|
||||
// - le filet intérieur des pions ;
|
||||
// - aucune erreur, aucune violation de la politique de sécurité.
|
||||
//
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
//
|
||||
// Usage : node plateau.mjs <adresse du site> <chromium|firefox> <dossier des captures>
|
||||
// Lancé par tests/browser.sh. Les dés viennent de Math.random, remplacé ici
|
||||
// par une file de valeurs choisies (window.__dice), puis par un générateur à
|
||||
// graine. Captures de la recette design (Chromium) dans
|
||||
// <dossier des captures>/plateau/, en 412×915 et 915×412.
|
||||
|
||||
import { mkdirSync } from "node:fs";
|
||||
import { chromium, firefox } from "playwright";
|
||||
|
||||
const [base, browserName, shots] = process.argv.slice(2);
|
||||
const browserType = { chromium, firefox }[browserName];
|
||||
if (!base || !browserType || !shots) {
|
||||
console.error("usage : node plateau.mjs <adresse> <chromium|firefox> <dossier des captures>");
|
||||
process.exit(2);
|
||||
}
|
||||
const full = browserName === "chromium";
|
||||
const dir = `${shots}/plateau`;
|
||||
mkdirSync(dir, { recursive: true });
|
||||
const PORTRAIT = { width: 412, height: 915 };
|
||||
const LANDSCAPE = { width: 915, height: 412 };
|
||||
// Positions de gnubg, vues du joueur : le départ ; un pion sur la 6 et un
|
||||
// sur la 1 (avec 6-1, seul le pion de la 1 a un coup évident).
|
||||
const START = "4HPwATDgc/ABMA";
|
||||
const SIX_AND_ONE = "BQAACAIAAAAAAA";
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
const browser = await browserType.launch();
|
||||
const context = await browser.newContext({
|
||||
viewport: PORTRAIT,
|
||||
deviceScaleFactor: 1,
|
||||
locale: "fr-FR",
|
||||
});
|
||||
await context.addInitScript(() => {
|
||||
window.__dice = [];
|
||||
let state = 7;
|
||||
Math.random = () => {
|
||||
const die = window.__dice.shift();
|
||||
if (die !== undefined) {
|
||||
return (die - 0.5) / 6;
|
||||
}
|
||||
state = (state * 1103515245 + 12345) % 2147483648;
|
||||
return state / 2147483648;
|
||||
};
|
||||
window.__csp = [];
|
||||
document.addEventListener("securitypolicyviolation", (e) =>
|
||||
window.__csp.push(`${e.effectiveDirective} ${e.blockedURI}`),
|
||||
);
|
||||
if (localStorage.getItem("ustagammon.settings") === null) {
|
||||
localStorage.setItem("ustagammon.settings", JSON.stringify({ tutorialSeen: 1 }));
|
||||
}
|
||||
});
|
||||
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()}`);
|
||||
}
|
||||
});
|
||||
|
||||
/** Attend une condition dans la page (sans page.waitForFunction, qui passe
|
||||
* par eval, interdit par la politique de sécurité du site). */
|
||||
async function until(what, fn, arg, ms = 60_000) {
|
||||
for (let waited = 0; waited < ms; waited += 100) {
|
||||
if (await page.evaluate(fn, arg).catch(() => false)) {
|
||||
return;
|
||||
}
|
||||
await sleep(100);
|
||||
}
|
||||
await page.screenshot({ path: `${dir}/${browserName}-bloque.png` }).catch(() => {});
|
||||
throw new Error(`attente trop longue : ${what}`);
|
||||
}
|
||||
|
||||
const idle = () => until("écran libre", () => !document.body.classList.contains("busy"));
|
||||
|
||||
/** Une nouvelle partie à partir d'une position, joueur au trait, puis les
|
||||
* dés imposés lancés. */
|
||||
async function startFrom(position, dice) {
|
||||
await page.goto(`${base}#/position/${encodeURIComponent(position)}`);
|
||||
await until("position lue", () => {
|
||||
const play = document.getElementById("position-play");
|
||||
return !play.hidden && !play.disabled;
|
||||
});
|
||||
await page.click("#position-play");
|
||||
await until(
|
||||
"partie commencée",
|
||||
() =>
|
||||
location.hash === "#/" &&
|
||||
!document.body.classList.contains("busy") &&
|
||||
!document.getElementById("roll").hidden,
|
||||
);
|
||||
await page.evaluate((d) => window.__dice.push(...d), dice);
|
||||
await page.click("#roll");
|
||||
await idle();
|
||||
}
|
||||
|
||||
/** L'état du plateau : pions par endroit (pion du dessus marqué), pion
|
||||
* choisi, cases d'arrivée, glissement en cours, dés joués. */
|
||||
function boardState() {
|
||||
return page.evaluate(() => {
|
||||
const board = document.getElementById("board");
|
||||
const at = (place) => board.querySelector(`[data-checker="${place}"]`) !== null;
|
||||
return {
|
||||
at: Object.fromEntries(
|
||||
["p1", "p5", "p6", "p7", "p8", "p13", "p24", "off-player"].map((p) => [p, at(p)]),
|
||||
),
|
||||
selected: board.querySelector(".checker.selected")?.closest("[data-checker]")
|
||||
?.getAttribute("data-checker") ?? null,
|
||||
landing: [...board.querySelectorAll(".hit.can-land")].map((e) =>
|
||||
e.getAttribute("data-place"),
|
||||
),
|
||||
dragging: board.querySelector("#drag-piece") !== null,
|
||||
ghost: board.querySelector(".drag-origin")?.getAttribute("data-checker") ?? null,
|
||||
used: board.querySelectorAll(".die-used").length,
|
||||
focus: board.querySelector(".key-focus") !== null,
|
||||
rings: board.querySelectorAll(".checker-ring").length,
|
||||
checkers: board.querySelectorAll("circle.checker").length,
|
||||
confirm: !document.getElementById("confirm").hidden,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** Le centre, à l'écran, de la zone à toucher d'un endroit. */
|
||||
async function center(place) {
|
||||
const box = await page.locator(`#board .hit[data-place="${place}"]`).boundingBox();
|
||||
check(box !== null, `zone ${place} introuvable`);
|
||||
return { x: box.x + box.width / 2, y: box.y + box.height / 2 };
|
||||
}
|
||||
|
||||
/** Le centre, à l'écran, du pion du dessus d'un endroit. */
|
||||
async function checkerCenter(place) {
|
||||
const box = await page.locator(`#board [data-checker="${place}"] circle.checker`).boundingBox();
|
||||
check(box !== null, `pion ${place} introuvable`);
|
||||
return { x: box.x + box.width / 2, y: box.y + box.height / 2 };
|
||||
}
|
||||
|
||||
/** Prend le pion de from et le fait glisser au-dessus de la zone de to,
|
||||
* sans le lâcher ; renvoie l'état du plateau pendant le glissement. */
|
||||
async function dragOver(from, to) {
|
||||
const start = await checkerCenter(from);
|
||||
const end = await center(to);
|
||||
await page.mouse.move(start.x, start.y);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(start.x + 4, start.y + 12, { steps: 3 });
|
||||
await page.mouse.move(end.x, end.y, { steps: 8 });
|
||||
await sleep(100);
|
||||
return boardState();
|
||||
}
|
||||
|
||||
/** Lâche le pion glissé, puis attend que le plateau soit de nouveau libre
|
||||
* et sans glissement (le pion rentré ou posé). */
|
||||
async function release() {
|
||||
await page.mouse.up();
|
||||
await until("glissement fini", () => document.getElementById("drag-piece") === null);
|
||||
await idle();
|
||||
}
|
||||
|
||||
/** Une capture de la recette, à la taille du moment (Chromium seulement). */
|
||||
async function capture(name) {
|
||||
if (!full) {
|
||||
return;
|
||||
}
|
||||
const size = page.viewportSize();
|
||||
await page.screenshot({ path: `${dir}/${name}-${size.width}x${size.height}.png` });
|
||||
captures++;
|
||||
}
|
||||
|
||||
// ------------------------------------------------- Le filet des pions
|
||||
|
||||
await startFrom(START, [3, 1]);
|
||||
let state = await boardState();
|
||||
check(state.checkers >= 28 && state.rings === state.checkers,
|
||||
`filet : ${state.rings} filets pour ${state.checkers} pions`);
|
||||
await until("bandeau parti", () => document.getElementById("banner").hidden, null, 15_000);
|
||||
for (const size of [PORTRAIT, LANDSCAPE]) {
|
||||
await page.setViewportSize(size);
|
||||
await sleep(300);
|
||||
await capture("plateau-filet");
|
||||
}
|
||||
await page.setViewportSize(PORTRAIT);
|
||||
await sleep(300);
|
||||
log(`filet intérieur sur chaque pion (${state.rings})`);
|
||||
|
||||
// ------------------------------------- Un coup entier au glisser-déposer
|
||||
|
||||
state = await dragOver("p8", "p5");
|
||||
check(state.dragging && state.ghost === "p8" && state.selected === "p8",
|
||||
`glissement de 8 : ${JSON.stringify(state)}`);
|
||||
check(state.landing.includes("p5") && state.landing.includes("p7"),
|
||||
`glissement de 8 : cases d'arrivée ${state.landing}`);
|
||||
await capture("glissement");
|
||||
await release();
|
||||
state = await boardState();
|
||||
check(state.used === 1 && state.at.p5 && !state.dragging && state.ghost === null,
|
||||
`8/5 lâché sur la 5 : ${JSON.stringify(state)}`);
|
||||
await dragOver("p6", "p5");
|
||||
await release();
|
||||
state = await boardState();
|
||||
check(state.used === 2 && state.confirm, `6/5 lâché sur la 5 : ${JSON.stringify(state)}`);
|
||||
await page.click("#confirm");
|
||||
await idle();
|
||||
log("un coup entier joué au glisser-déposer (8/5 6/5)");
|
||||
|
||||
// Le glissement en paysage, pour la recette.
|
||||
if (full) {
|
||||
await startFrom(START, [3, 1]);
|
||||
await until("bandeau parti", () => document.getElementById("banner").hidden, null, 15_000);
|
||||
await page.setViewportSize(LANDSCAPE);
|
||||
await sleep(300);
|
||||
state = await dragOver("p13", "p10");
|
||||
check(state.dragging && state.ghost === "p13", `paysage : ${JSON.stringify(state)}`);
|
||||
await capture("glissement");
|
||||
await release();
|
||||
await page.setViewportSize(PORTRAIT);
|
||||
await sleep(300);
|
||||
}
|
||||
|
||||
// ------------------------------------------- Lâché hors d'une case permise
|
||||
|
||||
await startFrom(START, [3, 1]);
|
||||
const before = await boardState();
|
||||
// La 12 est tenue par l'ordinateur : 13/12 n'est pas permis.
|
||||
state = await dragOver("p13", "p12");
|
||||
check(state.dragging && !state.landing.includes("p12"), `13 vers 12 : ${JSON.stringify(state)}`);
|
||||
await release();
|
||||
state = await boardState();
|
||||
check(state.used === 0 && state.at.p13 && state.selected === null && !state.dragging &&
|
||||
state.checkers === before.checkers,
|
||||
`lâché sur la 12 : le pion n'est pas revenu ${JSON.stringify(state)}`);
|
||||
log("lâché hors d'une case permise : le pion revient à sa place, rien n'est joué");
|
||||
|
||||
// ----------------------------------------------------------- Double toucher
|
||||
|
||||
await startFrom(SIX_AND_ONE, [6, 1]);
|
||||
// Depuis la 6 : 6/off 1/off ou 6/5/off, deux coups : rien.
|
||||
await page.dblclick('#board .hit[data-place="p6"]');
|
||||
await sleep(500);
|
||||
await idle();
|
||||
state = await boardState();
|
||||
check(state.used === 0 && state.at.p6 && state.at.p1, `double toucher sur la 6 : ${JSON.stringify(state)}`);
|
||||
// Depuis la 1 : 1/off puis 6/off, le seul coup.
|
||||
await page.dblclick('#board .hit[data-place="p1"]');
|
||||
await until("coup évident joué", () => !document.getElementById("confirm").hidden);
|
||||
await idle();
|
||||
state = await boardState();
|
||||
check(state.used === 2 && !state.at.p6 && !state.at.p1 && state.at["off-player"],
|
||||
`double toucher sur la 1 : ${JSON.stringify(state)}`);
|
||||
log("double toucher : rien sur la 6 (deux coups), le coup évident sur la 1");
|
||||
|
||||
// ------------------------------------------------------------------ Clavier
|
||||
|
||||
await startFrom(START, [3, 1]);
|
||||
await page.locator("body").focus().catch(() => {});
|
||||
// Les pions jouables dans le sens de la course : 24, 13, 8, 6.
|
||||
for (let i = 0; i < 3; i++) {
|
||||
await page.keyboard.press("ArrowRight");
|
||||
}
|
||||
state = await boardState();
|
||||
check(state.selected === "p8" && state.focus, `flèches : pion ${state.selected}`);
|
||||
// Les cases d'arrivée de la 8 : la 7, puis la 5.
|
||||
await page.keyboard.press("ArrowDown");
|
||||
await page.keyboard.press("Enter");
|
||||
await idle();
|
||||
state = await boardState();
|
||||
check(state.used === 1 && state.at.p5, `Entrée : 8/5 non joué ${JSON.stringify(state)}`);
|
||||
// En arrière depuis la fin : 5, puis 6 ; sa seule case, la 5.
|
||||
await page.keyboard.press("ArrowLeft");
|
||||
await page.keyboard.press("ArrowLeft");
|
||||
state = await boardState();
|
||||
check(state.selected === "p6" && state.focus, `flèche gauche : pion ${state.selected}`);
|
||||
await page.keyboard.press("Enter");
|
||||
await idle();
|
||||
state = await boardState();
|
||||
check(state.used === 2 && state.confirm, `Entrée : 6/5 non joué ${JSON.stringify(state)}`);
|
||||
const keys = await page.evaluate(() => {
|
||||
const kbd = document.querySelector("#confirm kbd.shortcut");
|
||||
return {
|
||||
keyboard: document.documentElement.classList.contains("keyboard"),
|
||||
shown: kbd !== null && kbd.getBoundingClientRect().width > 0,
|
||||
text: kbd?.textContent ?? null,
|
||||
};
|
||||
});
|
||||
check(keys.keyboard && keys.shown && keys.text === "Espace", `raccourci : ${JSON.stringify(keys)}`);
|
||||
await capture("clavier");
|
||||
await page.keyboard.press(" ");
|
||||
await until("coup validé", () => document.getElementById("confirm").hidden);
|
||||
await idle();
|
||||
log("un coup joué au clavier (flèches, Entrée, Espace), raccourcis affichés");
|
||||
|
||||
const csp = await page.evaluate(() => window.__csp);
|
||||
for (const violation of csp) {
|
||||
problems.push(`politique de sécurité : ${violation}`);
|
||||
}
|
||||
await browser.close();
|
||||
if (problems.length > 0) {
|
||||
for (const problem of problems) {
|
||||
console.error(`[${browserName}] ${problem}`);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
log(`plateau soigné : tout est conforme (${captures} captures)`);
|
||||
+13
-4
@@ -353,7 +353,8 @@ async function checkStable(when) {
|
||||
}
|
||||
|
||||
/** Les boutons visibles d'une page dont le texte passe à la ligne : un
|
||||
* bouton tient sur une ligne et fait 48 px de haut (charte). Un bouton plus
|
||||
* bouton tient sur une ligne et fait 48 px de haut (charte), touche de son
|
||||
* raccourci comprise. Un bouton plus
|
||||
* haut que 48 px n'est refusé que si son texte occupe plusieurs lignes ;
|
||||
* un texte coupé par le bord (white-space: nowrap) est refusé aussi. */
|
||||
async function wrappedButtons(target) {
|
||||
@@ -368,7 +369,9 @@ async function wrappedButtons(target) {
|
||||
const tops = new Set();
|
||||
const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT);
|
||||
for (let node = walker.nextNode(); node !== null; node = walker.nextNode()) {
|
||||
if (node.textContent.trim() === "") {
|
||||
// La touche du raccourci (kbd, quand un clavier est détecté)
|
||||
// est un badge en coin, pas une ligne du libellé.
|
||||
if (node.textContent.trim() === "" || node.parentElement?.closest("kbd.shortcut")) {
|
||||
continue;
|
||||
}
|
||||
const range = document.createRange();
|
||||
@@ -1193,6 +1196,7 @@ let resumed = false;
|
||||
let landscapeTurn = false;
|
||||
let rotatedChecked = false;
|
||||
let vibrationsChecked = false;
|
||||
let lastTouch = 0;
|
||||
let tracesSeen = 0;
|
||||
let replays = 0;
|
||||
let skips = 0;
|
||||
@@ -1420,7 +1424,7 @@ for (let actions = 0; ; actions++) {
|
||||
const confirm = document.getElementById("confirm");
|
||||
const cancel = document.getElementById("cancel");
|
||||
return {
|
||||
text: confirm.textContent,
|
||||
text: confirm.firstChild.textContent,
|
||||
primary: !confirm.classList.contains("secondary"),
|
||||
right: confirm.getBoundingClientRect().x > cancel.getBoundingClientRect().x,
|
||||
inBar: confirm.closest("#actions .slot.primary") !== null,
|
||||
@@ -1496,9 +1500,14 @@ for (let actions = 0; ; actions++) {
|
||||
check(!(await page.isVisible("#menu")), "Échap ne ferme pas le menu");
|
||||
}
|
||||
const vibrationsBefore = (await vibrations()).length;
|
||||
// Deux touchers rapprochés sur la même pile seraient un double
|
||||
// toucher (coup évident, tests/browser/plateau.mjs) : ici, un
|
||||
// toucher à la fois, un demi-coup à la fois.
|
||||
await sleep(Math.max(0, lastTouch + 500 - Date.now()));
|
||||
// Un pion à destination unique avance dès qu'on le touche ; sinon,
|
||||
// ses cases d'arrivée s'allument. Le premier toucher est mesuré :
|
||||
// l'écran répond dans le gestionnaire du toucher.
|
||||
lastTouch = Date.now();
|
||||
const ms = await page.evaluate(() => {
|
||||
const hit = document.querySelector(".hit.can-move");
|
||||
const t0 = performance.now();
|
||||
@@ -1673,7 +1682,7 @@ await shot("4-fin-de-partie");
|
||||
// reste dans la barre d'actions.
|
||||
await page.click("#summary-close");
|
||||
await page.waitForSelector("#summary-veil", { state: "hidden" });
|
||||
check(!(await page.isVisible("#panel")) && (await page.textContent("#again")) === "Rejouer" &&
|
||||
check(!(await page.isVisible("#panel")) && (await page.textContent("#again > span")) === "Rejouer" &&
|
||||
(await page.isVisible("#again")),
|
||||
"bilan fermé : panneau toujours là, ou pas de « Rejouer »");
|
||||
|
||||
|
||||
@@ -99,27 +99,6 @@ export async function hiddenUnderActionBar(target) {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* La barre haute (Aide, Plein écran, Menu) hors de l'écran de jeu : sur une
|
||||
* page (accueil, leçons, aide…) qui n'a pas son propre en-tête, elle doit
|
||||
* rester visible, en paysage comme en portrait ; sans elle, le menu est
|
||||
* inaccessible (recette 1g). Renvoie un problème décrit, ou null.
|
||||
*/
|
||||
export async function missingTopbar(target) {
|
||||
return target.evaluate(() => {
|
||||
const root = document.documentElement;
|
||||
if (!root.classList.contains("page-open") || root.classList.contains("bare")) {
|
||||
return null;
|
||||
}
|
||||
const bar = document.querySelector(".topbar");
|
||||
const r = bar?.getBoundingClientRect();
|
||||
const shown = bar !== null && getComputedStyle(bar).display !== "none" && r.height > 0;
|
||||
return shown
|
||||
? null
|
||||
: `barre haute absente sur ${root.dataset.screen} (${innerWidth}x${innerHeight})`;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Les largeurs d'un écran du tutoriel, en px CSS : le plateau, la colonne
|
||||
* de texte, et la largeur de la page (scrollWidth) face à celle de l'écran.
|
||||
|
||||
@@ -87,12 +87,6 @@ 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"
|
||||
|
||||
+91
-258
@@ -412,10 +412,42 @@ button:disabled {
|
||||
}
|
||||
|
||||
.icon-button.labelled {
|
||||
position: relative;
|
||||
width: 60px;
|
||||
height: 52px;
|
||||
}
|
||||
|
||||
/* Raccourci clavier d'un bouton, en petite touche (interaction.md) :
|
||||
* seulement quand un clavier est détecté (classe keyboard, app.ts). */
|
||||
.shortcut {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.keyboard .shortcut {
|
||||
display: inline-block;
|
||||
min-width: 1.6em;
|
||||
padding: 0 4px;
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
line-height: 18px;
|
||||
text-align: center;
|
||||
border: 1px solid currentColor;
|
||||
border-radius: 4px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
/* Barre haute : la touche en coin, sans changer la taille du bouton. */
|
||||
.keyboard .icon-button.labelled .shortcut {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 2px;
|
||||
min-width: 0;
|
||||
padding: 0 3px;
|
||||
line-height: 15px;
|
||||
background: var(--background);
|
||||
}
|
||||
|
||||
.icon-button .label {
|
||||
font-size: 13px;
|
||||
line-height: 15px;
|
||||
@@ -966,6 +998,13 @@ a.notation {
|
||||
font-variant-numeric: tabular-nums lining-nums;
|
||||
}
|
||||
|
||||
/* Le plateau de la partie : le doigt y fait glisser les pions, il ne fait
|
||||
* ni défiler ni zoomer la page (glisser-déposer, double toucher ; charte,
|
||||
* 1i). Les autres plateaux (accueil, tutoriel, position) laissent défiler. */
|
||||
#board {
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.board-frame { fill: var(--frame); }
|
||||
.board-bed { fill: var(--bed); }
|
||||
.board-bar { fill: var(--bar); }
|
||||
@@ -996,8 +1035,8 @@ a.notation {
|
||||
stroke: var(--checker-computer-edge);
|
||||
}
|
||||
|
||||
/* Filet intérieur des pions (bundle.css), prêt pour le jour où le plateau
|
||||
* le dessine. */
|
||||
/* Filet intérieur des pions (bundle.css), l'écho du logo : un cercle aux
|
||||
* trois cinquièmes du rayon, dessiné avec chaque pion (board-view.ts). */
|
||||
.checker-ring {
|
||||
fill: none;
|
||||
stroke-width: 1.5;
|
||||
@@ -1016,6 +1055,34 @@ a.notation {
|
||||
stroke-width: 6;
|
||||
}
|
||||
|
||||
/* Glisser-déposer (charte, 1i) : le pion parti laisse un fantôme en
|
||||
* pointillés ; le pion glissé, agrandi, passe devant tout. */
|
||||
.drag-origin .checker {
|
||||
fill: none;
|
||||
stroke: var(--highlight);
|
||||
stroke-width: 2.5;
|
||||
stroke-dasharray: 5 4;
|
||||
}
|
||||
|
||||
.drag-origin .checker-ring,
|
||||
.drag-origin .checker-count {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.drag-piece { pointer-events: none; }
|
||||
|
||||
.checker.dragged {
|
||||
stroke: var(--highlight);
|
||||
stroke-width: 4;
|
||||
}
|
||||
|
||||
/* Clavier : le pion ou la case d'arrivée choisis aux flèches. */
|
||||
.key-focus {
|
||||
fill: none;
|
||||
stroke: var(--focus);
|
||||
stroke-width: 3;
|
||||
}
|
||||
|
||||
/* Tailles des chiffres du plateau : attribut font-size, calculé par
|
||||
* geometry.ts (13 px au moins à l'écran). */
|
||||
.checker-count {
|
||||
@@ -1201,13 +1268,6 @@ html.landscape.page-open body {
|
||||
background: var(--background);
|
||||
}
|
||||
|
||||
/* Hors de l'écran de jeu, la barre haute (Aide, Plein écran, Menu) reste
|
||||
* en paysage : seul le plateau en paysage s'en passe. Les écrans qui ont
|
||||
* leur propre en-tête (bare) la masquent toujours. */
|
||||
html.landscape.page-open:not(.bare) .topbar {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
/* Repli du plein écran : le paysage ne peut pas être verrouillé, l'écran
|
||||
* entier tourne d'un quart de tour dans le portrait. Les touchers suivent
|
||||
* la rotation ; le plateau et les fenêtres sont posés dans #screen. */
|
||||
@@ -1856,194 +1916,6 @@ 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;
|
||||
width: fit-content;
|
||||
min-height: 44px;
|
||||
margin-top: var(--space-2);
|
||||
font-size: 15px;
|
||||
line-height: 20px;
|
||||
color: var(--muted);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.lessons-rules span {
|
||||
margin-left: var(--space-1);
|
||||
font-weight: 700;
|
||||
color: var(--accent);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
#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;
|
||||
@@ -2104,8 +1976,7 @@ html.bare .topbar {
|
||||
.tutorial-progress {
|
||||
display: grid;
|
||||
flex: 1;
|
||||
grid-auto-columns: minmax(0, 1fr);
|
||||
grid-auto-flow: column;
|
||||
grid-template-columns: repeat(8, minmax(0, 1fr));
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
@@ -2146,28 +2017,8 @@ html.bare .topbar {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
/* En portrait, l'écran tient au moins la hauteur de l'écran : « Retour »
|
||||
* et « Continuer » restent en bas, sous le pouce. */
|
||||
.tutorial {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-sizing: border-box;
|
||||
min-height: 100vh;
|
||||
min-height: 100dvh;
|
||||
}
|
||||
|
||||
/* Le titre, et « Revoir » sur sa ligne, à droite. */
|
||||
.tutorial-title-row {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
align-items: center;
|
||||
margin: var(--space-1) 0 var(--space-3);
|
||||
}
|
||||
|
||||
.tutorial-title {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
margin: var(--space-1) 0 var(--space-3);
|
||||
font-size: 24px;
|
||||
line-height: 30px;
|
||||
}
|
||||
@@ -2182,13 +2033,16 @@ html.bare .topbar {
|
||||
}
|
||||
}
|
||||
|
||||
.tutorial-replay {
|
||||
flex: none;
|
||||
#tutorial-replay {
|
||||
position: absolute;
|
||||
right: var(--space-1);
|
||||
bottom: calc(-44px - var(--space-1));
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.tutorial-text {
|
||||
margin-top: var(--space-3);
|
||||
padding-right: 52px;
|
||||
}
|
||||
|
||||
.tutorial-text p {
|
||||
@@ -2241,8 +2095,7 @@ html.bare .topbar {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1.4fr;
|
||||
gap: var(--space-2);
|
||||
margin-top: auto;
|
||||
padding-top: var(--space-6);
|
||||
margin-top: var(--space-6);
|
||||
}
|
||||
|
||||
.tutorial-nav .button {
|
||||
@@ -2309,46 +2162,16 @@ html.landscape .tutorial-head {
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
html.landscape .tutorial-title-row {
|
||||
html.landscape .tutorial-title {
|
||||
grid-row: 2;
|
||||
margin: 0 0 var(--space-1);
|
||||
padding-right: calc(var(--space-2) + env(safe-area-inset-right));
|
||||
}
|
||||
|
||||
html.landscape .tutorial-title {
|
||||
padding-right: calc(44px + var(--space-4));
|
||||
font-size: 20px;
|
||||
line-height: 26px;
|
||||
}
|
||||
|
||||
/* Une leçon garde en paysage sa ligne « Leçon 1 · 1 sur 4 · à vous »
|
||||
* au-dessus du titre (recette 1g) : une rangée de plus dans la grille. */
|
||||
html.landscape #lesson {
|
||||
grid-template-rows: auto auto auto minmax(0, 1fr) auto auto;
|
||||
}
|
||||
|
||||
html.landscape #lesson .overline {
|
||||
display: block;
|
||||
grid-row: 2;
|
||||
}
|
||||
|
||||
html.landscape #lesson .tutorial-title-row {
|
||||
grid-row: 3;
|
||||
}
|
||||
|
||||
html.landscape #lesson .tutorial-body {
|
||||
grid-row: 4;
|
||||
}
|
||||
|
||||
html.landscape #lesson .tutorial-cube {
|
||||
grid-row: 5;
|
||||
}
|
||||
|
||||
html.landscape #lesson .tutorial-nav {
|
||||
grid-row: 6;
|
||||
}
|
||||
|
||||
/* La boîte du plateau s'efface : le plateau se place lui-même dans la
|
||||
* grille. */
|
||||
/* La boîte du plateau s'efface : le plateau et « Revoir » se placent
|
||||
* eux-mêmes dans la grille. */
|
||||
html.landscape .tutorial-board-box {
|
||||
display: contents;
|
||||
}
|
||||
@@ -2363,6 +2186,16 @@ html.landscape .tutorial-board {
|
||||
background: var(--frame);
|
||||
}
|
||||
|
||||
html.landscape #tutorial-replay {
|
||||
position: static;
|
||||
grid-row: 2;
|
||||
grid-column: 2;
|
||||
justify-self: end;
|
||||
align-self: start;
|
||||
margin: -9px calc(var(--space-2) + env(safe-area-inset-right)) 0 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
html.landscape .tutorial-body {
|
||||
grid-row: 3;
|
||||
min-height: 0;
|
||||
|
||||
@@ -96,11 +96,6 @@
|
||||
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. -->
|
||||
@@ -151,7 +146,6 @@
|
||||
<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>
|
||||
|
||||
Reference in New Issue
Block a user