Tutoriel : texte source, lecture, vérification par le moteur, adresses

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Pierre MARTIN
2026-11-09 14:55:32 +01:00
co-authored by Claude Opus 5.5
parent f1c589eff1
commit 3f5b00133d
9 changed files with 877 additions and 13 deletions
+243
View File
@@ -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<TutorialBoard> {
const board = await engine.board(position, engineCube(cube, onRoll));
const sides =
onRoll === "player"
? { player: board.onRoll, computer: board.opponent }
: { player: board.opponent, computer: board.onRoll };
const result =
board.result === null
? null
: {
winner: (board.result.winner === "onRoll") === (onRoll === "player")
? ("player" as const)
: ("computer" as const),
kind: board.result.kind,
};
return { sides, result };
}
/** Les coups du jet que le moteur déclare légaux et dont la notation est
* l'une des notations données. Erreur si l'une d'elles n'est pas un coup
* légal : le texte du tutoriel ne correspond plus au moteur. */
export async function expectedMoves(
engine: Engine,
position: PositionId,
roll: Roll,
notations: readonly string[],
): Promise<Move[]> {
const legal = await engine.legalMoves(position, roll);
return notations.map((notation) => {
const move = legal.find((m) => m.notation === notation);
if (move === undefined) {
throw new Error(
`${notation} n'est pas un coup légal de ${roll.join("-")} dans ${position}`,
);
}
return move;
});
}
/** Le coup illustré d'un écran d'explication, demi-coup par demi-coup :
* les plateaux successifs, donnés par le moteur, et le résultat si le coup
* finit la partie. */
export async function illustratedMove(
engine: Engine,
position: PositionId,
roll: Roll,
notation: string,
): Promise<{ steps: MoveSteps; after: TutorialBoard }> {
const [move] = await expectedMoves(engine, position, roll, [notation]);
if (move === undefined) {
throw new Error(`coup illustré introuvable : ${notation}`);
}
const positions = [position];
for (let i = 1; i <= move.halfMoves.length; i++) {
const check = await engine.checkHalfMoves(position, roll, move.halfMoves.slice(0, i));
if (check.position === null) {
throw new Error(`demi-coup refusé par le moteur : ${notation}`);
}
positions.push(check.position);
}
const boards = await Promise.all(positions.map((p) => tutorialBoard(engine, p)));
const hits = move.halfMoves.map(
(_, i) => (boards[i + 1]?.sides.computer.bar ?? 0) > (boards[i]?.sides.computer.bar ?? 0),
);
const after = boards.at(-1) ?? boards[0];
if (after === undefined) {
throw new Error("aucun plateau");
}
return {
steps: {
side: "player",
halfMoves: move.halfMoves,
boards: boards.map((b) => b.sides),
hits,
},
after,
};
}
/**
* Les demi-coups saisis peuvent-ils encore mener à l'un des coups attendus ?
* Oui s'ils font tous partie (comme multiensemble) des demi-coups de l'un
* d'eux, tels que le moteur les donne. Un coup complet est jugé sur sa
* position (verdict de MovePractice).
*/
export function canStillLeadTo(
played: readonly HalfMove[],
expected: readonly Move[],
): boolean {
return expected.some((move) => {
const left = [...move.halfMoves];
return played.every((h) => {
const i = left.findIndex((m) => m.from === h.from && m.to === h.to);
if (i < 0) {
return false;
}
left.splice(i, 1);
return true;
});
});
}
/** Ce que devient un écran « à vous » après un demi-coup. */
export type PracticeVerdict = "continue" | "right" | "wrong";
/**
* Un écran « à vous » où le joueur joue un coup : une partie d'un seul jet,
* commencée à la position de l'écran, jamais enregistrée. Le joueur saisit
* ses demi-coups comme en partie (le moteur dit lesquels sont permis) ; le
* coup est juste s'il mène à la position de l'un des coups attendus.
*/
export class MovePractice {
readonly #game: Game;
readonly #expected: readonly Move[];
readonly roll: Roll;
private constructor(game: Game, expected: readonly Move[], roll: Roll) {
this.#game = game;
this.#expected = expected;
this.roll = roll;
}
/** Prépare l'écran : la position, le jet, et les coups attendus,
* reconnus par le moteur (erreur sinon). */
static async start(
engine: Engine,
position: PositionId,
roll: Roll,
notations: readonly string[],
): Promise<MovePractice> {
const expected = await expectedMoves(engine, position, roll, notations);
const game = Game.start(engine, {
setup: { position, firstTurn: "player" },
dice: fixedDice(roll),
ply: 0,
});
await game.roll();
return new MovePractice(game, expected, roll);
}
/** Le plateau affiché, demi-coups saisis compris. */
board(): Promise<SideBoards> {
return this.#game.displayedBoard();
}
/** Les demi-coups permis maintenant (le moteur le dit). */
nextHalfMoves(): Promise<HalfMove[]> {
return this.#game.nextHalfMoves();
}
get pendingHalfMoves(): readonly HalfMove[] {
return this.#game.pendingHalfMoves;
}
/** Les dés du jet, et ceux déjà utilisés. */
get diceToPlay(): { value: number; used: boolean }[] {
return this.#game.diceToPlay;
}
/**
* Joue un demi-coup. « wrong » si le moteur le refuse, ou s'il ne peut
* plus mener à un coup attendu, ou si le coup complet n'est pas l'un
* d'eux : le demi-coup reste affiché, à l'appelant de revenir en
* arrière (reset). « right » quand le coup complet est l'un des coups
* attendus.
*/
async play(halfMove: HalfMove): Promise<PracticeVerdict> {
if (!(await this.#game.playHalfMove(halfMove))) {
return "wrong";
}
if (this.#game.moveComplete) {
const position = this.#game.displayedPosition;
return this.#expected.some((m) => m.position === position) ? "right" : "wrong";
}
return canStillLeadTo(this.#game.pendingHalfMoves, this.#expected) ? "continue" : "wrong";
}
/** Le pion revient : tous les demi-coups saisis sont oubliés. */
reset(): void {
this.#game.cancelHalfMoves();
}
}
/**
* Un écran « à vous » de videau : l'ordinateur, au trait dans position (vue
* de lui), double. Le moteur dit, au niveau standard de l'ordinateur, si le
* joueur doit prendre (vrai) ou passer (faux). Erreur si l'ordinateur ne
* peut pas doubler dans cette position.
*/
export async function shouldTake(engine: Engine, position: PositionId): Promise<boolean> {
const decision = await engine.cubeDecision(
position,
{ value: 1, owner: "centered" },
STANDARD_PLY,
);
if (!decision.available) {
throw new Error(`l'ordinateur ne peut pas doubler dans ${position}`);
}
return decision.take;
}
+38 -11
View File
@@ -10,7 +10,7 @@
export type Route =
/** #/ : la partie en cours. */
| { readonly screen: "game" }
/** #/accueil (écran à venir, étape 0e.5). */
/** #/accueil. */
| { readonly screen: "home" }
/** #/aide, et une question ouverte : #/aide/<identifiant>. */
| { readonly screen: "help"; readonly question: string | null }
@@ -18,8 +18,13 @@ export type Route =
| { readonly screen: "glossary"; readonly term: string | null }
/** #/a-propos. */
| { readonly screen: "about" }
/** #/tutoriel, et une étape : #/tutoriel/<n> (écran à venir). */
| { readonly screen: "tutorial"; readonly step: number | null }
/** #/tutoriel, une étape : #/tutoriel/<n>, et son écran « à vous » :
* #/tutoriel/<n>/a-vous. */
| { readonly screen: "tutorial"; readonly step: number | null; readonly practice: boolean }
/** #/tutoriel/fin : la fin du tutoriel. */
| { readonly screen: "tutorialEnd" }
/** #/installer : la marche à suivre pour installer l'application. */
| { readonly screen: "install" }
/** #/parties, et une partie : #/parties/<identifiant>. */
| { readonly screen: "games"; readonly game: string | null }
/** #/menu. */
@@ -58,6 +63,9 @@ export function parseRoute(hash: string): Route {
if (head === "position") {
return positionRoute(rest.join("/")) ?? unknown;
}
if (head === "tutoriel") {
return tutorialRoute(rest) ?? unknown;
}
if (rest.length > 1) {
return unknown;
}
@@ -80,18 +88,31 @@ export function parseRoute(hash: string): Route {
return name === null ? { screen: "menu" } : unknown;
case "parties":
return { screen: "games", game: name };
case "tutoriel": {
if (name === null) {
return { screen: "tutorial", step: null };
}
const step = Number(name);
return /^[1-9][0-9]?$/.test(name) ? { screen: "tutorial", step } : unknown;
}
case "installer":
return name === null ? { screen: "install" } : unknown;
default:
return unknown;
}
}
/** #/tutoriel, #/tutoriel/<n>, #/tutoriel/<n>/a-vous, #/tutoriel/fin. */
function tutorialRoute(rest: readonly string[]): Route | null {
const [name, part, ...more] = rest;
if (name === undefined) {
return { screen: "tutorial", step: null, practice: false };
}
if (more.length > 0) {
return null;
}
if (name === "fin") {
return part === undefined ? { screen: "tutorialEnd" } : null;
}
if (!/^[1-9][0-9]?$/.test(name) || (part !== undefined && part !== "a-vous")) {
return null;
}
return { screen: "tutorial", step: Number(name), practice: part === "a-vous" };
}
/**
* #/position/... : le Position ID (14 caractères), puis, après une barre, le
* Match ID. Chaque identifiant est encodé avec encodeURIComponent, mais une
@@ -134,7 +155,13 @@ export function routeHash(route: Route): string {
case "about":
return "#/a-propos";
case "tutorial":
return route.step === null ? "#/tutoriel" : `#/tutoriel/${route.step}`;
return route.step === null
? "#/tutoriel"
: `#/tutoriel/${route.step}${route.practice ? "/a-vous" : ""}`;
case "tutorialEnd":
return "#/tutoriel/fin";
case "install":
return "#/installer";
case "games":
return route.game === null ? "#/parties" : `#/parties/${route.game}`;
case "menu":
+200
View File
@@ -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<string, string[]>;
paragraphs: string[];
}
function emptyScreen(): RawScreen {
return { keys: new Map(), paragraphs: [] };
}
function rollOf(text: string | undefined, where: string): readonly [number, number] | null {
if (text === undefined) {
return null;
}
const match = /^([1-6])-([1-6])$/.exec(text);
if (match === null) {
throw new Error(`${where} : jet illisible « ${text} »`);
}
return [Number(match[1]), Number(match[2])];
}
function screenOf(raw: RawScreen, where: string): TutorialScreen {
const one = (key: string) => raw.keys.get(key)?.[0];
const position = one("position");
if (position === undefined) {
throw new Error(`${where} : position absente`);
}
const trait = one("trait");
if (trait !== undefined && trait !== "ordinateur" && trait !== "joueur") {
throw new Error(`${where} : trait inconnu « ${trait} »`);
}
const answer = one("réponse");
if (answer !== undefined && answer !== "prendre" && answer !== "passer") {
throw new Error(`${where} : réponse inconnue « ${answer} »`);
}
return {
position,
onRoll: trait === "ordinateur" ? "computer" : "player",
roll: rollOf(one("jet"), where),
move: one("coup") ?? null,
path: one("chemin") === "oui",
expected: raw.keys.get("attendu") ?? [],
answer: answer === undefined ? null : answer === "prendre" ? "take" : "pass",
right: parseInline(one("juste") ?? ""),
wrong: parseInline(one("refus") ?? ""),
paragraphs: raw.paragraphs.map(parseInline),
};
}
/** Lit le texte du tutoriel. Erreur si un écran est incomplet ou si les
* étapes ne se suivent pas. */
export function parseTutorial(text: string): Tutorial {
const steps: { number: number; title: string; explain: RawScreen; practice: RawScreen | null }[] =
[];
let end: RawScreen | null = null;
let current: RawScreen | null = null;
let paragraph: string[] = [];
const endParagraph = () => {
if (paragraph.length > 0 && current !== null) {
current.paragraphs.push(paragraph.join(" "));
}
paragraph = [];
};
for (const raw of text.split("\n")) {
const line = raw.trim();
const step = /^## (\d+)\. (.+)$/.exec(line);
const key = /^- ([a-zéè]+): (.+)$/.exec(line);
if (line === "") {
endParagraph();
} else if (step !== null) {
endParagraph();
current = emptyScreen();
steps.push({
number: Number(step[1]),
title: step[2] ?? "",
explain: current,
practice: null,
});
} else if (line === "## Fin") {
endParagraph();
current = emptyScreen();
end = current;
} else if (line === "### À vous") {
endParagraph();
const last = steps.at(-1);
if (last === undefined) {
throw new Error("« À vous » hors d'une étape");
}
current = emptyScreen();
last.practice = current;
} else if (current === null) {
// Notice avant la première étape : non affichée.
} else if (key !== null && paragraph.length === 0 && current.paragraphs.length === 0) {
const values = current.keys.get(key[1] ?? "") ?? [];
values.push((key[2] ?? "").trim());
current.keys.set(key[1] ?? "", values);
} else {
paragraph.push(line);
}
}
endParagraph();
steps.forEach((step, i) => {
if (step.number !== i + 1) {
throw new Error(`étape ${step.number} à la place de l'étape ${i + 1}`);
}
});
if (end === null) {
throw new Error("écran de fin absent");
}
const finish: RawScreen = end;
return {
steps: steps.map((step) => ({
number: step.number,
title: step.title,
explain: screenOf(step.explain, `étape ${step.number}`),
practice:
step.practice === null
? null
: screenOf(step.practice, `étape ${step.number}, à vous`),
})),
end: {
action: finish.keys.get("action")?.[0] ?? "",
paragraphs: finish.paragraphs.map(parseInline),
},
};
}