From 3f5b00133dd1fd8ac429a367d9804d869da62aea Mon Sep 17 00:00:00 2001 From: Pierre MARTIN Date: Mon, 9 Nov 2026 14:55:32 +0100 Subject: [PATCH] =?UTF-8?q?Tutoriel=20:=20texte=20source,=20lecture,=20v?= =?UTF-8?q?=C3=A9rification=20par=20le=20moteur,=20adresses?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- app/src/domain/learning/tutorial.ts | 243 ++++++++++++++++++++++++++++ app/src/ui/router.ts | 49 ++++-- app/src/ui/tutorial-text.ts | 200 +++++++++++++++++++++++ app/tests/router.test.ts | 11 +- app/tests/tutorial.test.ts | 179 ++++++++++++++++++++ docs/glossaire/README.md | 1 + docs/glossaire/camp.md | 12 ++ docs/tutoriel/README.md | 65 ++++++++ docs/tutoriel/regles.md | 130 +++++++++++++++ 9 files changed, 877 insertions(+), 13 deletions(-) create mode 100644 app/src/domain/learning/tutorial.ts create mode 100644 app/src/ui/tutorial-text.ts create mode 100644 app/tests/tutorial.test.ts create mode 100644 docs/glossaire/camp.md create mode 100644 docs/tutoriel/README.md create mode 100644 docs/tutoriel/regles.md diff --git a/app/src/domain/learning/tutorial.ts b/app/src/domain/learning/tutorial.ts new file mode 100644 index 0000000..2a4b592 --- /dev/null +++ b/app/src/domain/learning/tutorial.ts @@ -0,0 +1,243 @@ +// Le tutoriel des règles, côté domaine : les positions illustrées et les +// écrans « à vous », vérifiés par le moteur. +// +// Copyright (C) 2026 les auteurs d'Ustagammon. +// Licence : GPLv3 ou ultérieure (voir LICENSE). +// +// Le texte du tutoriel vit dans docs/tutoriel/ (ADR 0008 : une seule +// source) ; il donne pour chaque écran une position par son identifiant +// gnubg (ADR 0013), un jet, et les coups attendus en notation de gnubg. +// Rien n'est jugé ici : le moteur dit quels coups sont légaux, où mène +// chaque demi-coup, et s'il faut prendre un double. Le domaine ne fait que +// comparer ce qu'a joué le joueur aux coups attendus que le moteur a +// reconnus. + +import { CENTERED_CUBE, type Cube } from "../game/cube.js"; +import type { BoardResult, Engine, EngineCube, Move } from "../game/engine.js"; +import { Game, type MoveSteps, type SideBoards, STANDARD_PLY } from "../game/game.js"; +import type { HalfMove, PositionId } from "../game/position.js"; +import { fixedDice, type Roll } from "../game/roll.js"; +import type { Side } from "../game/side.js"; + +/** Un plateau du tutoriel, décodé par le moteur, et le résultat si la + * partie y est finie (vu du camp « player »). */ +export interface TutorialBoard { + readonly sides: SideBoards; + readonly result: { readonly winner: Side; readonly kind: BoardResult["kind"] } | null; +} + +/** Le videau vu du camp au trait. */ +function engineCube(cube: Cube, onRoll: Side): EngineCube { + return { + value: cube.value, + owner: cube.owner === null ? "centered" : cube.owner === onRoll ? "onRoll" : "opponent", + }; +} + +/** Le plateau d'une position vue de onRoll, décodé par le moteur (erreur si + * l'identifiant est illisible). */ +export async function tutorialBoard( + engine: Engine, + position: PositionId, + onRoll: Side = "player", + cube: Cube = CENTERED_CUBE, +): Promise { + const board = await engine.board(position, engineCube(cube, onRoll)); + const sides = + onRoll === "player" + ? { player: board.onRoll, computer: board.opponent } + : { player: board.opponent, computer: board.onRoll }; + const result = + board.result === null + ? null + : { + winner: (board.result.winner === "onRoll") === (onRoll === "player") + ? ("player" as const) + : ("computer" as const), + kind: board.result.kind, + }; + return { sides, result }; +} + +/** Les coups du jet que le moteur déclare légaux et dont la notation est + * l'une des notations données. Erreur si l'une d'elles n'est pas un coup + * légal : le texte du tutoriel ne correspond plus au moteur. */ +export async function expectedMoves( + engine: Engine, + position: PositionId, + roll: Roll, + notations: readonly string[], +): Promise { + const legal = await engine.legalMoves(position, roll); + return notations.map((notation) => { + const move = legal.find((m) => m.notation === notation); + if (move === undefined) { + throw new Error( + `${notation} n'est pas un coup légal de ${roll.join("-")} dans ${position}`, + ); + } + return move; + }); +} + +/** Le coup illustré d'un écran d'explication, demi-coup par demi-coup : + * les plateaux successifs, donnés par le moteur, et le résultat si le coup + * finit la partie. */ +export async function illustratedMove( + engine: Engine, + position: PositionId, + roll: Roll, + notation: string, +): Promise<{ steps: MoveSteps; after: TutorialBoard }> { + const [move] = await expectedMoves(engine, position, roll, [notation]); + if (move === undefined) { + throw new Error(`coup illustré introuvable : ${notation}`); + } + const positions = [position]; + for (let i = 1; i <= move.halfMoves.length; i++) { + const check = await engine.checkHalfMoves(position, roll, move.halfMoves.slice(0, i)); + if (check.position === null) { + throw new Error(`demi-coup refusé par le moteur : ${notation}`); + } + positions.push(check.position); + } + const boards = await Promise.all(positions.map((p) => tutorialBoard(engine, p))); + const hits = move.halfMoves.map( + (_, i) => (boards[i + 1]?.sides.computer.bar ?? 0) > (boards[i]?.sides.computer.bar ?? 0), + ); + const after = boards.at(-1) ?? boards[0]; + if (after === undefined) { + throw new Error("aucun plateau"); + } + return { + steps: { + side: "player", + halfMoves: move.halfMoves, + boards: boards.map((b) => b.sides), + hits, + }, + after, + }; +} + +/** + * Les demi-coups saisis peuvent-ils encore mener à l'un des coups attendus ? + * Oui s'ils font tous partie (comme multiensemble) des demi-coups de l'un + * d'eux, tels que le moteur les donne. Un coup complet est jugé sur sa + * position (verdict de MovePractice). + */ +export function canStillLeadTo( + played: readonly HalfMove[], + expected: readonly Move[], +): boolean { + return expected.some((move) => { + const left = [...move.halfMoves]; + return played.every((h) => { + const i = left.findIndex((m) => m.from === h.from && m.to === h.to); + if (i < 0) { + return false; + } + left.splice(i, 1); + return true; + }); + }); +} + +/** Ce que devient un écran « à vous » après un demi-coup. */ +export type PracticeVerdict = "continue" | "right" | "wrong"; + +/** + * Un écran « à vous » où le joueur joue un coup : une partie d'un seul jet, + * commencée à la position de l'écran, jamais enregistrée. Le joueur saisit + * ses demi-coups comme en partie (le moteur dit lesquels sont permis) ; le + * coup est juste s'il mène à la position de l'un des coups attendus. + */ +export class MovePractice { + readonly #game: Game; + readonly #expected: readonly Move[]; + readonly roll: Roll; + + private constructor(game: Game, expected: readonly Move[], roll: Roll) { + this.#game = game; + this.#expected = expected; + this.roll = roll; + } + + /** Prépare l'écran : la position, le jet, et les coups attendus, + * reconnus par le moteur (erreur sinon). */ + static async start( + engine: Engine, + position: PositionId, + roll: Roll, + notations: readonly string[], + ): Promise { + const expected = await expectedMoves(engine, position, roll, notations); + const game = Game.start(engine, { + setup: { position, firstTurn: "player" }, + dice: fixedDice(roll), + ply: 0, + }); + await game.roll(); + return new MovePractice(game, expected, roll); + } + + /** Le plateau affiché, demi-coups saisis compris. */ + board(): Promise { + return this.#game.displayedBoard(); + } + + /** Les demi-coups permis maintenant (le moteur le dit). */ + nextHalfMoves(): Promise { + return this.#game.nextHalfMoves(); + } + + get pendingHalfMoves(): readonly HalfMove[] { + return this.#game.pendingHalfMoves; + } + + /** Les dés du jet, et ceux déjà utilisés. */ + get diceToPlay(): { value: number; used: boolean }[] { + return this.#game.diceToPlay; + } + + /** + * Joue un demi-coup. « wrong » si le moteur le refuse, ou s'il ne peut + * plus mener à un coup attendu, ou si le coup complet n'est pas l'un + * d'eux : le demi-coup reste affiché, à l'appelant de revenir en + * arrière (reset). « right » quand le coup complet est l'un des coups + * attendus. + */ + async play(halfMove: HalfMove): Promise { + if (!(await this.#game.playHalfMove(halfMove))) { + return "wrong"; + } + if (this.#game.moveComplete) { + const position = this.#game.displayedPosition; + return this.#expected.some((m) => m.position === position) ? "right" : "wrong"; + } + return canStillLeadTo(this.#game.pendingHalfMoves, this.#expected) ? "continue" : "wrong"; + } + + /** Le pion revient : tous les demi-coups saisis sont oubliés. */ + reset(): void { + this.#game.cancelHalfMoves(); + } +} + +/** + * Un écran « à vous » de videau : l'ordinateur, au trait dans position (vue + * de lui), double. Le moteur dit, au niveau standard de l'ordinateur, si le + * joueur doit prendre (vrai) ou passer (faux). Erreur si l'ordinateur ne + * peut pas doubler dans cette position. + */ +export async function shouldTake(engine: Engine, position: PositionId): Promise { + const decision = await engine.cubeDecision( + position, + { value: 1, owner: "centered" }, + STANDARD_PLY, + ); + if (!decision.available) { + throw new Error(`l'ordinateur ne peut pas doubler dans ${position}`); + } + return decision.take; +} diff --git a/app/src/ui/router.ts b/app/src/ui/router.ts index 1ddd254..c8a1556 100644 --- a/app/src/ui/router.ts +++ b/app/src/ui/router.ts @@ -10,7 +10,7 @@ export type Route = /** #/ : la partie en cours. */ | { readonly screen: "game" } - /** #/accueil (écran à venir, étape 0e.5). */ + /** #/accueil. */ | { readonly screen: "home" } /** #/aide, et une question ouverte : #/aide/. */ | { readonly screen: "help"; readonly question: string | null } @@ -18,8 +18,13 @@ export type Route = | { readonly screen: "glossary"; readonly term: string | null } /** #/a-propos. */ | { readonly screen: "about" } - /** #/tutoriel, et une étape : #/tutoriel/ (écran à venir). */ - | { readonly screen: "tutorial"; readonly step: number | null } + /** #/tutoriel, une étape : #/tutoriel/, et son écran « à vous » : + * #/tutoriel//a-vous. */ + | { readonly screen: "tutorial"; readonly step: number | null; readonly practice: boolean } + /** #/tutoriel/fin : la fin du tutoriel. */ + | { readonly screen: "tutorialEnd" } + /** #/installer : la marche à suivre pour installer l'application. */ + | { readonly screen: "install" } /** #/parties, et une partie : #/parties/. */ | { readonly screen: "games"; readonly game: string | null } /** #/menu. */ @@ -58,6 +63,9 @@ export function parseRoute(hash: string): Route { if (head === "position") { return positionRoute(rest.join("/")) ?? unknown; } + if (head === "tutoriel") { + return tutorialRoute(rest) ?? unknown; + } if (rest.length > 1) { return unknown; } @@ -80,18 +88,31 @@ export function parseRoute(hash: string): Route { return name === null ? { screen: "menu" } : unknown; case "parties": return { screen: "games", game: name }; - case "tutoriel": { - if (name === null) { - return { screen: "tutorial", step: null }; - } - const step = Number(name); - return /^[1-9][0-9]?$/.test(name) ? { screen: "tutorial", step } : unknown; - } + case "installer": + return name === null ? { screen: "install" } : unknown; default: return unknown; } } +/** #/tutoriel, #/tutoriel/, #/tutoriel//a-vous, #/tutoriel/fin. */ +function tutorialRoute(rest: readonly string[]): Route | null { + const [name, part, ...more] = rest; + if (name === undefined) { + return { screen: "tutorial", step: null, practice: false }; + } + if (more.length > 0) { + return null; + } + if (name === "fin") { + return part === undefined ? { screen: "tutorialEnd" } : null; + } + if (!/^[1-9][0-9]?$/.test(name) || (part !== undefined && part !== "a-vous")) { + return null; + } + return { screen: "tutorial", step: Number(name), practice: part === "a-vous" }; +} + /** * #/position/... : le Position ID (14 caractères), puis, après une barre, le * Match ID. Chaque identifiant est encodé avec encodeURIComponent, mais une @@ -134,7 +155,13 @@ export function routeHash(route: Route): string { case "about": return "#/a-propos"; case "tutorial": - return route.step === null ? "#/tutoriel" : `#/tutoriel/${route.step}`; + return route.step === null + ? "#/tutoriel" + : `#/tutoriel/${route.step}${route.practice ? "/a-vous" : ""}`; + case "tutorialEnd": + return "#/tutoriel/fin"; + case "install": + return "#/installer"; case "games": return route.game === null ? "#/parties" : `#/parties/${route.game}`; case "menu": diff --git a/app/src/ui/tutorial-text.ts b/app/src/ui/tutorial-text.ts new file mode 100644 index 0000000..a781da9 --- /dev/null +++ b/app/src/ui/tutorial-text.ts @@ -0,0 +1,200 @@ +// Lecture du texte du tutoriel (docs/tutoriel/regles.md, format décrit dans +// docs/tutoriel/README.md), copié tel quel dans le site par make site +// (ADR 0008 : une seule source). Fonction pure, testée sous Node. +// +// Copyright (C) 2026 les auteurs d'Ustagammon. +// Licence : GPLv3 ou ultérieure (voir LICENSE). + +/** Un morceau de texte : simple, en gras, ou lien vers une fiche du + * glossaire (nom de fichier sans .md). */ +export interface Inline { + readonly text: string; + readonly strong?: boolean; + readonly term?: string; +} + +export type Paragraph = readonly Inline[]; + +/** Un écran du tutoriel : explication, ou « à vous ». */ +export interface TutorialScreen { + /** Identifiant de position de gnubg, vu du camp au trait. */ + readonly position: string; + readonly onRoll: "player" | "computer"; + /** Le jet du coup illustré (explication) ou à jouer (« à vous »). */ + readonly roll: readonly [number, number] | null; + /** Explication : le coup illustré, en notation de gnubg. */ + readonly move: string | null; + /** Explication : le sens du jeu dessiné en pointillés. */ + readonly path: boolean; + /** « À vous » : les coups justes, en notation de gnubg. */ + readonly expected: readonly string[]; + /** « À vous » de videau : la bonne réponse au double. */ + readonly answer: "take" | "pass" | null; + readonly right: Paragraph; + readonly wrong: Paragraph; + readonly paragraphs: readonly Paragraph[]; +} + +export interface TutorialStep { + readonly number: number; + readonly title: string; + readonly explain: TutorialScreen; + readonly practice: TutorialScreen | null; +} + +export interface Tutorial { + readonly steps: readonly TutorialStep[]; + readonly end: { readonly action: string; readonly paragraphs: readonly Paragraph[] }; +} + +/** Lit les liens [mot](fiche.md) et le **gras** d'une ligne de texte. */ +export function parseInline(text: string): Paragraph { + const parts: Inline[] = []; + const pattern = /\[([^\]]+)\]\(([a-z0-9-]+)\.md\)|\*\*([^*]+)\*\*/g; + let last = 0; + for (const match of text.matchAll(pattern)) { + const index = match.index ?? 0; + if (index > last) { + parts.push({ text: text.slice(last, index) }); + } + if (match[1] !== undefined && match[2] !== undefined) { + parts.push({ text: match[1], term: match[2] }); + } else { + parts.push({ text: match[3] ?? "", strong: true }); + } + last = index + match[0].length; + } + if (last < text.length) { + parts.push({ text: text.slice(last) }); + } + return parts; +} + +interface RawScreen { + keys: Map; + paragraphs: string[]; +} + +function emptyScreen(): RawScreen { + return { keys: new Map(), paragraphs: [] }; +} + +function rollOf(text: string | undefined, where: string): readonly [number, number] | null { + if (text === undefined) { + return null; + } + const match = /^([1-6])-([1-6])$/.exec(text); + if (match === null) { + throw new Error(`${where} : jet illisible « ${text} »`); + } + return [Number(match[1]), Number(match[2])]; +} + +function screenOf(raw: RawScreen, where: string): TutorialScreen { + const one = (key: string) => raw.keys.get(key)?.[0]; + const position = one("position"); + if (position === undefined) { + throw new Error(`${where} : position absente`); + } + const trait = one("trait"); + if (trait !== undefined && trait !== "ordinateur" && trait !== "joueur") { + throw new Error(`${where} : trait inconnu « ${trait} »`); + } + const answer = one("réponse"); + if (answer !== undefined && answer !== "prendre" && answer !== "passer") { + throw new Error(`${where} : réponse inconnue « ${answer} »`); + } + return { + position, + onRoll: trait === "ordinateur" ? "computer" : "player", + roll: rollOf(one("jet"), where), + move: one("coup") ?? null, + path: one("chemin") === "oui", + expected: raw.keys.get("attendu") ?? [], + answer: answer === undefined ? null : answer === "prendre" ? "take" : "pass", + right: parseInline(one("juste") ?? ""), + wrong: parseInline(one("refus") ?? ""), + paragraphs: raw.paragraphs.map(parseInline), + }; +} + +/** Lit le texte du tutoriel. Erreur si un écran est incomplet ou si les + * étapes ne se suivent pas. */ +export function parseTutorial(text: string): Tutorial { + const steps: { number: number; title: string; explain: RawScreen; practice: RawScreen | null }[] = + []; + let end: RawScreen | null = null; + let current: RawScreen | null = null; + let paragraph: string[] = []; + + const endParagraph = () => { + if (paragraph.length > 0 && current !== null) { + current.paragraphs.push(paragraph.join(" ")); + } + paragraph = []; + }; + + for (const raw of text.split("\n")) { + const line = raw.trim(); + const step = /^## (\d+)\. (.+)$/.exec(line); + const key = /^- ([a-zéè]+): (.+)$/.exec(line); + if (line === "") { + endParagraph(); + } else if (step !== null) { + endParagraph(); + current = emptyScreen(); + steps.push({ + number: Number(step[1]), + title: step[2] ?? "", + explain: current, + practice: null, + }); + } else if (line === "## Fin") { + endParagraph(); + current = emptyScreen(); + end = current; + } else if (line === "### À vous") { + endParagraph(); + const last = steps.at(-1); + if (last === undefined) { + throw new Error("« À vous » hors d'une étape"); + } + current = emptyScreen(); + last.practice = current; + } else if (current === null) { + // Notice avant la première étape : non affichée. + } else if (key !== null && paragraph.length === 0 && current.paragraphs.length === 0) { + const values = current.keys.get(key[1] ?? "") ?? []; + values.push((key[2] ?? "").trim()); + current.keys.set(key[1] ?? "", values); + } else { + paragraph.push(line); + } + } + endParagraph(); + + steps.forEach((step, i) => { + if (step.number !== i + 1) { + throw new Error(`étape ${step.number} à la place de l'étape ${i + 1}`); + } + }); + if (end === null) { + throw new Error("écran de fin absent"); + } + const finish: RawScreen = end; + return { + steps: steps.map((step) => ({ + number: step.number, + title: step.title, + explain: screenOf(step.explain, `étape ${step.number}`), + practice: + step.practice === null + ? null + : screenOf(step.practice, `étape ${step.number}, à vous`), + })), + end: { + action: finish.keys.get("action")?.[0] ?? "", + paragraphs: finish.paragraphs.map(parseInline), + }, + }; +} diff --git a/app/tests/router.test.ts b/app/tests/router.test.ts index 0894e9c..2f53724 100644 --- a/app/tests/router.test.ts +++ b/app/tests/router.test.ts @@ -28,8 +28,11 @@ const TABLE: [string, Route][] = [ ["#/glossaire", { screen: "glossary", term: null }], ["#/glossaire/videau", { screen: "glossary", term: "videau" }], ["#/a-propos", { screen: "about" }], - ["#/tutoriel", { screen: "tutorial", step: null }], - ["#/tutoriel/3", { screen: "tutorial", step: 3 }], + ["#/tutoriel", { screen: "tutorial", step: null, practice: false }], + ["#/tutoriel/3", { screen: "tutorial", step: 3, practice: false }], + ["#/tutoriel/3/a-vous", { screen: "tutorial", step: 3, practice: true }], + ["#/tutoriel/fin", { screen: "tutorialEnd" }], + ["#/installer", { screen: "install" }], ["#/parties", { screen: "games", game: null }], ["#/parties/mh2k3x-a1b2c3", { screen: "games", game: "mh2k3x-a1b2c3" }], ["#/menu", { screen: "menu" }], @@ -63,6 +66,10 @@ test("une adresse inconnue est reconnue comme telle", () => { "#/menu/plus", "#/tutoriel/0", "#/tutoriel/x", + "#/tutoriel/3/b", + "#/tutoriel/3/a-vous/x", + "#/tutoriel/fin/a-vous", + "#/installer/x", "#/a-propos/x", "#/position", "#/position/%E0%A4%A", diff --git a/app/tests/tutorial.test.ts b/app/tests/tutorial.test.ts new file mode 100644 index 0000000..722ffb2 --- /dev/null +++ b/app/tests/tutorial.test.ts @@ -0,0 +1,179 @@ +// Tests du tutoriel des règles : le texte (docs/tutoriel/regles.md) se lit, +// chaque position est valide pour le vrai moteur, chaque coup illustré et +// chaque coup attendu est légal, chaque écran « à vous » accepte ses coups +// justes et refuse les autres, et la réponse au double est celle du moteur. +// +// Copyright (C) 2026 les auteurs d'Ustagammon. +// Licence : GPLv3 ou ultérieure (voir LICENSE). + +import assert from "node:assert/strict"; +import { existsSync, readFileSync } from "node:fs"; +import { test } from "node:test"; +import type { Move } from "../src/domain/game/engine.js"; +import { + canStillLeadTo, + illustratedMove, + MovePractice, + shouldTake, + tutorialBoard, +} from "../src/domain/learning/tutorial.js"; +import { parseInline, parseTutorial, type TutorialScreen } from "../src/ui/tutorial-text.js"; +import { loadNodeEngine } from "./support/node-engine.js"; + +const root = new URL("../../../", import.meta.url); +const tutorial = parseTutorial(readFileSync(new URL("docs/tutoriel/regles.md", root), "utf8")); + +test("le texte du tutoriel a huit étapes, une fin, et ses écrans « à vous »", () => { + assert.equal(tutorial.steps.length, 8); + assert.deepEqual( + tutorial.steps.filter((s) => s.practice !== null).map((s) => s.number), + [3, 4, 6, 8], + ); + assert.ok(tutorial.end.action !== ""); + assert.ok(tutorial.end.paragraphs.length > 0); + for (const step of tutorial.steps) { + assert.ok(step.explain.paragraphs.length > 0, `étape ${step.number} sans texte`); + const practice = step.practice; + if (practice !== null) { + assert.ok(practice.paragraphs.length > 0, `étape ${step.number} : consigne absente`); + assert.ok(practice.right.length > 0 && practice.wrong.length > 0, + `étape ${step.number} : phrases juste et refus absentes`); + assert.ok(practice.expected.length > 0 || practice.answer !== null, + `étape ${step.number} : ni coup attendu, ni réponse au double`); + } + } +}); + +test("chaque mot du glossaire cité par le tutoriel a sa fiche", () => { + const screens = tutorial.steps.flatMap((s) => [s.explain, ...(s.practice ? [s.practice] : [])]); + const inlines = [ + ...screens.flatMap((s) => [...s.paragraphs.flat(), ...s.right, ...s.wrong]), + ...tutorial.end.paragraphs.flat(), + ]; + const terms = inlines.flatMap((i) => (i.term === undefined ? [] : [i.term])); + assert.ok(terms.length >= 10, `seulement ${terms.length} liens vers le glossaire`); + for (const term of terms) { + assert.ok(existsSync(new URL(`docs/glossaire/${term}.md`, root)), `fiche ${term}.md absente`); + } +}); + +test("les liens et le gras du texte sont lus", () => { + assert.deepEqual(parseInline("Un [blot](blot.md) **seul**, voilà."), [ + { text: "Un " }, + { text: "blot", term: "blot" }, + { text: " " }, + { text: "seul", strong: true }, + { text: ", voilà." }, + ]); +}); + +test("un texte mal formé est refusé", () => { + assert.throws(() => parseTutorial("## 2. Sans la première\n\n- position: x\n\nTexte.\n\n## Fin\n")); + assert.throws(() => parseTutorial("## 1. Sans position\n\nTexte.\n\n## Fin\n")); + assert.throws(() => parseTutorial("## 1. Jet faux\n\n- position: x\n- jet: 7-1\n\n## Fin\n")); + assert.throws(() => parseTutorial("## 1. Sans fin\n\n- position: x\n\nTexte.\n")); +}); + +/** Tous les écrans, avec leur nom pour les messages. */ +function screens(): [string, TutorialScreen][] { + return tutorial.steps.flatMap((s) => [ + [`étape ${s.number}`, s.explain] as [string, TutorialScreen], + ...(s.practice === null + ? [] + : [[`étape ${s.number}, à vous`, s.practice] as [string, TutorialScreen]]), + ]); +} + +test("chaque position du tutoriel est valide pour le moteur", async () => { + const engine = await loadNodeEngine(); + for (const [name, screen] of screens()) { + const board = await tutorialBoard(engine, screen.position, screen.onRoll); + const count = (side: "player" | "computer") => { + const s = board.sides[side]; + return s.points.reduce((a, b) => a + b, 0) + s.bar + s.off; + }; + assert.equal(count("player"), 15, `${name} : pions ivoire`); + assert.equal(count("computer"), 15, `${name} : pions noyer`); + } +}); + +test("chaque coup illustré est légal, et la partie de l'étape 7 finit par un gammon", async () => { + const engine = await loadNodeEngine(); + for (const [name, screen] of screens()) { + if (screen.move === null) { + continue; + } + assert.ok(screen.roll !== null, `${name} : coup sans jet`); + const { steps, after } = await illustratedMove(engine, screen.position, screen.roll, + screen.move); + assert.equal(steps.boards.length, steps.halfMoves.length + 1, name); + if (name === "étape 7") { + assert.deepEqual(after.result, { winner: "player", kind: "gammon" }); + } else { + assert.equal(after.result, null, name); + } + if (name === "étape 4") { + assert.ok(steps.hits.some(Boolean), "étape 4 : le coup illustré ne frappe pas"); + } + } +}); + +test("chaque écran « à vous » accepte ses coups attendus et refuse les autres", async () => { + const engine = await loadNodeEngine(); + for (const [name, screen] of screens()) { + if (screen.expected.length === 0) { + continue; + } + assert.ok(screen.roll !== null, `${name} : pas de jet`); + const legal = await engine.legalMoves(screen.position, screen.roll); + for (const move of legal) { + const practice = await MovePractice.start(engine, screen.position, screen.roll, + screen.expected); + let verdict = "continue"; + for (const halfMove of move.halfMoves) { + verdict = await practice.play(halfMove); + if (verdict !== "continue") { + break; + } + } + const right = screen.expected.includes(move.notation); + assert.equal(verdict, right ? "right" : "wrong", `${name} : ${move.notation}`); + } + // Un coup attendu au moins, et un coup refusé au moins. + assert.ok(legal.some((m) => !screen.expected.includes(m.notation)), `${name} : rien à refuser`); + } +}); + +test("le joueur peut revenir en arrière après un coup refusé", async () => { + const engine = await loadNodeEngine(); + const practice = await MovePractice.start(engine, "4HPwATDgc/ABMA", [3, 1], ["8/5 6/5"]); + assert.equal(await practice.play({ from: 24, to: 21 }), "wrong"); + practice.reset(); + assert.deepEqual(practice.pendingHalfMoves, []); + assert.equal(await practice.play({ from: 8, to: 5 }), "continue"); + assert.equal(await practice.play({ from: 6, to: 5 }), "right"); +}); + +test("la réponse au double de l'étape 8 est celle du moteur", async () => { + const engine = await loadNodeEngine(); + for (const [name, screen] of screens()) { + if (screen.answer === null) { + continue; + } + assert.equal(screen.onRoll, "computer", `${name} : l'ordinateur doit avoir la main`); + assert.equal(await shouldTake(engine, screen.position), screen.answer === "take", name); + } +}); + +test("des demi-coups peuvent encore mener à un coup attendu, ou plus", () => { + const move = (notation: string, halfMoves: [number, number][]): Move => ({ + notation, + halfMoves: halfMoves.map(([from, to]) => ({ from, to })), + position: notation, + }); + const expected = [move("8/5 6/5", [[8, 5], [6, 5]])]; + assert.ok(canStillLeadTo([], expected)); + assert.ok(canStillLeadTo([{ from: 6, to: 5 }], expected)); + assert.ok(!canStillLeadTo([{ from: 8, to: 7 }], expected)); + assert.ok(!canStillLeadTo([{ from: 8, to: 5 }, { from: 8, to: 5 }], expected)); +}); diff --git a/docs/glossaire/README.md b/docs/glossaire/README.md index 886ef6a..7d46bc5 100644 --- a/docs/glossaire/README.md +++ b/docs/glossaire/README.md @@ -40,6 +40,7 @@ exportée. `make test` vérifie le lien dans les deux sens : chaque fiche avec | [Barre](barre.md) | Bar | | [Blot](blot.md) | Blot | | [Branche](branche.md) | Branch | +| [Camp](camp.md) | Home board | | [Chance](chance.md) | Luck | | [Compte de points](compte-de-points.md) | Pip count | | [Coup](coup.md) | Move | diff --git a/docs/glossaire/camp.md b/docs/glossaire/camp.md new file mode 100644 index 0000000..3b9b8c3 --- /dev/null +++ b/docs/glossaire/camp.md @@ -0,0 +1,12 @@ +--- +term: Camp +english: Home board +--- +# Camp + +**En bref** : les six dernières flèches d'un joueur, avant sa sortie : il y rassemble ses pions pour les sortir, et un pion adverse frappé doit y rentrer. + +Votre camp est en bas à droite (vos flèches 1 à 6) ; celui de l'ordinateur, +en haut à droite (vos flèches 19 à 24), où rentrent vos pions frappés. + +Voir aussi : [Sortie](sortie.md), [Barre](barre.md), [Point](point.md) diff --git a/docs/tutoriel/README.md b/docs/tutoriel/README.md new file mode 100644 index 0000000..95bc379 --- /dev/null +++ b/docs/tutoriel/README.md @@ -0,0 +1,65 @@ +# Tutoriel des règles + +Le texte du tutoriel de l'application, en une seule source (ADR 0008) : +`regles.md`. `make site` copie ce dossier dans le site publié +(`docs/tutoriel/`) ; l'application le lit à l'exécution +(`app/src/ui/tutorial-text.ts`), et `make test` vérifie chaque position et +chaque coup avec le vrai moteur (`app/tests/tutorial.test.ts`). + +Texte sous licence **CC BY-SA 4.0**. Première version, rédigée par Claude +Code d'après la charte (`docs/identite/charte/accueil-tutoriel-aide.md`), +**à valider par Pierre**. + +## Format + +Tout ce qui précède la première étape (ce titre, cette notice) n'est pas +affiché. + +```markdown +## 3. Les dés et les doubles + +- position: 4HPwATDgc/ABMA +- jet: 5-2 +- coup: 13/11 13/8 + +Un paragraphe, avec un [mot du glossaire](pion.md) et du **gras**. + +### À vous + +- position: 4HPwATDgc/ABMA +- jet: 3-1 +- attendu: 8/5 6/5 +- juste: La phrase affichée quand le coup est juste. +- refus: La phrase affichée quand le coup est refusé. + +La consigne de l'écran. + +## Fin + +- action: Jouer ma première partie + +Le texte de la fin du tutoriel. +``` + +- `## . ` commence l'étape *n* (huit au plus, dans l'ordre) ; + `### À vous` commence son écran « à vous », facultatif ; `## Fin`, l'écran + qui suit la dernière étape. +- Les lignes `- clé: valeur` en tête d'écran le décrivent : + - `position` : l'identifiant de position de GNU Backgammon (ADR 0013), vu + du camp au trait ; + - `trait` : `ordinateur` si c'est l'ordinateur qui a la main dans cette + position (par défaut, vous) ; + - `jet` et `coup` : sur un écran d'explication, le coup illustré, qui se + joue tout seul (notation de gnubg) ; + - `chemin: oui` : le sens du jeu, dessiné en pointillés sur le plateau ; + - `attendu` (une ligne par coup accepté) : sur un écran « à vous », les + coups justes, en notation de gnubg, tels que le moteur les écrit ; + - `réponse` : `prendre` ou `passer`, pour un écran « à vous » où + l'ordinateur double ; le moteur doit donner la même réponse ; + - `juste` et `refus` : les phrases qui suivent un geste juste, ou refusé ; + - `action` : le libellé du bouton de la fin. +- Les autres lignes forment les paragraphes. `[mot](fiche.md)` renvoie à une + fiche du glossaire, à la première apparition du mot. + +Aucune règle n'est écrite dans le code : le moteur lit les positions, +illustre les coups, juge les coups joués et les décisions de videau. diff --git a/docs/tutoriel/regles.md b/docs/tutoriel/regles.md new file mode 100644 index 0000000..ea1371c --- /dev/null +++ b/docs/tutoriel/regles.md @@ -0,0 +1,130 @@ +# Les règles du backgammon + +Tutoriel d'Ustagammon, en huit étapes. Texte sous licence CC BY-SA 4.0, +première version à valider par Pierre. Format : voir README.md. + +## 1. Le but du jeu + +- position: 4HPwATDgc/ABMA + +Vous avez quinze [pions](pion.md) ivoire, l'ordinateur quinze pions noyer. Chacun fait faire le tour du [plateau](plateau.md) à ses pions, puis les [sort](sortie.md). + +Le premier qui a sorti tous les siens gagne la [partie](partie.md). + +## 2. Dans quel sens avancer + +- position: 4HPwATDgc/ABMA +- chemin: oui + +Vos pions ivoire font le tour du plateau dans le sens des pointillés : ils partent d'en haut à droite, passent à gauche, puis reviennent en bas jusqu'aux **six dernières flèches**, en bas à droite. C'est là que vous les rassemblerez avant de les sortir. + +Les pions noyer de l'ordinateur font le même chemin, en sens inverse. + +## 3. Les dés et les doubles + +- position: 4HPwATDgc/ABMA +- jet: 5-2 +- coup: 13/11 13/8 + +Chaque dé déplace un pion d'autant de flèches. Avec 5 et 2, vous jouez un pion de 5 et un pion de 2, ou le même pion de 5 puis de 2. + +Un double se joue quatre fois : avec 4-4, vous avancez quatre fois de 4. + +### À vous + +- position: 4HPwATDgc/ABMA +- jet: 3-1 +- attendu: 8/5 6/5 +- juste: Deux pions sur la flèche 5 : elle est à vous, l'ordinateur ne peut plus s'y poser. +- refus: Ce coup est permis, mais il ne réunit pas deux pions sur la flèche 5. Jouez un pion de la flèche 8 avec le 3, et un de la flèche 6 avec le 1. + +Vous avez 3 et 1. Réunissez deux pions sur la flèche 5 : touchez un pion, puis sa case d'arrivée. + +## 4. Frapper un pion seul + +- position: 4HPwESDgc/ABMA +- jet: 4-2 +- coup: 13/11 13/9* + +Un pion seul sur une flèche s'appelle un [blot](blot.md). Si vous vous posez dessus, vous le frappez : il part sur la [barre](barre.md) et devra refaire tout le chemin. + +Une flèche où l'adversaire a deux pions ou plus est fermée : vous ne pouvez pas vous y poser. + +### À vous + +- position: 4HPwIDDgc/ABMA +- jet: 6-1 +- attendu: 13/7* 8/7 +- attendu: 13/7*/6 +- attendu: 13/7* 6/5 +- attendu: 24/23 13/7* +- attendu: 24/18 8/7* +- attendu: 8/7* 8/2 +- juste: Touché : le pion noyer part sur la barre, il devra refaire tout le chemin. +- refus: Ce coup ne frappe pas le pion seul de la flèche 7. Un pion qui s'y pose le frappe : celui de la flèche 13 avec le 6, ou celui de la flèche 8 avec le 1. + +Vous avez 6 et 1. Le pion noyer de la flèche 7 est seul : frappez-le. + +## 5. Rentrer depuis la barre + +- position: 4HPwATDgc/ABUA +- jet: 4-3 +- coup: bar/22 13/9 + +Votre pion frappé attend sur la barre, au milieu. Avant de jouer quoi que ce soit d'autre, il doit rentrer dans le [camp](camp.md) adverse, en haut à droite. + +Chaque dé indique une flèche d'entrée : avec 3 et 4, la 22 ou la 21. Si les deux flèches sont fermées, votre tour passe. + +## 6. Sortir ses pions + +- position: bHcAANh2dwAAAA +- jet: 5-2 +- coup: 5/off 2/off + +Quand vos quinze pions sont dans les six dernières flèches, vous pouvez commencer à les sortir : un 6 sort un pion de la flèche 6, un 1 un pion de la flèche 1. + +Touchez le pion, puis la sortie à droite. + +### À vous + +- position: bHcAANh2dwAAAA +- jet: 6-3 +- attendu: 6/off 3/off +- juste: Deux pions sortis. Plus que treize ! +- refus: Ce coup est permis, mais il ne sort qu'un pion. Le 6 sort un pion de la flèche 6, le 3 un pion de la flèche 3. + +Vous avez 6 et 3 : sortez deux pions. + +## 7. Gammon et backgammon + +- position: 8J4HBwAFAAAAAA +- jet: 2-1 +- coup: 2/off 1/off + +Une partie gagnée vaut 1 point. Si l'adversaire n'a sorti aucun pion quand vous finissez, c'est un [gammon](gammon.md) : 2 points. S'il lui reste en plus un pion sur la barre ou dans votre camp, c'est un [backgammon](backgammon.md) : 3 points. + +Dans une partie libre contre l'ordinateur, le gammon et le backgammon ne comptent double ou triple qu'une fois le videau doublé : c'est la [règle de Jacoby](regle-de-jacoby.md). + +## 8. Le videau + +- position: 4HPwATDgc/ABMA + +Le cube aux faces 2, 4, 8, 16, 32 et 64 s'appelle le [videau](videau.md) : il sert à miser. Avant de lancer vos dés, vous pouvez proposer de [doubler](doubler.md) la valeur de la partie. + +L'ordinateur [prend](prendre.md), et la partie continue en valant le double, ou il [passe](passer.md), et vous gagnez tout de suite. Celui qui a pris garde le cube : lui seul pourra redoubler. + +### À vous + +- position: 7O7YAADs7jYAAA +- trait: ordinateur +- réponse: prendre +- juste: Le maître prend aussi. L'ordinateur mène la course, mais vous gagnez encore assez souvent pour que prendre vous coûte moins cher, en moyenne, que passer. +- refus: Le maître prendrait. Passer vous coûte un point tout de suite ; en prenant, vous gardez vos chances de gagner, et le videau est chez vous. + +C'est à l'ordinateur de jouer, et il mène la course. Il vous propose de doubler : prenez-vous, ou passez-vous ? + +## Fin + +- action: Jouer ma première partie + +Vous connaissez les règles. Le reste s'apprend en jouant, et le maître vous accompagne.