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:
co-authored by
Claude Opus 5.5
parent
f1c589eff1
commit
3f5b00133d
@@ -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
@@ -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":
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
Reference in New Issue
Block a user