Compare commits
65
Commits
@@ -127,9 +127,9 @@ SITE_ICONS := icone.svg icone-192.png icone-512.png icone-maskable-192.png icone
|
||||
# docs/identite/logo/ (une seule source, ADR 0010) ;
|
||||
# - l'application compilée (build/web/src/) sous app/ ;
|
||||
# - le moteur sous wasm/ ;
|
||||
# - les copies du glossaire, des scénarios, du tutoriel, des phrases du
|
||||
# - les copies du glossaire, des scénarios, du tutoriel, des leçons, des phrases du
|
||||
# maître et des prompts pour une IA sous docs/, lues par l'aide, le
|
||||
# tutoriel, le conseil du maître et « Demander à une IA »
|
||||
# tutoriel, les leçons, le conseil du maître et « Demander à une IA »
|
||||
# (ADR 0008 : jamais commitées
|
||||
# ailleurs) ;
|
||||
# - version.json (commit publié) ;
|
||||
@@ -143,7 +143,8 @@ site: engine-wasm web ## Assemble le site publié dans build/site/
|
||||
cp -r $(BUILD_DIR)/web/src $(BUILD_DIR)/site/app
|
||||
cp $(BUILD_DIR)/wasm/engine.mjs $(BUILD_DIR)/wasm/engine.wasm $(BUILD_DIR)/wasm/engine.data \
|
||||
$(BUILD_DIR)/wasm/positions.json $(BUILD_DIR)/site/wasm/
|
||||
cp -r docs/glossaire docs/scenarios docs/tutoriel docs/maitre docs/prompts $(BUILD_DIR)/site/docs/
|
||||
cp -r docs/glossaire docs/scenarios docs/tutoriel docs/lecons docs/maitre docs/prompts \
|
||||
$(BUILD_DIR)/site/docs/
|
||||
printf '{"commit": "%s", "dirty": %s}\n' "$$(git rev-parse HEAD)" \
|
||||
"$$(git diff --quiet HEAD && echo false || echo true)" > $(BUILD_DIR)/site/version.json
|
||||
scripts/service-worker.sh $(BUILD_DIR)/site
|
||||
|
||||
@@ -102,10 +102,9 @@ export async function analyseDecision(
|
||||
if (analysis === null) {
|
||||
return null;
|
||||
}
|
||||
const ranking =
|
||||
options.ranking === false
|
||||
? []
|
||||
: await engine.rankMoves(before.position, cube, before.roll, ply);
|
||||
// Le classement est celui de l'analyse même : mêmes coups, mêmes
|
||||
// niveaux et mêmes équités que le coup joué et le meilleur coup.
|
||||
const ranking = options.ranking === false ? [] : analysis.ranking;
|
||||
const start = await engine.board(before.position, cube);
|
||||
const facts = async (move: EvaluatedMove) =>
|
||||
factsOf(start.onRoll, start.opponent, await engine.board(move.position, cube));
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
|
||||
import type { AdvisedMove, MoveAdvice } from "../game/advice.js";
|
||||
import type { Ply } from "../game/engine.js";
|
||||
|
||||
/** Un nombre à la française, signé, à trois décimales : « −0,123 ». */
|
||||
export function formatEquity(value: number): string {
|
||||
@@ -34,18 +35,21 @@ export interface RankingRow {
|
||||
|
||||
/**
|
||||
* Le classement d'un conseil tel que l'affiche la feuille du maître : les
|
||||
* coups du classement du moteur, chacun avec son écart par rapport au
|
||||
* premier ; puis le coup joué, s'il n'y figure pas, avec l'écart de
|
||||
* l'analyse du coup joué.
|
||||
* coups du classement de l'analyse, chacun avec son écart par rapport au
|
||||
* premier ; puis le coup joué, s'il n'y figure pas. Le coup joué a toujours
|
||||
* l'écart de l'analyse (equityLoss), celui que dit le maître.
|
||||
*/
|
||||
export function rankingRows(advice: MoveAdvice): RankingRow[] {
|
||||
const first = advice.ranking[0]?.equity ?? advice.best.equity;
|
||||
const rows = advice.ranking.map((move, i) => ({
|
||||
move,
|
||||
rank: i + 1,
|
||||
loss: move.equity - first,
|
||||
played: move.notation === advice.played.notation,
|
||||
}));
|
||||
const rows = advice.ranking.map((move, i) => {
|
||||
const played = move.notation === advice.played.notation;
|
||||
return {
|
||||
move,
|
||||
rank: i + 1,
|
||||
loss: played ? advice.equityLoss : move.equity - first,
|
||||
played,
|
||||
};
|
||||
});
|
||||
if (!rows.some((row) => row.played)) {
|
||||
rows.push({
|
||||
move: advice.played,
|
||||
@@ -56,3 +60,18 @@ export function rankingRows(advice: MoveAdvice): RankingRow[] {
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* Le niveau du classement d'un conseil : celui auquel le moteur a évalué le
|
||||
* meilleur coup. C'est le niveau demandé (advice.ply), sauf quand gnubg
|
||||
* s'arrête avant, parce qu'un seul coup a passé ses filtres : tout le
|
||||
* classement reste alors au niveau inférieur.
|
||||
*/
|
||||
export function rankingPly(advice: MoveAdvice): Ply {
|
||||
return advice.best.ply;
|
||||
}
|
||||
|
||||
/** « 1 pli », « 2 plis » : gnubg compte les plis à partir de 0. */
|
||||
export function pliesText(ply: Ply): string {
|
||||
return `${ply} ${ply > 1 ? "plis" : "pli"}`;
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
} from "../game/advice.js";
|
||||
import type { CubeAction, LuckRating } from "../game/engine.js";
|
||||
import type { GameSummary } from "./game-analysis.js";
|
||||
import type { ProgressionTrend } from "./progression.js";
|
||||
|
||||
/** Les gabarits, par clé de section (« coup.meilleur »…). */
|
||||
export type MasterPhrases = ReadonlyMap<string, readonly string[]>;
|
||||
@@ -103,6 +104,10 @@ export const PHRASE_KEYS: readonly string[] = [
|
||||
"bilan.des.contre",
|
||||
"bilan.des.tres-contre",
|
||||
"bilan.aucun-coup",
|
||||
"progression.baisse",
|
||||
"progression.hausse",
|
||||
"progression.stable",
|
||||
"progression.attente",
|
||||
];
|
||||
|
||||
const DICE_KEYS: Readonly<Record<LuckRating, string>> = {
|
||||
@@ -140,6 +145,26 @@ export function nothingToReview(phrases: MasterPhrases): string {
|
||||
return pick(phrases, "bilan.aucun-coup", "0");
|
||||
}
|
||||
|
||||
const TREND_KEYS: Readonly<Record<ProgressionTrend["kind"], string>> = {
|
||||
down: "progression.baisse",
|
||||
up: "progression.hausse",
|
||||
steady: "progression.stable",
|
||||
waiting: "progression.attente",
|
||||
};
|
||||
|
||||
/** La phrase de l'écran de progression (étape 1h) : la tendance du taux
|
||||
* d'erreur, en faits (nombre de parties, moyennes en millièmes). */
|
||||
export function progressionSentence(phrases: MasterPhrases, trend: ProgressionTrend): string {
|
||||
const template = pick(phrases, TREND_KEYS[trend.kind], "0");
|
||||
return trend.kind === "waiting"
|
||||
? template
|
||||
: fillIn(template, {
|
||||
parties: String(trend.games),
|
||||
debut: String(trend.from),
|
||||
fin: String(trend.to),
|
||||
});
|
||||
}
|
||||
|
||||
/** Ce que le maître dit d'un conseil du moteur. */
|
||||
export function masterWords(phrases: MasterPhrases, advice: Advice): MasterWords {
|
||||
return advice.kind === "move" ? moveWords(phrases, advice) : cubeWords(phrases, advice);
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
// La progression du joueur (étape 1h ; charte, ecrans-etape-1.md, « 1h ») :
|
||||
// le taux d'erreur de chaque partie terminée, tel que le bilan le calcule
|
||||
// (summarizeAnalysis), sa courbe partie après partie et la tendance que dit
|
||||
// la phrase du maître. Fonctions pures, testées sous Node
|
||||
// (app/tests/progression.test.ts).
|
||||
//
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
//
|
||||
// Rien n'est recalculé ici : le taux d'une partie vient des analyses
|
||||
// rangées dans son journal (1e). Une partie dont l'analyse n'est pas finie
|
||||
// n'a pas encore de taux ; l'application l'analyse en arrière-plan.
|
||||
|
||||
import type { Level } from "../game/engine.js";
|
||||
import { currentLine, type GameJournal } from "../game/events.js";
|
||||
import { type GameResult, replay } from "../game/state.js";
|
||||
import { type ErrorRate, readyForSummary, summarizeAnalysis } from "./game-analysis.js";
|
||||
|
||||
/** Les parties prises en compte au plus : les plus récentes. Au-delà, la
|
||||
* courbe devient illisible sur un téléphone, et il faudrait relire tous
|
||||
* les journaux à chaque visite. */
|
||||
export const PROGRESSION_MAX_GAMES = 20;
|
||||
|
||||
/** Parties analysées qu'il faut pour tracer la courbe (charte : « Votre
|
||||
* courbe apparaîtra après trois parties »). */
|
||||
export const CURVE_MIN_GAMES = 3;
|
||||
|
||||
/** Une partie terminée, telle que le stockage la donne. */
|
||||
export interface FinishedGame {
|
||||
readonly id: string;
|
||||
/** Début de la partie, en millisecondes depuis 1970. */
|
||||
readonly startedAt: number;
|
||||
/** Le niveau de l'ordinateur. */
|
||||
readonly level: Level;
|
||||
readonly result: GameResult | null;
|
||||
readonly journal: GameJournal;
|
||||
}
|
||||
|
||||
/** Une ligne de la progression : une partie terminée et son taux
|
||||
* d'erreur (null : analyse pas encore finie). */
|
||||
export interface ProgressionEntry {
|
||||
readonly id: string;
|
||||
readonly startedAt: number;
|
||||
readonly level: Level;
|
||||
readonly result: GameResult | null;
|
||||
/** La valeur du videau à la fin de la partie (1 : jamais doublé). */
|
||||
readonly cube: number;
|
||||
readonly errorRate: ErrorRate | null;
|
||||
}
|
||||
|
||||
/** Les lignes de la progression, de la plus ancienne à la plus récente.
|
||||
* Le taux d'erreur est celui du bilan, sur les analyses déjà rangées. */
|
||||
export function progressionEntries(games: readonly FinishedGame[]): ProgressionEntry[] {
|
||||
return [...games]
|
||||
.sort((a, b) => a.startedAt - b.startedAt)
|
||||
.map((game) => ({
|
||||
id: game.id,
|
||||
startedAt: game.startedAt,
|
||||
level: game.level,
|
||||
result: game.result,
|
||||
cube: replay(currentLine(game.journal)).cube.value,
|
||||
errorRate: readyForSummary(game.journal)
|
||||
? summarizeAnalysis(game.journal).player.errorRate
|
||||
: null,
|
||||
}));
|
||||
}
|
||||
|
||||
/** Un point de la courbe : une partie analysée. */
|
||||
export interface ProgressionPoint {
|
||||
readonly id: string;
|
||||
/** Équité perdue par décision (ErrorRate.perDecision). */
|
||||
readonly perDecision: number;
|
||||
}
|
||||
|
||||
/** Les points de la courbe, de la plus ancienne partie analysée à la plus
|
||||
* récente. */
|
||||
export function progressionCurve(entries: readonly ProgressionEntry[]): ProgressionPoint[] {
|
||||
return entries.flatMap((entry) =>
|
||||
entry.errorRate === null
|
||||
? []
|
||||
: [{ id: entry.id, perDecision: entry.errorRate.perDecision }],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Le taux d'erreur tel que l'écran l'écrit : en millièmes d'équité par
|
||||
* décision, arrondi (0,014 s'écrit 14), comme les « mEMG » de GNU
|
||||
* Backgammon. Le glossaire le dit (docs/glossaire/taux-d-erreur.md).
|
||||
*/
|
||||
export function errorRateMillis(perDecision: number): number {
|
||||
return Math.round(perDecision * 1000);
|
||||
}
|
||||
|
||||
/** Ce que dit la phrase de la progression. */
|
||||
export type ProgressionTrend =
|
||||
/** Moins de CURVE_MIN_GAMES parties analysées. */
|
||||
| { readonly kind: "waiting" }
|
||||
/** Le taux baisse, monte ou ne change pas : moyenne de la première
|
||||
* moitié des parties (from) et de la seconde (to), en millièmes. */
|
||||
| {
|
||||
readonly kind: "down" | "up" | "steady";
|
||||
readonly games: number;
|
||||
readonly from: number;
|
||||
readonly to: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* La tendance de la courbe : la moyenne des taux de la plus ancienne moitié
|
||||
* des parties, comparée à celle de la plus récente (la partie du milieu,
|
||||
* quand leur nombre est impair, n'entre dans aucune). Les deux moyennes
|
||||
* sont arrondies en millièmes avant d'être comparées : la phrase ne dit
|
||||
* jamais « passé de 9 à 9 ».
|
||||
*/
|
||||
export function progressionTrend(points: readonly ProgressionPoint[]): ProgressionTrend {
|
||||
if (points.length < CURVE_MIN_GAMES) {
|
||||
return { kind: "waiting" };
|
||||
}
|
||||
const half = Math.floor(points.length / 2);
|
||||
const mean = (part: readonly ProgressionPoint[]) =>
|
||||
part.reduce((sum, p) => sum + p.perDecision, 0) / part.length;
|
||||
const from = errorRateMillis(mean(points.slice(0, half)));
|
||||
const to = errorRateMillis(mean(points.slice(points.length - half)));
|
||||
const kind = to < from ? "down" : to > from ? "up" : "steady";
|
||||
return { kind, games: points.length, from, to };
|
||||
}
|
||||
@@ -10,13 +10,15 @@
|
||||
// stockage, dans le journal (events.ts, champ advice). Le format est
|
||||
// durable (ADR 0007) : chaque conseil porte sa version (ADVICE_VERSION).
|
||||
|
||||
import type { CubeAction, CubeEquities, Ply, Skill } from "./engine.js";
|
||||
import type { CubeAction, CubeEquities, MoveNotation, Ply, Skill } from "./engine.js";
|
||||
import type { HalfMove, PositionId } from "./position.js";
|
||||
import type { Roll } from "./roll.js";
|
||||
|
||||
/** Version du format d'un conseil enregistré. Un conseil d'une autre
|
||||
* version est ignoré à la reprise (il peut être recalculé). */
|
||||
export const ADVICE_VERSION = 1;
|
||||
* version est ignoré à la reprise (il peut être recalculé). Version 2 : le
|
||||
* classement des coups vient de l'analyse du coup joué, et non plus d'un
|
||||
* second classement comme la commande « hint » de gnubg. */
|
||||
export const ADVICE_VERSION = 2;
|
||||
|
||||
/**
|
||||
* Le conseil du maître sur une décision du joueur : un coup ou une décision
|
||||
@@ -29,7 +31,7 @@ export type Advice = MoveAdvice | CubeAdvice;
|
||||
/** Un coup, tel que le conseil le retient. */
|
||||
export interface AdvisedMove {
|
||||
/** Notation de gnubg, par exemple « 8/5 6/5 ». */
|
||||
readonly notation: string;
|
||||
readonly notation: MoveNotation;
|
||||
readonly halfMoves: readonly HalfMove[];
|
||||
/** Équité (avec videau) du point de vue du joueur, et le niveau auquel
|
||||
* le moteur l'a évaluée. */
|
||||
@@ -66,8 +68,8 @@ export interface MoveAdvice {
|
||||
readonly best: AdvisedMove;
|
||||
readonly playedFacts: MoveFacts;
|
||||
readonly bestFacts: MoveFacts;
|
||||
/** Les meilleurs coups du classement du moteur, du meilleur au moins
|
||||
* bon (cinq au plus). */
|
||||
/** Les meilleurs coups du classement de l'analyse, du meilleur au
|
||||
* moins bon (cinq au plus), chacun au niveau où le moteur l'a évalué. */
|
||||
readonly ranking: readonly AdvisedMove[];
|
||||
/** Équité du coup joué moins celle du meilleur : 0 ou négative. */
|
||||
readonly equityLoss: number;
|
||||
|
||||
@@ -80,17 +80,6 @@ export interface Engine {
|
||||
ply: Ply,
|
||||
): Promise<MoveAnalysis | null>;
|
||||
|
||||
/** Les coups légaux d'un jet classés du meilleur au moins bon, au
|
||||
* niveau ply, comme la commande « hint » de gnubg (les coups écartés
|
||||
* par ses filtres gardent l'évaluation d'un niveau inférieur, voir leur
|
||||
* ply). */
|
||||
rankMoves(
|
||||
position: PositionId,
|
||||
cube: EngineCube,
|
||||
roll: Roll,
|
||||
ply: Ply,
|
||||
): Promise<EvaluatedMove[]>;
|
||||
|
||||
/** L'analyse d'une décision de videau, position vue du joueur au trait
|
||||
* (celui qui double ou non ; take et pass sont la réponse de son
|
||||
* adversaire). */
|
||||
@@ -222,6 +211,12 @@ export interface MoveAnalysis {
|
||||
/** Équité du coup joué moins celle du meilleur : 0 ou négative. */
|
||||
readonly equityLoss: number;
|
||||
readonly skill: Skill;
|
||||
/** Le classement de l'analyse, du meilleur au moins bon (dix coups au
|
||||
* plus) : la liste de l'analyse de partie de gnubg, chaque coup au
|
||||
* niveau où gnubg l'a évalué (ply). Les coups écartés par ses filtres
|
||||
* gardent l'évaluation d'un niveau inférieur ; le meilleur coup et le
|
||||
* coup joué y ont les évaluations de best et played. */
|
||||
readonly ranking: readonly EvaluatedMove[];
|
||||
}
|
||||
|
||||
/** Décision de videau : doubler ou non (joueur au trait), prendre ou passer
|
||||
@@ -281,6 +276,15 @@ export interface EngineMatchAfter {
|
||||
readonly crawford: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Un coup écrit dans la notation de gnubg : « 8/5 6/5 », « 24/22* »,
|
||||
* « 6/off », « bar/22 », « 8/4(2) ». Le moteur l'écrit ; l'application ne
|
||||
* fait que l'afficher.
|
||||
*
|
||||
* Glossaire : docs/glossaire/notation-des-coups.md
|
||||
*/
|
||||
export type MoveNotation = string;
|
||||
|
||||
/**
|
||||
* Un coup légal complet.
|
||||
*
|
||||
@@ -288,7 +292,7 @@ export interface EngineMatchAfter {
|
||||
*/
|
||||
export interface Move {
|
||||
/** Notation de gnubg, par exemple « 8/5 6/5 ». */
|
||||
readonly notation: string;
|
||||
readonly notation: MoveNotation;
|
||||
/** Ses demi-coups, dans un ordre jouable. */
|
||||
readonly halfMoves: readonly HalfMove[];
|
||||
/** La position après le coup, vue du joueur qui vient de jouer. */
|
||||
|
||||
@@ -12,7 +12,15 @@
|
||||
import type { Advice } from "./advice.js";
|
||||
import { type Branch, branches, switchToBranch } from "./branches.js";
|
||||
import { CENTERED_CUBE, type Cube } from "./cube.js";
|
||||
import type { BoardSide, Engine, EngineCube, Level, Ply, ResultKind } from "./engine.js";
|
||||
import type {
|
||||
BoardSide,
|
||||
Engine,
|
||||
EngineCube,
|
||||
Level,
|
||||
MoveNotation,
|
||||
Ply,
|
||||
ResultKind,
|
||||
} from "./engine.js";
|
||||
import {
|
||||
appendEvent,
|
||||
currentLine,
|
||||
@@ -290,7 +298,7 @@ export class Game {
|
||||
* À défaut (aucun coup légal ne correspond), les demi-coups ; chaîne
|
||||
* vide si l'événement n'est pas un coup ou si aucun pion n'a bougé.
|
||||
*/
|
||||
async moveNotation(eventId: string): Promise<string> {
|
||||
async moveNotation(eventId: string): Promise<MoveNotation> {
|
||||
const line = currentLine({ ...this.#journal, head: eventId });
|
||||
const event = line.at(-1);
|
||||
if (event?.type !== "MovePlayed" || event.halfMoves.length === 0) {
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
// 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;
|
||||
}
|
||||
@@ -20,7 +20,13 @@ import {
|
||||
type ReviewedDecision,
|
||||
summarizeAnalysis,
|
||||
} from "../analysis/game-analysis.js";
|
||||
import { formatEquity, formatPercent, rankingRows } from "../analysis/numbers.js";
|
||||
import {
|
||||
formatEquity,
|
||||
formatPercent,
|
||||
pliesText,
|
||||
rankingPly,
|
||||
rankingRows,
|
||||
} from "../analysis/numbers.js";
|
||||
import { diceWords, type MasterPhrases, masterWords } from "../analysis/phrases.js";
|
||||
import {
|
||||
type Advice,
|
||||
@@ -83,7 +89,7 @@ const plural = (n: number, one: string, many: string) => `${n} ${n > 1 ? many :
|
||||
|
||||
/** « analyse à 2 plis ». */
|
||||
export function plyText(ply: Ply): string {
|
||||
return `analyse à ${ply} ${ply > 1 ? "plis" : "pli"}`;
|
||||
return `analyse à ${pliesText(ply)}`;
|
||||
}
|
||||
|
||||
/** Nombre de décisions analysées à partir duquel le niveau du joueur se
|
||||
@@ -146,8 +152,14 @@ export function decisionPrompt(
|
||||
niveau_eval: plyText(data.advice.ply),
|
||||
appreciation: masterWords(phrases, data.advice).word,
|
||||
};
|
||||
// Le niveau d'un classement de coups : celui de son meilleur coup, que
|
||||
// le moteur a pu évaluer moins profondément que demandé.
|
||||
return data.advice.kind === "move"
|
||||
? fillPrompt(templates.move, { ...common, ...moveValues(data.advice) })
|
||||
? fillPrompt(templates.move, {
|
||||
...common,
|
||||
niveau_eval: plyText(rankingPly(data.advice)),
|
||||
...moveValues(data.advice),
|
||||
})
|
||||
: fillPrompt(templates.cube, { ...common, ...cubeValues(data.advice) });
|
||||
}
|
||||
|
||||
@@ -163,7 +175,10 @@ function moveValues(advice: MoveAdvice): Record<string, string> {
|
||||
// de « Voir les chiffres du moteur », avec les mêmes chiffres.
|
||||
const rows = rankingRows(advice).filter((row) => row.rank <= 3 || row.played);
|
||||
const ranking = rows.map((row) => {
|
||||
const ply = row.move.ply === advice.ply ? "" : `, évalué à ${row.move.ply} pli`;
|
||||
// Un coup écarté par les filtres du moteur garde l'évaluation d'un
|
||||
// niveau inférieur à celui du classement : la ligne le dit.
|
||||
const ply =
|
||||
row.move.ply === rankingPly(advice) ? "" : `, évalué à ${pliesText(row.move.ply)}`;
|
||||
const mine = row.played ? " (mon coup)" : "";
|
||||
return `${row.rank}. ${row.move.notation}${mine} : équité ${formatEquity(row.move.equity)}, écart ${formatEquity(row.loss)}${ply}`;
|
||||
});
|
||||
@@ -431,6 +446,42 @@ export async function exercisesPosition(
|
||||
return worst === undefined ? null : positionBefore(engine, worst.journal, worst.advice.eventId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ce qui revient dans les imprécisions et erreurs de plusieurs parties,
|
||||
* compté sur les faits que le moteur donne de chaque coup (pions frappés,
|
||||
* points faits, lus sur les plateaux qu'il décode) : jamais une
|
||||
* explication stratégique. Une ligne par fait présent au moins une fois.
|
||||
*/
|
||||
export function mistakeThemes(mistakes: readonly { readonly advice: Advice }[]): string[] {
|
||||
const moves = mistakes.flatMap((m) => (m.advice.kind === "move" ? [m.advice] : []));
|
||||
const cubes = mistakes.length - moves.length;
|
||||
const times = (n: number) => `${n} fois`;
|
||||
const masterHits = moves.filter((a) => a.bestFacts.hits > 0 && a.playedFacts.hits === 0);
|
||||
const masterPoints = moves.filter((a) =>
|
||||
a.bestFacts.madePoints.some((p) => !a.playedFacts.madePoints.includes(p)),
|
||||
);
|
||||
const playerHits = moves.filter((a) => a.playedFacts.hits > 0 && a.bestFacts.hits === 0);
|
||||
const lines = [
|
||||
`- ${plural(moves.length, "coup", "coups")} et ${plural(cubes, "décision de videau", "décisions de videau")}`,
|
||||
];
|
||||
if (masterHits.length > 0) {
|
||||
lines.push(
|
||||
`- ${times(masterHits.length)}, le coup de GNU Backgammon frappait un pion et le mien non`,
|
||||
);
|
||||
}
|
||||
if (masterPoints.length > 0) {
|
||||
lines.push(
|
||||
`- ${times(masterPoints.length)}, le coup de GNU Backgammon faisait un point que le mien ne faisait pas`,
|
||||
);
|
||||
}
|
||||
if (playerHits.length > 0) {
|
||||
lines.push(
|
||||
`- ${times(playerHits.length)}, mon coup frappait un pion et celui de GNU Backgammon non`,
|
||||
);
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
/** Ce que demande le prompt des exercices. */
|
||||
export interface ExercisesPromptData {
|
||||
/** Les parties analysées, de la plus ancienne à la plus récente. */
|
||||
@@ -469,6 +520,7 @@ export function exercisesPrompt(
|
||||
niveau_eval: plyText(worst.advice.ply),
|
||||
parties: String(data.journals.length),
|
||||
erreurs: listed.join("\n"),
|
||||
themes: mistakeThemes(mistakes).join("\n"),
|
||||
position_titre: `La plus coûteuse (partie ${worst.game}, coup ${worst.turn}) : la position avant ma décision`,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -182,6 +182,7 @@ export function createWasmEngine(run: RunCommand): Engine {
|
||||
best: CliEvaluatedMove | null;
|
||||
equity_loss: number;
|
||||
skill: CliSkill;
|
||||
ranking: CliEvaluatedMove[];
|
||||
};
|
||||
if (answer.rank === null || answer.played === null || answer.best === null) {
|
||||
return null;
|
||||
@@ -193,17 +194,10 @@ export function createWasmEngine(run: RunCommand): Engine {
|
||||
best: toEvaluatedMove(answer.best),
|
||||
equityLoss: answer.equity_loss,
|
||||
skill: toSkill(answer.skill),
|
||||
ranking: answer.ranking.map(toEvaluatedMove),
|
||||
};
|
||||
},
|
||||
|
||||
async rankMoves(position: PositionId, cube: EngineCube, roll: Roll, ply: Ply) {
|
||||
const args = ["--ply", String(ply), "moves", position, await matchId(cube)];
|
||||
const answer = (await run([...args, diceText(roll)])) as {
|
||||
moves: CliEvaluatedMove[];
|
||||
};
|
||||
return answer.moves.map(toEvaluatedMove);
|
||||
},
|
||||
|
||||
async analyseCube(
|
||||
position: PositionId,
|
||||
cube: EngineCube,
|
||||
|
||||
+12
-3
@@ -18,7 +18,7 @@ import {
|
||||
tutorialBoard,
|
||||
} from "./domain/learning/tutorial.js";
|
||||
import { positionBefore } from "./domain/prompts/position.js";
|
||||
import { conceptExample, summaryPosition } from "./domain/prompts/prompts.js";
|
||||
import { conceptExample, exercisesPosition, summaryPosition } from "./domain/prompts/prompts.js";
|
||||
import { createWasmEngine } from "./engine/wasm-engine.js";
|
||||
import { type EngineWorker, startEngineWorker } from "./engine/worker-run.js";
|
||||
import { createGameSession } from "./storage/game-session.js";
|
||||
@@ -65,7 +65,7 @@ const settings = createSettingsStore(localStorage ?? memoryStorage());
|
||||
// pendant que le moteur se charge : le plateau est dessiné sans les
|
||||
// attendre. L'écran de jeu ne s'en sert qu'une fois engineReady résolue.
|
||||
const opening = (async () => {
|
||||
const { library } = await openGameLibrary({
|
||||
const { library, lessons } = await openGameLibrary({
|
||||
indexedDB: attempt(() => window.indexedDB),
|
||||
localStorage,
|
||||
});
|
||||
@@ -76,7 +76,7 @@ const opening = (async () => {
|
||||
resume: (journal) => Game.resume(engine, journal),
|
||||
});
|
||||
const saved = await session.restore();
|
||||
return { library, session, saved };
|
||||
return { library, session, saved, lessons };
|
||||
})();
|
||||
let stored: Awaited<typeof opening> | null = null;
|
||||
/** Le stockage, ouvert (toujours le cas une fois engineReady résolue). */
|
||||
@@ -154,6 +154,14 @@ startApp({
|
||||
MovePractice.start(engine, position, roll, expected),
|
||||
shouldTake: (position) => shouldTake(engine, position),
|
||||
},
|
||||
// La progression dans les leçons (étape 1g), dans le stockage des
|
||||
// parties : lue une fois ouvert, enregistrée sans être attendue.
|
||||
lessons: {
|
||||
load: async () => (await opening).lessons.load(),
|
||||
save: (progress) => {
|
||||
void opening.then((opened) => opened.lessons.save(progress));
|
||||
},
|
||||
},
|
||||
// Hors ligne : le service worker met tout le site en cache. Le navigateur
|
||||
// le refuse hors https (sauf localhost) : l'application marche alors sans.
|
||||
// Il est enregistré une fois le moteur chargé : si le Worker et le service
|
||||
@@ -172,6 +180,7 @@ startApp({
|
||||
before: (journal, eventId) => positionBefore(engine, journal, eventId),
|
||||
summary: (journal) => summaryPosition(engine, journal),
|
||||
concept: (journal) => conceptExample(engine, journal, ANALYSIS_PLY),
|
||||
exercises: (journals) => exercisesPosition(engine, journals),
|
||||
},
|
||||
startUpdates: (onAvailable) =>
|
||||
startUpdates(new URL("../sw.js", import.meta.url), worker.ready, onAvailable),
|
||||
|
||||
@@ -59,6 +59,10 @@ 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,7 +6,8 @@
|
||||
// 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 »).
|
||||
// - settings : { key, value }, dont la partie en cours (key « current ») et
|
||||
// la progression dans les leçons (key « lessons », étape 1g).
|
||||
// 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.
|
||||
|
||||
@@ -19,6 +20,9 @@ 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> {
|
||||
@@ -77,5 +81,11 @@ 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,6 +19,8 @@ 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";
|
||||
@@ -60,6 +62,8 @@ 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)),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
// 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,13 +4,19 @@
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
|
||||
import { createGameLibrary, type GameLibrary, type LibraryOptions } from "./game-library.js";
|
||||
import {
|
||||
createGameLibrary,
|
||||
type GameLibrary,
|
||||
type LibraryBackend,
|
||||
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 {
|
||||
@@ -23,6 +29,8 @@ 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
|
||||
@@ -55,7 +63,7 @@ async function open(storage: BrowserStorage, options: LibraryOptions): Promise<O
|
||||
timer = setTimeout(() => reject(new Error("IndexedDB muet")), OPEN_TIMEOUT_MS);
|
||||
}),
|
||||
]);
|
||||
return { library: createGameLibrary(backend, options), kind: "indexeddb" };
|
||||
return opened(backend, options, "indexeddb");
|
||||
} catch {
|
||||
// Indisponible (navigation privée de certains navigateurs) : repli.
|
||||
} finally {
|
||||
@@ -63,14 +71,21 @@ async function open(storage: BrowserStorage, options: LibraryOptions): Promise<O
|
||||
}
|
||||
}
|
||||
if (storage.localStorage !== undefined && usable(storage.localStorage)) {
|
||||
return {
|
||||
library: createGameLibrary(createKeyValueBackend(storage.localStorage), options),
|
||||
kind: "localStorage",
|
||||
};
|
||||
return opened(createKeyValueBackend(storage.localStorage), options, "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(createKeyValueBackend(memoryStorage()), options),
|
||||
kind: "memory",
|
||||
library: createGameLibrary(backend, options),
|
||||
kind,
|
||||
lessons: createLessonStore(backend),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -26,8 +26,8 @@ export const ADVICE_TEXTS = {
|
||||
replay: "Rejouer le coup",
|
||||
numbers: "Voir les chiffres du moteur",
|
||||
you: "(vous)",
|
||||
moveNote: (ply: number) =>
|
||||
`Écart d'équité à ${ply} plis, par rapport au meilleur coup. Un écart de 0,03 ou moins reste un bon coup.`,
|
||||
moveNote: (plies: string) =>
|
||||
`Écart d'équité à ${plies}, par rapport au meilleur coup. Un écart de 0,03 ou moins reste un bon coup.`,
|
||||
cubeNote: (ply: number) =>
|
||||
`Équités de celui qui double, pour un videau de 1, à ${ply} plis. Écart de votre décision : `,
|
||||
cubeRows: {
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
|
||||
import { rankingRows } from "../domain/analysis/numbers.js";
|
||||
import { pliesText, rankingPly, rankingRows } from "../domain/analysis/numbers.js";
|
||||
import { type MasterPhrases, masterWords } from "../domain/analysis/phrases.js";
|
||||
import {
|
||||
type Advice,
|
||||
@@ -21,6 +21,7 @@ import { ADVICE_TEXTS, CUBE_GLOSSARY, formatEquity } from "./advice-texts.js";
|
||||
import { askAiLink, clearSheet } from "./ask-ai.js";
|
||||
import { type IconName, icon } from "./icons.js";
|
||||
import { boardSvg, createMovePlayer, drawSteps, type MovePlayer } from "./move-board.js";
|
||||
import { showWithNotation } from "./notation.js";
|
||||
import { routeHash } from "./router.js";
|
||||
|
||||
/** Préfixe des identifiants du plateau de la feuille. */
|
||||
@@ -203,8 +204,9 @@ export function createAdviceView(options: AdviceViewOptions): AdviceView {
|
||||
const svg = sheetBoard(boardBox);
|
||||
if (best === null) {
|
||||
svg.dataset.move = "mine";
|
||||
svg.setAttribute("aria-label", ADVICE_TEXTS.legendSame(advice.played.notation));
|
||||
legend.textContent = ADVICE_TEXTS.legendSame(advice.played.notation);
|
||||
const text = ADVICE_TEXTS.legendSame(advice.played.notation);
|
||||
svg.setAttribute("aria-label", text);
|
||||
showWithNotation(legend, text, advice.played.notation);
|
||||
void player?.play(mine);
|
||||
return;
|
||||
}
|
||||
@@ -233,7 +235,7 @@ export function createAdviceView(options: AdviceViewOptions): AdviceView {
|
||||
shown === "mine"
|
||||
? ADVICE_TEXTS.legendMine(notation)
|
||||
: ADVICE_TEXTS.legendBest(notation);
|
||||
legend.textContent = text;
|
||||
showWithNotation(legend, text, notation);
|
||||
svg.setAttribute("aria-label", text);
|
||||
svg.dataset.move = shown;
|
||||
void player?.play(shown === "mine" ? mine : best);
|
||||
@@ -302,7 +304,7 @@ export function createAdviceView(options: AdviceViewOptions): AdviceView {
|
||||
}
|
||||
element(details, "p", {
|
||||
className: "explain-note",
|
||||
text: ADVICE_TEXTS.moveNote(advice.ply),
|
||||
text: ADVICE_TEXTS.moveNote(pliesText(rankingPly(advice))),
|
||||
});
|
||||
} else {
|
||||
// Les lignes de votre décision, en gras : doubler mène à l'une des
|
||||
|
||||
+460
-41
@@ -34,8 +34,14 @@ import {
|
||||
masterWords,
|
||||
parsePhrases,
|
||||
} from "../domain/analysis/phrases.js";
|
||||
import {
|
||||
type FinishedGame,
|
||||
PROGRESSION_MAX_GAMES,
|
||||
progressionEntries,
|
||||
} from "../domain/analysis/progression.js";
|
||||
import type { Advice } from "../domain/game/advice.js";
|
||||
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";
|
||||
@@ -43,6 +49,15 @@ import { type PositionLink, type SharedPosition, setupOf } from "../domain/game/
|
||||
import type { SavedGameSummary } from "../domain/game/saved-game.js";
|
||||
import { otherSide, type Side } from "../domain/game/side.js";
|
||||
import type { GameState } from "../domain/game/state.js";
|
||||
import {
|
||||
finishedLessons,
|
||||
LESSON_END,
|
||||
type LessonProgress,
|
||||
lessonRecord,
|
||||
NO_PROGRESS,
|
||||
withSolved,
|
||||
withVisit,
|
||||
} from "../domain/learning/lessons.js";
|
||||
import { ADVICE_TEXTS } from "./advice-texts.js";
|
||||
import { type Analyse, createAdviceTracker } from "./advice-tracker.js";
|
||||
import { createAdviceView, VERDICT_ICONS } from "./advice-view.js";
|
||||
@@ -81,9 +96,15 @@ 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";
|
||||
import { showWithNotation } from "./notation.js";
|
||||
import { createProgressionView } from "./progression-view.js";
|
||||
import { createPromptSource, type PromptsAccess } from "./prompt-source.js";
|
||||
import { createReviewRunner, type RunAnalysis } from "./review-runner.js";
|
||||
import {
|
||||
@@ -104,8 +125,21 @@ import {
|
||||
} from "./settings.js";
|
||||
import { createSummaryView } from "./summary-view.js";
|
||||
import { type PromptText, TEXTS } from "./texts.js";
|
||||
import { seenAfter } from "./tutorial-nav.js";
|
||||
import { createTutorialView, type TutorialAccess } from "./tutorial-view.js";
|
||||
import {
|
||||
lessonPlaceOf,
|
||||
lessonPlaceRoute,
|
||||
seenAfter,
|
||||
placeOf as tutorialPlaceOf,
|
||||
placeRoute as tutorialPlaceRoute,
|
||||
} from "./tutorial-nav.js";
|
||||
import { parseTutorial, type Tutorial } from "./tutorial-text.js";
|
||||
import {
|
||||
type Course,
|
||||
cachedText,
|
||||
createTutorialView,
|
||||
TUTORIAL_SOURCE,
|
||||
type TutorialAccess,
|
||||
} from "./tutorial-view.js";
|
||||
import { betweenTurns, type Updates, updateBannerShown } from "./updates.js";
|
||||
|
||||
/** Glissement d'un pion de l'ordinateur, et pause entre deux pions
|
||||
@@ -145,8 +179,15 @@ export interface AppOptions {
|
||||
/** Les réglages, en JSON (null : jamais enregistrés). */
|
||||
readonly loadSettings: () => string | null;
|
||||
readonly saveSettings: (json: string) => void;
|
||||
/** Le tutoriel : plateaux, coups illustrés, gestes jugés par le moteur. */
|
||||
/** Le tutoriel : plateaux, coups illustrés, gestes jugés par le moteur ;
|
||||
* les leçons s'en servent aussi. */
|
||||
readonly tutorial: TutorialAccess;
|
||||
/** La progression dans les leçons (étape 1g), dans le stockage des
|
||||
* parties. */
|
||||
readonly lessons: {
|
||||
readonly load: () => Promise<LessonProgress>;
|
||||
readonly save: (progress: LessonProgress) => void;
|
||||
};
|
||||
/** Les mises à jour de l'application (service worker) : onAvailable est
|
||||
* appelé quand une nouvelle version attend. */
|
||||
readonly startUpdates: (onAvailable: () => void) => Updates;
|
||||
@@ -247,6 +288,7 @@ export function startApp(options: AppOptions): void {
|
||||
summaryVeil: byId("summary-veil"),
|
||||
promptVeil: byId("prompt-veil"),
|
||||
analysis: byId("analysis"),
|
||||
progression: byId("progression"),
|
||||
menuAdvice: byId<HTMLButtonElement>("menu-advice"),
|
||||
prompt: byId("prompt"),
|
||||
strips: { computer: stripOf("strip-computer"), player: stripOf("strip-player") },
|
||||
@@ -306,8 +348,11 @@ export function startApp(options: AppOptions): void {
|
||||
resumeOld: byId<HTMLButtonElement>("resume-old"),
|
||||
home: byId("home"),
|
||||
tutorial: byId("tutorial"),
|
||||
lessons: byId("lessons"),
|
||||
lesson: byId("lesson"),
|
||||
menuHome: byId<HTMLAnchorElement>("menu-home"),
|
||||
menuTutorial: byId<HTMLAnchorElement>("menu-tutorial"),
|
||||
menuLessons: byId<HTMLAnchorElement>("menu-lessons"),
|
||||
menuInstall: byId<HTMLButtonElement>("menu-install"),
|
||||
menuUpdate: byId<HTMLButtonElement>("menu-update"),
|
||||
installVeil: byId("install-veil"),
|
||||
@@ -405,6 +450,7 @@ export function startApp(options: AppOptions): void {
|
||||
ui.resumeOld.textContent = TEXTS.resumeOld;
|
||||
label(ui.menuHome, HOME_TEXTS.homeLink, "home");
|
||||
label(ui.menuTutorial, HOME_TEXTS.tutorialMenu, "rules");
|
||||
label(ui.menuLessons, LESSON_TEXTS.menu, "lessons");
|
||||
label(ui.menuInstall, HOME_TEXTS.installMenu, "install");
|
||||
label(ui.menuUpdate, HOME_TEXTS.updateCheck, "replay");
|
||||
ui.installTitle.textContent = HOME_TEXTS.installTitle;
|
||||
@@ -443,6 +489,10 @@ export function startApp(options: AppOptions): void {
|
||||
let stuck = false;
|
||||
let busy = true;
|
||||
let info: string = TEXTS.loading;
|
||||
// La dernière ligne « L'ordinateur a joué … » et le coup qu'elle cite :
|
||||
// tant qu'elle est la ligne d'information, ce coup mène à la fiche
|
||||
// « Notation des coups ».
|
||||
let computerLine: ComputerTurnLine | null = null;
|
||||
let engineFailed = false;
|
||||
// Le dernier coup de l'ordinateur, montré (traces, « Revoir ») jusqu'à
|
||||
// la prochaine action du joueur ; null : rien à montrer.
|
||||
@@ -646,7 +696,11 @@ export function startApp(options: AppOptions): void {
|
||||
} else if (computerCouldNotMove(game)) {
|
||||
ui.info.textContent = TEXTS.computerCannotMove(rollBefore(game));
|
||||
} else {
|
||||
ui.info.textContent = info;
|
||||
showWithNotation(
|
||||
ui.info,
|
||||
info,
|
||||
computerLine?.text === info ? computerLine.notation : null,
|
||||
);
|
||||
}
|
||||
ui.prompt.textContent = panelShown
|
||||
? ""
|
||||
@@ -1037,7 +1091,8 @@ export function startApp(options: AppOptions): void {
|
||||
while (game !== null && game.state.turn === "computer" && isPlaying(game)) {
|
||||
await refresh();
|
||||
await game.playComputerTurn();
|
||||
info = await describeComputerTurn(game);
|
||||
computerLine = await describeComputerTurn(game);
|
||||
info = computerLine.text;
|
||||
save();
|
||||
played = true;
|
||||
}
|
||||
@@ -1419,6 +1474,18 @@ export function startApp(options: AppOptions): void {
|
||||
}
|
||||
case "glossary":
|
||||
return promptSource.concept(route.term ?? "", game?.journal ?? null);
|
||||
case "progression": {
|
||||
// Les parties analysées de l'écran, de la plus ancienne à la
|
||||
// plus récente : rien n'est analysé pour le prompt.
|
||||
const games = progressionGames ?? (await loadProgression());
|
||||
const journals = [...games]
|
||||
.sort((a, b) => a.startedAt - b.startedAt)
|
||||
.map((g) => g.journal)
|
||||
.filter((journal) => readyForSummary(journal));
|
||||
return journals.length === 0
|
||||
? { message: ASK_AI_TEXTS.noAnalysedGame }
|
||||
: promptSource.exercises(journals);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1456,20 +1523,143 @@ export function startApp(options: AppOptions): void {
|
||||
if (missingAnalyses(journal).length === 0) {
|
||||
return;
|
||||
}
|
||||
reviewRunner.start(id, journal, (analysed) => {
|
||||
const live = liveGame(id);
|
||||
if (live !== null) {
|
||||
live.recordAnalyses(analysed);
|
||||
save();
|
||||
} else {
|
||||
options.games.saveAnalyses(id, analysed);
|
||||
}
|
||||
if (reviewed?.id === id) {
|
||||
reviewed = { ...reviewed, journal: live?.journal ?? analysed };
|
||||
}
|
||||
showReview(id, false);
|
||||
render();
|
||||
});
|
||||
reviewRunner.start(
|
||||
id,
|
||||
journal,
|
||||
(analysed) => {
|
||||
const live = liveGame(id);
|
||||
if (live !== null) {
|
||||
live.recordAnalyses(analysed);
|
||||
save();
|
||||
} else {
|
||||
options.games.saveAnalyses(id, analysed);
|
||||
}
|
||||
if (reviewed?.id === id) {
|
||||
reviewed = { ...reviewed, journal: live?.journal ?? analysed };
|
||||
}
|
||||
progressionAnalysed(id, live?.journal ?? analysed);
|
||||
showReview(id, false);
|
||||
render();
|
||||
},
|
||||
// L'écran de progression attend peut-être cette analyse pour
|
||||
// lancer celle de la partie suivante.
|
||||
() => reviewProgression(),
|
||||
);
|
||||
}
|
||||
|
||||
// ------------------------------------------------ Progression (1h)
|
||||
|
||||
// L'écran #/progression : les parties terminées (les plus récentes),
|
||||
// relues à chaque visite, avec leurs analyses rangées. Celles qui n'en
|
||||
// ont pas encore sont analysées en arrière-plan, une à une, la plus
|
||||
// récente d'abord ; rien n'est recalculé pour les autres.
|
||||
const progressionView = createProgressionView({ section: ui.progression });
|
||||
let progressionGames: FinishedGame[] | null = null;
|
||||
// Chaque lecture a son numéro : une lecture dépassée n'est pas dessinée.
|
||||
let progressionRequest = 0;
|
||||
// Les parties dont l'analyse a été lancée depuis l'ouverture de l'écran.
|
||||
const progressionTried = new Set<string>();
|
||||
|
||||
/** Les parties terminées les plus récentes, avec leur journal (celui
|
||||
* de la partie à l'écran, s'il s'agit d'elle). */
|
||||
async function loadProgression(): Promise<FinishedGame[]> {
|
||||
const list = await options.games.list();
|
||||
const over = list
|
||||
.filter((summary) => summary.status === "over")
|
||||
.sort((a, b) => b.startedAt - a.startedAt)
|
||||
.slice(0, PROGRESSION_MAX_GAMES);
|
||||
const games = await Promise.all(
|
||||
over.map(async (summary) => {
|
||||
const journal =
|
||||
liveGame(summary.id)?.journal ?? (await options.games.journal(summary.id));
|
||||
return journal === null
|
||||
? null
|
||||
: {
|
||||
id: summary.id,
|
||||
startedAt: summary.startedAt,
|
||||
level: summary.level,
|
||||
result: summary.result,
|
||||
journal,
|
||||
};
|
||||
}),
|
||||
);
|
||||
return games.filter((g): g is FinishedGame => g !== null);
|
||||
}
|
||||
|
||||
/** Ouvre l'écran : les parties relues à chaque visite (une partie a pu
|
||||
* finir entre-temps) ; d'ici là, l'écran dit qu'il les lit, sans
|
||||
* montrer la liste de la visite précédente comme si elle était à
|
||||
* jour. */
|
||||
function showProgression(): void {
|
||||
const request = ++progressionRequest;
|
||||
progressionTried.clear();
|
||||
progressionGames = null;
|
||||
renderProgression();
|
||||
options.engineReady
|
||||
.then(() => loadProgression())
|
||||
.then(
|
||||
(games) => {
|
||||
if (request !== progressionRequest) {
|
||||
return;
|
||||
}
|
||||
progressionGames = games;
|
||||
renderProgression();
|
||||
reviewProgression();
|
||||
},
|
||||
() => {
|
||||
if (request === progressionRequest && progressionGames === null) {
|
||||
progressionView.render(null, true);
|
||||
}
|
||||
},
|
||||
);
|
||||
if (phrases === null) {
|
||||
phrasesLoading.then(
|
||||
() => renderProgression(),
|
||||
() => undefined,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function renderProgression(): void {
|
||||
if (navigation.route.screen !== "progression") {
|
||||
return;
|
||||
}
|
||||
progressionView.render(
|
||||
progressionGames === null
|
||||
? null
|
||||
: { entries: progressionEntries(progressionGames), phrases },
|
||||
);
|
||||
}
|
||||
|
||||
/** Une analyse rangée : la partie, si l'écran la montre, se met à
|
||||
* jour. */
|
||||
function progressionAnalysed(id: string, journal: GameJournal): void {
|
||||
if (progressionGames?.some((g) => g.id === id)) {
|
||||
progressionGames = progressionGames.map((g) => (g.id === id ? { ...g, journal } : g));
|
||||
renderProgression();
|
||||
}
|
||||
}
|
||||
|
||||
/** Lance l'analyse de la prochaine partie de l'écran qui n'est pas
|
||||
* analysée (rien si une analyse tourne déjà : à sa fin, elle rappelle
|
||||
* cette fonction). */
|
||||
function reviewProgression(): void {
|
||||
if (
|
||||
navigation.route.screen !== "progression" ||
|
||||
progressionGames === null ||
|
||||
reviewRunner.running !== null
|
||||
) {
|
||||
return;
|
||||
}
|
||||
// Une partie dont l'analyse a échoué n'est pas relancée sans fin :
|
||||
// une fois par visite de l'écran.
|
||||
const next = progressionGames.find(
|
||||
(g) => !progressionTried.has(g.id) && missingAnalyses(g.journal).length > 0,
|
||||
);
|
||||
if (next !== undefined) {
|
||||
progressionTried.add(next.id);
|
||||
review(next.id, next.journal);
|
||||
}
|
||||
}
|
||||
|
||||
/** L'analyse en cours d'une partie qui n'est ni regardée ni à l'écran
|
||||
@@ -1529,25 +1719,37 @@ export function startApp(options: AppOptions): void {
|
||||
* La partie à l'écran vient de finir : son analyse part en
|
||||
* arrière-plan, et le bilan remplace le panneau de fin de partie dès
|
||||
* qu'il est prêt (charte 1e), si le panneau est toujours là.
|
||||
*
|
||||
* Le bilan s'ouvre un peu plus tard (microtâche) : seulement si cette
|
||||
* partie finie est encore celle de l'écran. Sinon, « Commencer » de
|
||||
* l'écran « Nouvelle partie » ouvrait le bilan de l'ancienne partie
|
||||
* par-dessus la nouvelle, qui venait de commencer entre-temps (défaut
|
||||
* signalé le 2026-11-11).
|
||||
*/
|
||||
function reviewFinishedGame(): void {
|
||||
const id = options.games.shownId();
|
||||
if (game === null || id === null || busy || game.state.phase !== "over") {
|
||||
const finished = game;
|
||||
if (finished === null || id === null || busy || finished.state.phase !== "over") {
|
||||
return;
|
||||
}
|
||||
// L'analyse d'une autre partie, regardée, passe d'abord.
|
||||
const running = reviewRunner.running;
|
||||
if (running === null || running === id) {
|
||||
review(id, game.journal);
|
||||
review(id, finished.journal);
|
||||
}
|
||||
if (
|
||||
navigation.route.screen === "game" &&
|
||||
!panelDismissed &&
|
||||
phrases !== null &&
|
||||
readyForSummary(game.journal)
|
||||
readyForSummary(finished.journal)
|
||||
) {
|
||||
queueMicrotask(() => {
|
||||
if (navigation.route.screen === "game" && !panelDismissed) {
|
||||
if (
|
||||
game === finished &&
|
||||
options.games.shownId() === id &&
|
||||
navigation.route.screen === "game" &&
|
||||
!panelDismissed
|
||||
) {
|
||||
navigation.go(routeHash({ screen: "summary", game: id }));
|
||||
}
|
||||
});
|
||||
@@ -1699,6 +1901,12 @@ 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");
|
||||
} else if (route.screen === "progression") {
|
||||
navigation.leave((r) => r.screen === "progression");
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -1804,6 +2012,7 @@ export function startApp(options: AppOptions): void {
|
||||
replacingLink(ui.menuHelp, navigation);
|
||||
replacingLink(ui.menuHome, navigation);
|
||||
replacingLink(ui.menuTutorial, navigation);
|
||||
replacingLink(ui.menuLessons, navigation);
|
||||
ui.menuClose.onclick = () => closeOverlays();
|
||||
ui.menuCopyLink.onclick = () => {
|
||||
const link = currentLink;
|
||||
@@ -1855,9 +2064,21 @@ export function startApp(options: AppOptions): void {
|
||||
const position = below.screen === "position";
|
||||
const home = below.screen === "home";
|
||||
const tutorial = below.screen === "tutorial" || below.screen === "tutorialEnd";
|
||||
const lessonList = below.screen === "lessons";
|
||||
const lesson = below.screen === "lesson" || below.screen === "lessonEnd";
|
||||
const choosing = below.screen === "newGame";
|
||||
const analysing = below.screen === "analysis";
|
||||
const page = help || position || home || tutorial || choosing || analysing;
|
||||
const progression = below.screen === "progression";
|
||||
const page =
|
||||
help ||
|
||||
position ||
|
||||
home ||
|
||||
tutorial ||
|
||||
lessonList ||
|
||||
lesson ||
|
||||
choosing ||
|
||||
analysing ||
|
||||
progression;
|
||||
const wasPage = ui.game.hidden;
|
||||
const wasScreen = ui.root.dataset.screen;
|
||||
ui.game.hidden = page;
|
||||
@@ -1865,13 +2086,19 @@ 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;
|
||||
ui.progression.hidden = !progression;
|
||||
const opening = choosing && ui.newGameSection.hidden;
|
||||
ui.newGameSection.hidden = !choosing;
|
||||
ui.root.classList.toggle("page-open", page);
|
||||
// Le tutoriel, le premier lancement et l'écran « Nouvelle partie »
|
||||
// ont leur propre en-tête.
|
||||
ui.root.classList.toggle("bare", tutorial || choosing || (home && isFirstLaunch(settings)));
|
||||
// Le tutoriel, les leçons, le premier lancement et l'écran « Nouvelle
|
||||
// partie » ont leur propre en-tête.
|
||||
ui.root.classList.toggle(
|
||||
"bare",
|
||||
tutorial || lesson || choosing || (home && isFirstLaunch(settings)),
|
||||
);
|
||||
if (opening) {
|
||||
newGameView.render(busy || game === null);
|
||||
newGameView.focus();
|
||||
@@ -1887,9 +2114,29 @@ 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;
|
||||
}
|
||||
// La progression se relit à chaque arrivée sur l'écran (pas en
|
||||
// ouvrant ou en fermant la feuille « Créer des exercices »).
|
||||
if (progression && wasScreen !== "progression") {
|
||||
showProgression();
|
||||
}
|
||||
|
||||
// La marche à suivre pour installer : une feuille, comme le menu.
|
||||
if (route.screen === "install" && ui.installVeil.hidden) {
|
||||
@@ -1950,7 +2197,7 @@ export function startApp(options: AppOptions): void {
|
||||
() => openReview(id),
|
||||
() => undefined,
|
||||
);
|
||||
} else {
|
||||
} else if (route.screen !== "progression") {
|
||||
stopForeignReview();
|
||||
}
|
||||
if (route.screen === "games") {
|
||||
@@ -2015,8 +2262,10 @@ export function startApp(options: AppOptions): void {
|
||||
welcomed: () => saveSettings(welcomed()),
|
||||
});
|
||||
|
||||
// L'écran « Nouvelle partie » : « Commencer » remplace son adresse par
|
||||
// celle de la partie (le retour du téléphone ne le rouvre pas).
|
||||
// L'écran « Nouvelle partie » : « Commencer » lance la partie, puis
|
||||
// remplace son adresse par celle de la partie (le retour du téléphone
|
||||
// ne le rouvre pas). Dans cet ordre : l'écran de jeu ne se redessine
|
||||
// jamais avec l'ancienne partie, finie, et son panneau de fin.
|
||||
const newGameView = createNewGameView({
|
||||
section: ui.newGameSection,
|
||||
settings: () => settings,
|
||||
@@ -2025,8 +2274,8 @@ export function startApp(options: AppOptions): void {
|
||||
if (busy || game === null) {
|
||||
return;
|
||||
}
|
||||
navigation.replace("#/");
|
||||
newGame(chosenGame(settings));
|
||||
navigation.replace("#/");
|
||||
},
|
||||
});
|
||||
leavingLink(newGameView.closeLink, (route) => route.screen === "newGame");
|
||||
@@ -2039,8 +2288,26 @@ export function startApp(options: AppOptions): void {
|
||||
sides: sides ?? options.cachedBoard()?.sides ?? null,
|
||||
tutorialSeen: settings.tutorialSeen,
|
||||
progress: homeProgress,
|
||||
lessons:
|
||||
lessonTexts === null || lessonProgress === null
|
||||
? null
|
||||
: {
|
||||
next: nextLessonCard(lessonTexts, lessonProgress),
|
||||
finished: finishedLessons(lessonProgress, lessonTexts.map(outlineOf)),
|
||||
total: lessonTexts.length,
|
||||
},
|
||||
busy: busy || game === null,
|
||||
});
|
||||
if (lessonTexts === null || lessonProgress === null) {
|
||||
lessonsReady().then(
|
||||
() => {
|
||||
if (navigation.route.screen === "home") {
|
||||
renderHome();
|
||||
}
|
||||
},
|
||||
() => undefined,
|
||||
);
|
||||
}
|
||||
if (homeProgress === null && !progressLoading) {
|
||||
progressLoading = true;
|
||||
// La liste vient du stockage, ouvert avec le moteur.
|
||||
@@ -2063,17 +2330,158 @@ export function startApp(options: AppOptions): void {
|
||||
}
|
||||
}
|
||||
|
||||
const tutorialView = createTutorialView({
|
||||
section: ui.tutorial,
|
||||
access: options.tutorial,
|
||||
engineReady: options.engineReady,
|
||||
const loadTutorial = cachedText(TUTORIAL_SOURCE, parseTutorial);
|
||||
const tutorialCourse: Course = {
|
||||
id: "tutorial",
|
||||
load: () => loadTutorial(),
|
||||
placeOf: tutorialPlaceOf,
|
||||
routeOf: (_, place) => tutorialPlaceRoute(place),
|
||||
seen: () => settings.tutorialSeen,
|
||||
visit: (place) => {
|
||||
visit: (_, place) => {
|
||||
const seen = seenAfter(settings.tutorialSeen, place);
|
||||
if (seen !== settings.tutorialSeen || !settings.welcomed) {
|
||||
saveSettings({ tutorialSeen: seen, ...welcomed() });
|
||||
}
|
||||
},
|
||||
solved: () => undefined,
|
||||
quitHref: () => "#/",
|
||||
firstBackHref: () => "#/accueil",
|
||||
endHref: () => "#/",
|
||||
texts: {
|
||||
progress: HOME_TEXTS.tutorialProgress,
|
||||
stepLink: HOME_TEXTS.tutorialStepLink,
|
||||
quit: HOME_TEXTS.tutorialSkip,
|
||||
quitLabel: HOME_TEXTS.tutorialSkipLabel,
|
||||
overline: (_, n, total, practice) => HOME_TEXTS.tutorialOverline(n, total, practice),
|
||||
endOverline: () => HOME_TEXTS.tutorialEndOverline,
|
||||
endTitle: () => HOME_TEXTS.tutorialEndTitle,
|
||||
loading: HOME_TEXTS.tutorialLoading,
|
||||
failed: HOME_TEXTS.tutorialFailed,
|
||||
},
|
||||
};
|
||||
const tutorialView = createTutorialView({
|
||||
section: ui.tutorial,
|
||||
access: options.tutorial,
|
||||
engineReady: options.engineReady,
|
||||
course: tutorialCourse,
|
||||
placed: () => haptic("checkerPlaced"),
|
||||
reducedMotion,
|
||||
landscape: () => layout() === "landscape",
|
||||
replace: (hash) => navigation.replace(hash),
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------ Leçons
|
||||
|
||||
// Les textes des leçons (docs/lecons/, copiés dans le site) et la
|
||||
// progression enregistrée, lus une fois.
|
||||
const lessonLoaders = Array.from({ length: LESSON_COUNT }, (_, i) =>
|
||||
cachedText(lessonSource(i + 1), (text) => parseLesson(i + 1, text)),
|
||||
);
|
||||
let lessonTexts: Lesson[] | null = null;
|
||||
let lessonProgress: LessonProgress | null = null;
|
||||
const lessonProgressLoading = options.lessons
|
||||
.load()
|
||||
.catch(() => NO_PROGRESS)
|
||||
.then((progress) => {
|
||||
lessonProgress = progress;
|
||||
return progress;
|
||||
});
|
||||
/** Les leçons et la progression, lues (rejetée si un texte manque). */
|
||||
async function lessonsReady(): Promise<{ texts: Lesson[]; progress: LessonProgress }> {
|
||||
const [texts, progress] = await Promise.all([
|
||||
Promise.all(lessonLoaders.map((load) => load())),
|
||||
lessonProgressLoading,
|
||||
]);
|
||||
lessonTexts = texts;
|
||||
return { texts, progress };
|
||||
}
|
||||
const progressNow = () => lessonProgress ?? NO_PROGRESS;
|
||||
function updateLessons(progress: LessonProgress): void {
|
||||
lessonProgress = progress;
|
||||
options.lessons.save(progress);
|
||||
}
|
||||
const lessonOf = (route: Route) =>
|
||||
route.screen === "lesson" || route.screen === "lessonEnd" ? route.lesson : 1;
|
||||
// Le texte que l'écran d'une leçon reçoit est une leçon (Lesson).
|
||||
const lessonNumberOf = (text: Tutorial) => (text as Partial<Lesson>).number ?? 1;
|
||||
const lessonTitleOf = (text: Tutorial) => (text as Partial<Lesson>).title ?? "";
|
||||
|
||||
const lessonsView = createLessonsView(ui.lessons);
|
||||
function renderLessons(): void {
|
||||
if (lessonTexts !== null && lessonProgress !== null) {
|
||||
lessonsView.render(lessonCards(lessonTexts, lessonProgress));
|
||||
return;
|
||||
}
|
||||
lessonsView.render(null);
|
||||
lessonsReady().then(
|
||||
() => {
|
||||
if (navigation.route.screen === "lessons") {
|
||||
renderLessons();
|
||||
}
|
||||
},
|
||||
() => {
|
||||
if (navigation.route.screen === "lessons") {
|
||||
lessonsView.render(null, true);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const lessonCourse: Course = {
|
||||
id: "lesson",
|
||||
load: async (route) => {
|
||||
await lessonProgressLoading;
|
||||
const load = lessonLoaders[lessonOf(route) - 1];
|
||||
if (load === undefined) {
|
||||
throw new Error(`leçon ${lessonOf(route)} absente`);
|
||||
}
|
||||
return load();
|
||||
},
|
||||
placeOf: lessonPlaceOf,
|
||||
routeOf: (route, place) => lessonPlaceRoute(lessonOf(route), place),
|
||||
seen: (route) => lessonRecord(progressNow(), lessonOf(route)).reached,
|
||||
visit: (route, place) => {
|
||||
const number = lessonOf(route);
|
||||
const at = place === "end" ? LESSON_END : place.step;
|
||||
const record = lessonRecord(progressNow(), number);
|
||||
if (record.last !== at || record.reached < at) {
|
||||
updateLessons(withVisit(progressNow(), number, at));
|
||||
}
|
||||
if (!settings.welcomed) {
|
||||
saveSettings(welcomed());
|
||||
}
|
||||
},
|
||||
solved: (route, place) => {
|
||||
if (place !== "end" && place.practice) {
|
||||
updateLessons(withSolved(progressNow(), lessonOf(route), place.step));
|
||||
}
|
||||
},
|
||||
quitHref: () => routeHash({ screen: "lessons" }),
|
||||
firstBackHref: () => routeHash({ screen: "lessons" }),
|
||||
endHref: (route) => {
|
||||
const number = lessonOf(route);
|
||||
return number < LESSON_COUNT
|
||||
? routeHash({ screen: "lesson", lesson: number + 1, step: 1, practice: false })
|
||||
: routeHash({ screen: "lessons" });
|
||||
},
|
||||
texts: {
|
||||
progress: LESSON_TEXTS.progress,
|
||||
stepLink: LESSON_TEXTS.stepLink,
|
||||
quit: LESSON_TEXTS.quit,
|
||||
quitLabel: LESSON_TEXTS.quitLabel,
|
||||
overline: (text, n, total, practice) =>
|
||||
LESSON_TEXTS.overline(lessonNumberOf(text), n, total, practice),
|
||||
endOverline: (text) => LESSON_TEXTS.endOverline(lessonNumberOf(text)),
|
||||
endTitle: (text) => LESSON_TEXTS.endTitle(lessonTitleOf(text)),
|
||||
loading: LESSON_TEXTS.loading,
|
||||
failed: LESSON_TEXTS.failed,
|
||||
},
|
||||
};
|
||||
const lessonView = createTutorialView({
|
||||
section: ui.lesson,
|
||||
access: options.tutorial,
|
||||
engineReady: options.engineReady,
|
||||
course: lessonCourse,
|
||||
placed: () => haptic("checkerPlaced"),
|
||||
reducedMotion,
|
||||
landscape: () => layout() === "landscape",
|
||||
@@ -2344,6 +2752,9 @@ export function startApp(options: AppOptions): void {
|
||||
if (!ui.tutorial.hidden) {
|
||||
tutorialView.relayout();
|
||||
}
|
||||
if (!ui.lesson.hidden) {
|
||||
lessonView.relayout();
|
||||
}
|
||||
});
|
||||
};
|
||||
window.addEventListener("resize", relayout);
|
||||
@@ -2661,22 +3072,30 @@ async function computerMoveToShow(game: Game): Promise<MoveSteps | null> {
|
||||
return game.lastMoveSteps();
|
||||
}
|
||||
|
||||
/** La ligne d'information après le tour de l'ordinateur, et le coup
|
||||
* qu'elle cite (null s'il n'a pas joué de coup). */
|
||||
interface ComputerTurnLine {
|
||||
readonly text: string;
|
||||
readonly notation: MoveNotation | null;
|
||||
}
|
||||
|
||||
/** Ce qu'a fait l'ordinateur pendant son tour (sans durée de calcul). Son
|
||||
* coup s'écrit dans la notation du moteur, comme dans l'analyse. */
|
||||
async function describeComputerTurn(game: Game): Promise<string> {
|
||||
async function describeComputerTurn(game: Game): Promise<ComputerTurnLine> {
|
||||
const last = game.events.at(-1);
|
||||
if (last?.type === "CubeOffered") {
|
||||
return "";
|
||||
return { text: "", notation: null };
|
||||
}
|
||||
const played = game.events.findLast((e) => e.type === "MovePlayed");
|
||||
if (played?.type !== "MovePlayed") {
|
||||
return "";
|
||||
return { text: "", notation: null };
|
||||
}
|
||||
const dice = rollBefore(game);
|
||||
if (played.halfMoves.length === 0) {
|
||||
return TEXTS.computerCannotMove(dice);
|
||||
return { text: TEXTS.computerCannotMove(dice), notation: null };
|
||||
}
|
||||
return TEXTS.computerMoved(dice, await game.moveNotation(played.id));
|
||||
const notation = await game.moveNotation(played.id);
|
||||
return { text: TEXTS.computerMoved(dice, notation), notation };
|
||||
}
|
||||
|
||||
/** Le résultat d'une partie finie, en deux parties : le titre du panneau
|
||||
|
||||
@@ -21,10 +21,15 @@ export const ASK_AI_TEXTS = {
|
||||
summary: "Bilan",
|
||||
analysis: "Analyse",
|
||||
glossary: "Glossaire",
|
||||
progression: "Progression",
|
||||
},
|
||||
failed: "Le prompt n'a pas pu être préparé.",
|
||||
noAdvice: "Pas de prompt pour cette décision : le maître ne l'a pas encore analysée.",
|
||||
notReady: "Le maître analyse encore la partie : le prompt sera prêt avec le bilan.",
|
||||
missingGame: "Cette partie n'est plus sur ce téléphone.",
|
||||
missingTerm: "Ce terme n'est pas dans le glossaire.",
|
||||
noAnalysedGame:
|
||||
"Pas encore d'exercices : le maître n'a analysé aucune partie terminée. Ils seront prêts avec la première.",
|
||||
noMistake:
|
||||
"Pas d'exercices à proposer : sur vos parties analysées, vous avez joué comme le maître.",
|
||||
} as const;
|
||||
|
||||
@@ -156,6 +156,43 @@ export function pointSlot(number: number): { top: boolean; column: number } {
|
||||
return { top: true, column: number - 13 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Le jan intérieur d'un camp : ses six dernières flèches avant la sortie,
|
||||
* numérotées du point de vue du joueur (1 à 6 pour lui, 19 à 24 pour
|
||||
* l'ordinateur), dans le sens de marche du camp. Pur dessin : le moteur
|
||||
* reste seul juge des règles qui en dépendent (sortie, rentrée).
|
||||
* Glossaire : docs/glossaire/jan-interieur.md
|
||||
*/
|
||||
export interface HomeBoard {
|
||||
readonly side: Side;
|
||||
readonly points: readonly number[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Le jan extérieur d'un camp : les six flèches qui précèdent son jan
|
||||
* intérieur (7 à 12 pour le joueur, 13 à 18 pour l'ordinateur), dans le sens
|
||||
* de marche du camp.
|
||||
* Glossaire : docs/glossaire/jan-exterieur.md
|
||||
*/
|
||||
export interface OuterBoard {
|
||||
readonly side: Side;
|
||||
readonly points: readonly number[];
|
||||
}
|
||||
|
||||
/** Six flèches d'un camp, de sa flèche first à first - 5 dans sa propre
|
||||
* numérotation, renumérotées pour le joueur. */
|
||||
function sixPoints(side: Side, first: number): number[] {
|
||||
return Array.from({ length: 6 }, (_, i) => (side === "player" ? first - i : 25 - (first - i)));
|
||||
}
|
||||
|
||||
export function homeBoard(side: Side): HomeBoard {
|
||||
return { side, points: sixPoints(side, 6) };
|
||||
}
|
||||
|
||||
export function outerBoard(side: Side): OuterBoard {
|
||||
return { side, points: sixPoints(side, 12) };
|
||||
}
|
||||
|
||||
/** Le plateau de la position de départ, pour le dessiner avant que le
|
||||
* moteur soit chargé (première ouverture) : chaque camp a 2 pions sur sa case
|
||||
* 24, 5 sur la 13, 3 sur la 8 et 5 sur la 6 ; 167 points chacun. Une donnée,
|
||||
|
||||
@@ -303,23 +303,17 @@ function drawPoint(
|
||||
"data-point": number,
|
||||
points: `${left},${base} ${left + g.pointWidth},${base} ${center},${tip}`,
|
||||
});
|
||||
// Numéros : sur le cadre en paysage, dans la bande centrale en
|
||||
// portrait. Leur taille (12 px au moins à l'écran) dépend de l'échelle
|
||||
// Numéros : sur le cadre, bandes haute et basse, en portrait comme en
|
||||
// paysage. Leur taille (12 px au moins à l'écran) dépend de l'échelle
|
||||
// du dessin : c'est une mesure du dessin, comme x et y.
|
||||
const size = g.numberSize;
|
||||
let y: number;
|
||||
if (g.layout === "landscape") {
|
||||
y = top ? g.edge / 2 + size * 0.36 : g.height - g.edge / 2 + size * 0.36;
|
||||
} else {
|
||||
y = top ? middle - g.die / 2 - 4 : middle + g.die / 2 + 4 + size * 0.72;
|
||||
}
|
||||
add(
|
||||
svg,
|
||||
"text",
|
||||
{
|
||||
class: g.layout === "landscape" ? "point-number on-frame" : "point-number",
|
||||
class: "point-number",
|
||||
x: center,
|
||||
y,
|
||||
y: top ? g.edge / 2 + size * 0.36 : g.height - g.edge / 2 + size * 0.36,
|
||||
"font-size": size,
|
||||
},
|
||||
String(number),
|
||||
|
||||
+10
-5
@@ -25,7 +25,7 @@ export interface Geometry {
|
||||
readonly height: number;
|
||||
/** Épaisseur du cadre sur les côtés. */
|
||||
readonly frame: number;
|
||||
/** Hauteur du cadre en haut et en bas : la bande des numéros en paysage. */
|
||||
/** Hauteur du cadre en haut et en bas : la bande des numéros des flèches. */
|
||||
readonly edge: number;
|
||||
/** Colonne de commandes à gauche (0 en portrait). */
|
||||
readonly left: number;
|
||||
@@ -95,14 +95,19 @@ export function boardGeometry(layout: Layout, aspect: number, pxPerUnit: number)
|
||||
arrowWidth: 2.5 / pxPerUnit,
|
||||
};
|
||||
if (layout === "portrait") {
|
||||
// Les mesures du plateau d'origine : flèches de 44, pions de 20.
|
||||
// Les mesures du plateau d'origine : flèches de 44. Les bandes haute
|
||||
// et basse du cadre portent les numéros des flèches, comme en
|
||||
// paysage (12 px au moins à l'écran) : pions de 19 au plus. La bande ne
|
||||
// dépasse pas 36 unités : sur une miniature (moins de 275 px de
|
||||
// large), les numéros rapetissent plutôt que d'écraser les flèches.
|
||||
const frame = 8;
|
||||
const edge = Math.min(36, Math.max(24, numberSize + 8));
|
||||
const tray = 48;
|
||||
const bar = 40;
|
||||
const width = 640;
|
||||
const pointWidth = (width - 3 * frame - tray - bar) / 12;
|
||||
const band = 84;
|
||||
const radius = Math.min(pointWidth / 2 - 2, (height / 2 - frame - band / 2) / 10);
|
||||
const radius = Math.min(pointWidth / 2 - 2, (height / 2 - edge - band / 2) / 10);
|
||||
const x0 = frame;
|
||||
const barLeft = x0 + 6 * pointWidth;
|
||||
return {
|
||||
@@ -110,7 +115,7 @@ export function boardGeometry(layout: Layout, aspect: number, pxPerUnit: number)
|
||||
width,
|
||||
height,
|
||||
frame,
|
||||
edge: frame,
|
||||
edge,
|
||||
left: 0,
|
||||
tray,
|
||||
bar,
|
||||
@@ -123,7 +128,7 @@ export function boardGeometry(layout: Layout, aspect: number, pxPerUnit: number)
|
||||
rightHalf: barLeft + bar,
|
||||
trayLeft: barLeft + bar + 6 * pointWidth + frame,
|
||||
target,
|
||||
numberSize,
|
||||
numberSize: Math.min(numberSize, edge - 8),
|
||||
bandFont: atLeast(13.5, pxPerUnit, 17),
|
||||
chipFont: atLeast(13.5, pxPerUnit, 17),
|
||||
...sizes,
|
||||
|
||||
@@ -35,6 +35,8 @@ 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/suivre-ma-progression.feature",
|
||||
"docs/scenarios/mes-parties.feature",
|
||||
"docs/scenarios/partager-une-position.feature",
|
||||
"docs/scenarios/regler-l-application.feature",
|
||||
|
||||
@@ -30,6 +30,11 @@ export interface HomeModel {
|
||||
/** La carte de la partie : « Reprendre la partie » si une partie est en
|
||||
* cours, sinon « Nouvelle partie » en action principale. */
|
||||
readonly resume: boolean;
|
||||
/** La miniature du plateau et le compte de points : seulement pour une
|
||||
* partie à reprendre. Sans partie en cours, la carte ne montre que son
|
||||
* titre et « Nouvelle partie », jamais la position de la partie finie
|
||||
* (recette 1h). */
|
||||
readonly board: boolean;
|
||||
/** L'invitation à installer. */
|
||||
readonly installCard: boolean;
|
||||
}
|
||||
@@ -45,6 +50,7 @@ export function homeModel(
|
||||
return {
|
||||
welcome,
|
||||
resume: playing !== false,
|
||||
board: playing !== false,
|
||||
installCard: !welcome && installCardShown(settings, installed),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -41,7 +41,11 @@ export const HOME_TEXTS = {
|
||||
rulesContinue: (step: number) => `Reprendre à l'étape ${step}`,
|
||||
rulesAgain: "Revoir",
|
||||
progress: (played: number, won: number) =>
|
||||
`${plural(played, "partie jouée", "parties jouées")} · ${plural(won, "gagnée", "gagnées")}`,
|
||||
played === 0
|
||||
? "aucune partie jouée"
|
||||
: `${plural(played, "partie jouée", "parties jouées")} · ${won === 0 ? "aucune gagnée" : plural(won, "gagnée", "gagnées")}`,
|
||||
/** La ligne de progression, lien vers l'écran de progression (1h). */
|
||||
progressLink: (line: string) => `Votre progression : ${line}`,
|
||||
footer: "Né à l'Atelier du Huit, à Cugnaux. Logiciel libre, sans publicité ni mise.",
|
||||
|
||||
// Installation
|
||||
@@ -94,14 +98,15 @@ export const HOME_TEXTS = {
|
||||
drop: "Passer",
|
||||
tutorialLoading: "Chargement du tutoriel…",
|
||||
tutorialFailed: "Le tutoriel n'a pas pu se charger.",
|
||||
pathLabel: "Le sens du jeu de vos pions, en pointillés",
|
||||
pathLabel:
|
||||
"Le sens du jeu : la flèche claire pour vos pions, la sombre pour ceux de l'ordinateur",
|
||||
/** Le résultat d'une partie finie par le coup illustré (étape 7). */
|
||||
illustratedEnd: (kind: ResultKind) =>
|
||||
kind === "single"
|
||||
? "Partie finie : l'ordinateur avait sorti des pions, c'est une partie simple."
|
||||
: kind === "gammon"
|
||||
? "Partie finie : l'ordinateur n'a sorti aucun pion, c'est un gammon."
|
||||
: "Partie finie : il reste un pion de l'ordinateur dans votre camp, c'est un backgammon.",
|
||||
: "Partie finie : il reste un pion de l'ordinateur dans votre jan intérieur, c'est un backgammon.",
|
||||
computerDoubles: "L'ordinateur double.",
|
||||
|
||||
// Aide
|
||||
@@ -118,7 +123,7 @@ export const HOME_TEXTS = {
|
||||
aboutVersionUnknown: "Version publiée : inconnue (lancée depuis les sources).",
|
||||
aboutParagraphs: [
|
||||
"Ustagammon est un logiciel libre, sous licence GNU GPL version 3 ou ultérieure : vous pouvez l'utiliser, l'étudier, le modifier et le partager.",
|
||||
"Les textes du tutoriel, de l'aide et du glossaire sont sous licence Creative Commons BY-SA 4.0.",
|
||||
"Les textes du tutoriel, des leçons, de l'aide et du glossaire sont sous licence Creative Commons BY-SA 4.0.",
|
||||
"L'ordinateur joue et juge les coups avec GNU Backgammon (GPLv3), compilé pour le navigateur. Police Alegreya Sans, sous licence SIL Open Font License.",
|
||||
"Né à l'Atelier du Huit, café associatif à Cugnaux. Sans publicité, sans compte, sans mise : tout reste sur votre téléphone.",
|
||||
],
|
||||
|
||||
+80
-10
@@ -1,13 +1,13 @@
|
||||
// L'accueil (#/accueil) : au premier lancement, le choix « Je découvre » /
|
||||
// « Je connais les règles » ; ensuite, la partie en cours, l'invitation à
|
||||
// installer, les règles et la progression résumée
|
||||
// installer, la prochaine leçon (1g), les règles et la progression résumée
|
||||
// (docs/identite/charte/accueil-tutoriel-aide.md, « Accueil »).
|
||||
//
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
//
|
||||
// Les leçons (1g) et la progression détaillée (1h) n'existent pas encore :
|
||||
// l'accueil n'affiche que ce qui existe.
|
||||
// La ligne de progression résumée mène à l'écran de progression
|
||||
// (#/progression, étape 1h).
|
||||
|
||||
import type { SideBoards } from "../domain/game/game.js";
|
||||
import { boardModel, OPENING_BOARDS } from "./board-model.js";
|
||||
@@ -16,6 +16,9 @@ import { boardGeometry, PORTRAIT_ASPECT } from "./geometry.js";
|
||||
import { type HomeModel, WELCOME_LINKS } from "./home-model.js";
|
||||
import { HOME_TEXTS } from "./home-texts.js";
|
||||
import { icon } from "./icons.js";
|
||||
import { LESSON_TEXTS } from "./lesson-texts.js";
|
||||
import type { LessonCardModel } from "./lessons-model.js";
|
||||
import { fillLessonCard, lessonAction } from "./lessons-view.js";
|
||||
import { TUTORIAL_DONE } from "./settings.js";
|
||||
import { TEXTS } from "./texts.js";
|
||||
|
||||
@@ -38,6 +41,13 @@ export interface HomeState {
|
||||
readonly tutorialSeen: number;
|
||||
/** Parties jouées et gagnées (null : pas encore lues). */
|
||||
readonly progress: { readonly played: number; readonly won: number } | null;
|
||||
/** Les leçons : la prochaine (null : toutes terminées), et combien sont
|
||||
* terminées sur combien (null : pas encore lues). */
|
||||
readonly lessons: {
|
||||
readonly next: LessonCardModel | null;
|
||||
readonly finished: number;
|
||||
readonly total: number;
|
||||
} | null;
|
||||
/** Le moteur se charge, ou l'écran est occupé. */
|
||||
readonly busy: boolean;
|
||||
}
|
||||
@@ -151,7 +161,7 @@ export function createHomeView(options: HomeViewOptions): HomeView {
|
||||
const mini = boardSvg(miniBox, "home-board", "home-board");
|
||||
const facts = element(cardTop, "div", { className: "home-facts" });
|
||||
const gameOverline = element(facts, "span", { className: "overline" });
|
||||
element(facts, "strong", { text: HOME_TEXTS.againstComputer });
|
||||
const againstLine = element(facts, "strong", { text: HOME_TEXTS.againstComputer });
|
||||
const pips = element(facts, "span", { id: "home-pips", className: "home-pips" });
|
||||
const actions = element(card, "div", { className: "home-actions" });
|
||||
const resume = element(actions, "a", {
|
||||
@@ -186,16 +196,37 @@ 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 });
|
||||
element(rulesText, "span", { className: "muted", text: HOME_TEXTS.rulesDetail });
|
||||
const rulesAction = element(rules, "span", { className: "pill" });
|
||||
|
||||
const progress = element(main, "p", {
|
||||
// La ligne de progression mène à l'écran de progression (1h).
|
||||
const progress = element(main, "a", {
|
||||
id: "home-progress",
|
||||
className: "caption home-progress",
|
||||
});
|
||||
progress.href = "#/progression";
|
||||
const progressText = element(progress, "span", { className: "home-progress-text" });
|
||||
progress.append(icon("chevron"));
|
||||
const footer = element(main, "footer", { className: "home-footer" });
|
||||
const footerSymbol = element(footer, "img");
|
||||
footerSymbol.src = "icons/symbole-couleur.svg";
|
||||
@@ -212,7 +243,12 @@ export function createHomeView(options: HomeViewOptions): HomeView {
|
||||
return;
|
||||
}
|
||||
const sides = state.sides ?? OPENING_BOARDS;
|
||||
drawBoard(mini, sides, miniBox.clientWidth || 132);
|
||||
miniBox.hidden = !model.board;
|
||||
pips.hidden = !model.board;
|
||||
againstLine.hidden = !model.board;
|
||||
if (model.board) {
|
||||
drawBoard(mini, sides, miniBox.clientWidth || 132);
|
||||
}
|
||||
gameOverline.textContent = model.resume
|
||||
? HOME_TEXTS.gameOverline
|
||||
: HOME_TEXTS.noGameOverline;
|
||||
@@ -233,10 +269,44 @@ export function createHomeView(options: HomeViewOptions): HomeView {
|
||||
: seen >= TUTORIAL_DONE
|
||||
? HOME_TEXTS.rulesAgain
|
||||
: HOME_TEXTS.rulesContinue(seen);
|
||||
progress.textContent =
|
||||
state.progress === null || state.progress.played === 0
|
||||
? ""
|
||||
: HOME_TEXTS.progress(state.progress.played, state.progress.won);
|
||||
// La carte de la leçon, une fois les leçons lues.
|
||||
lesson.hidden = state.lessons === null;
|
||||
if (state.lessons !== null) {
|
||||
const card = state.lessons.next;
|
||||
if (card === null) {
|
||||
lessonInfo.replaceChildren();
|
||||
element(lessonInfo, "strong", {
|
||||
className: "lesson-card-title",
|
||||
text: LESSON_TEXTS.homeAllDone,
|
||||
});
|
||||
element(lessonInfo, "span", {
|
||||
className: "muted",
|
||||
text: LESSON_TEXTS.homeAllDoneDetail,
|
||||
});
|
||||
lessonGo.hidden = true;
|
||||
} else {
|
||||
fillLessonCard(lessonInfo, card);
|
||||
lessonGo.hidden = false;
|
||||
lessonGo.textContent = lessonAction(card);
|
||||
lessonGo.href = card.href;
|
||||
lessonGo.setAttribute(
|
||||
"aria-label",
|
||||
`${lessonAction(card)} : ${LESSON_TEXTS.openLabel(card.number, card.title)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
const parts: string[] = [];
|
||||
if (state.lessons !== null) {
|
||||
parts.push(LESSON_TEXTS.homeSummary(state.lessons.finished, state.lessons.total));
|
||||
}
|
||||
if (state.progress !== null) {
|
||||
parts.push(HOME_TEXTS.progress(state.progress.played, state.progress.won));
|
||||
}
|
||||
const line = parts.join(" · ");
|
||||
const shown = line.charAt(0).toUpperCase() + line.slice(1);
|
||||
progressText.textContent = shown;
|
||||
progress.hidden = parts.length === 0;
|
||||
progress.setAttribute("aria-label", HOME_TEXTS.progressLink(line));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ export const ICON_PATHS = {
|
||||
home: "M4 11l8-7 8 7M6 9.5V20h12V9.5M10 20v-6h4v6",
|
||||
rules: "M4 5h6a2 2 0 0 1 2 2v13a2 2 0 0 0-2-2H4zM20 5h-6a2 2 0 0 0-2 2v13a2 2 0 0 1 2-2h6z",
|
||||
install: "M12 4v11M7 10l5 5 5-5M5 20h14",
|
||||
lessons: "M3 9l9-5 9 5-9 5zM7 11.2V16c0 1.6 2.2 3 5 3s5-1.4 5-3v-4.8M21 9v6",
|
||||
verdictGood: "M12 21a9 9 0 1 0 0-18a9 9 0 0 0 0 18zM8 12.5l3 3 5-6",
|
||||
verdictDoubtful:
|
||||
"M12 21a9 9 0 1 0 0-18a9 9 0 0 0 0 18zM9.5 9.3a2.6 2.6 0 1 1 3.6 2.4c-.7.3-1.1.9-1.1 1.6v.6M12 17.2v.1",
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
// 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),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
// 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;
|
||||
@@ -0,0 +1,66 @@
|
||||
// Ce que montrent la liste des leçons et la carte « prochaine leçon » de
|
||||
// l'accueil : fonctions pures, testées sous Node
|
||||
// (docs/identite/charte/ecrans-etape-1.md, « 1g. Leçons »).
|
||||
//
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
|
||||
import {
|
||||
type LessonProgress,
|
||||
type LessonState,
|
||||
lessonRecord,
|
||||
lessonState,
|
||||
nextLesson,
|
||||
resumeStep,
|
||||
} from "../domain/learning/lessons.js";
|
||||
import { type Lesson, outlineOf } from "./lesson-text.js";
|
||||
import { routeHash } from "./router.js";
|
||||
|
||||
/** Une leçon dans la liste, ou sur l'accueil. */
|
||||
export interface LessonCardModel {
|
||||
readonly number: number;
|
||||
readonly title: string;
|
||||
readonly state: LessonState;
|
||||
/** Les exercices, dans l'ordre : réussi ou non (barre de progression). */
|
||||
readonly exercises: readonly boolean[];
|
||||
/** Où mène la carte : là où reprendre la leçon. */
|
||||
readonly href: string;
|
||||
/** La leçon proposée ensuite : carte bordée de brique, seule action
|
||||
* principale de l'écran. */
|
||||
readonly current: boolean;
|
||||
}
|
||||
|
||||
/** L'adresse où reprendre la leçon. */
|
||||
export function resumeHref(progress: LessonProgress, lesson: Lesson): string {
|
||||
const step = resumeStep(progress, outlineOf(lesson));
|
||||
return routeHash({ screen: "lesson", lesson: lesson.number, step, practice: false });
|
||||
}
|
||||
|
||||
/** Les cartes de la liste des leçons, dans l'ordre. */
|
||||
export function lessonCards(
|
||||
lessons: readonly Lesson[],
|
||||
progress: LessonProgress,
|
||||
): LessonCardModel[] {
|
||||
const outlines = lessons.map(outlineOf);
|
||||
const next = nextLesson(progress, outlines)?.number ?? null;
|
||||
return lessons.map((lesson, i) => {
|
||||
const outline = outlines[i] ?? outlineOf(lesson);
|
||||
const solved = lessonRecord(progress, lesson.number).solved;
|
||||
return {
|
||||
number: lesson.number,
|
||||
title: lesson.title,
|
||||
state: lessonState(progress, outline),
|
||||
exercises: outline.exercises.map((step) => solved.includes(step)),
|
||||
href: resumeHref(progress, lesson),
|
||||
current: lesson.number === next,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** La carte « prochaine leçon » de l'accueil (null : toutes terminées). */
|
||||
export function nextLessonCard(
|
||||
lessons: readonly Lesson[],
|
||||
progress: LessonProgress,
|
||||
): LessonCardModel | null {
|
||||
return lessonCards(lessons, progress).find((card) => card.current) ?? null;
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -57,7 +57,7 @@ export const MATCH_TEXTS = {
|
||||
advice: "Conseil du maître",
|
||||
adviceDetail: "Une ligne après chacun de vos coups",
|
||||
startGame: "Commencer la partie",
|
||||
startMatch: (n: number) => `Commencer le match en ${n} points`,
|
||||
startMatch: "Commencer le match",
|
||||
kept: "Vos choix sont gardés pour la prochaine fois.",
|
||||
|
||||
// Score du match sur l'écran de jeu
|
||||
|
||||
@@ -200,9 +200,7 @@ export function createNewGameView(options: NewGameViewOptions): NewGameView {
|
||||
levelName.textContent = MATCH_TEXTS.levels[s.level].name;
|
||||
levelText.textContent = MATCH_TEXTS.levels[s.level].text;
|
||||
advice.setAttribute("aria-checked", String(s.masterAdvice));
|
||||
start.textContent = isMatch
|
||||
? MATCH_TEXTS.startMatch(s.matchLength)
|
||||
: MATCH_TEXTS.startGame;
|
||||
start.textContent = isMatch ? MATCH_TEXTS.startMatch : MATCH_TEXTS.startGame;
|
||||
start.disabled = busy;
|
||||
},
|
||||
focus() {
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
// Un coup écrit dans la notation du moteur (« 24/22* 6/1 », « 8/4(2) »),
|
||||
// touchable vers sa fiche du glossaire : un débutant y apprend ce que
|
||||
// veulent dire l'étoile, « off », « bar » et « (2) ».
|
||||
//
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
|
||||
import type { MoveNotation } from "../domain/game/engine.js";
|
||||
import { routeHash } from "./router.js";
|
||||
|
||||
/** La fiche du glossaire de la notation (docs/glossaire/, sans .md). */
|
||||
export const NOTATION_TERM = "notation-des-coups";
|
||||
|
||||
/**
|
||||
* Remplace le contenu de parent par text, où la première occurrence de
|
||||
* notation devient un lien vers la fiche « Notation des coups ». Sans
|
||||
* notation dans le texte, le texte seul.
|
||||
*/
|
||||
export function showWithNotation(
|
||||
parent: HTMLElement,
|
||||
text: string,
|
||||
notation: MoveNotation | null,
|
||||
): void {
|
||||
const at = notation === null || notation === "" ? -1 : text.indexOf(notation);
|
||||
if (notation === null || at < 0) {
|
||||
parent.textContent = text;
|
||||
return;
|
||||
}
|
||||
const link = document.createElement("a");
|
||||
link.className = "notation";
|
||||
link.href = routeHash({ screen: "glossary", term: NOTATION_TERM });
|
||||
link.textContent = notation;
|
||||
parent.replaceChildren(text.slice(0, at), link, text.slice(at + notation.length));
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
// Les textes de l'écran de progression (étape 1h ; ADR 0007, point 5). La
|
||||
// phrase de synthèse, elle, est une phrase du maître : elle vit dans
|
||||
// docs/maitre/phrases.md (sections progression.…, une seule source,
|
||||
// ADR 0008).
|
||||
//
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
|
||||
import type { GameResult } from "../domain/game/state.js";
|
||||
|
||||
const plural = (n: number, one: string, many: string) => `${n} ${n > 1 ? many : one}`;
|
||||
|
||||
const RESULT_KIND = {
|
||||
single: "",
|
||||
gammon: "gammon",
|
||||
backgammon: "backgammon",
|
||||
} as const;
|
||||
|
||||
export const PROGRESSION_TEXTS = {
|
||||
title: "Votre progression",
|
||||
loading: "Lecture de vos parties…",
|
||||
failed: "Vos parties n'ont pas pu être lues.",
|
||||
/** Aucune partie terminée. */
|
||||
empty: "Aucune partie terminée pour l'instant : votre progression commencera avec la première.",
|
||||
newGame: "Nouvelle partie",
|
||||
curveTitle: "Votre taux d'erreur · plus bas, c'est mieux",
|
||||
curveLabel: (rates: readonly number[]) =>
|
||||
`Votre taux d'erreur, partie après partie, de la plus ancienne à la plus récente : ${rates.join(", ")}.`,
|
||||
whatIsIt: "Qu'est-ce que le taux d'erreur ?",
|
||||
analysing: (n: number) =>
|
||||
`Le maître analyse ${plural(n, "partie", "parties")} : ${n > 1 ? "elles apparaîtront" : "elle apparaîtra"} ici une fois ${n > 1 ? "analysées" : "analysée"}.`,
|
||||
exercises: "Demander des exercices à une IA",
|
||||
listTitle: "Vos parties",
|
||||
/** Le résultat court, qui dit d'où viennent les points : « Gagnée,
|
||||
* 1 point », « Perdue, gammon, 2 points », « Perdue, videau à 2,
|
||||
* 2 points », « Perdue, gammon, videau à 2, 4 points » (jamais
|
||||
* « partie simple »). cube : la valeur du videau à la fin de la
|
||||
* partie. */
|
||||
result: (result: GameResult | null, cube: number) => {
|
||||
if (result === null) {
|
||||
return "Partie terminée";
|
||||
}
|
||||
const parts = [result.winner === "player" ? "Gagnée" : "Perdue"];
|
||||
if (result.kind !== "single") {
|
||||
parts.push(RESULT_KIND[result.kind]);
|
||||
}
|
||||
if (cube > 1) {
|
||||
parts.push(`videau à ${cube}`);
|
||||
}
|
||||
parts.push(plural(result.points, "point", "points"));
|
||||
return parts.join(", ");
|
||||
},
|
||||
level: (name: string) => `contre ${name}`,
|
||||
rateLabel: "taux d'erreur",
|
||||
rateLoading: "en analyse",
|
||||
openSummary: (result: string, date: string) => `Bilan de la partie du ${date} : ${result}`,
|
||||
/** La date d'une partie : « 3 nov. 2026 ». */
|
||||
date: (ms: number) => new Date(ms).toLocaleDateString("fr-FR", { dateStyle: "medium" }),
|
||||
} as const;
|
||||
@@ -0,0 +1,308 @@
|
||||
// L'écran de progression (#/progression, étape 1h ; charte,
|
||||
// ecrans-etape-1.md, « 1h ») : une phrase du maître, la courbe du taux
|
||||
// d'erreur partie après partie (SVG fait main), « Créer des exercices », et
|
||||
// la liste des parties terminées, chacune menant à son bilan et à son
|
||||
// analyse.
|
||||
//
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
//
|
||||
// La vue ne connaît ni le stockage ni le moteur : l'écran de jeu (app.ts)
|
||||
// lui passe les parties et leur taux, lus dans les analyses rangées
|
||||
// (domain/analysis/progression.ts), et la redessine quand une analyse en
|
||||
// arrière-plan se termine.
|
||||
|
||||
import type { MasterPhrases } from "../domain/analysis/phrases.js";
|
||||
import { progressionSentence } from "../domain/analysis/phrases.js";
|
||||
import {
|
||||
CURVE_MIN_GAMES,
|
||||
errorRateMillis,
|
||||
type ProgressionEntry,
|
||||
progressionCurve,
|
||||
progressionTrend,
|
||||
} from "../domain/analysis/progression.js";
|
||||
import { computerLevelOf } from "../domain/game/level.js";
|
||||
import { icon } from "./icons.js";
|
||||
import { MATCH_TEXTS } from "./match-texts.js";
|
||||
import { PROGRESSION_TEXTS } from "./progression-texts.js";
|
||||
import { routeHash } from "./router.js";
|
||||
|
||||
/** Ce que montre l'écran. */
|
||||
export interface ProgressionData {
|
||||
/** Les parties terminées, de la plus ancienne à la plus récente. */
|
||||
readonly entries: readonly ProgressionEntry[];
|
||||
/** Les phrases du maître (null : pas encore lues). */
|
||||
readonly phrases: MasterPhrases | null;
|
||||
}
|
||||
|
||||
export interface ProgressionViewOptions {
|
||||
readonly section: HTMLElement;
|
||||
}
|
||||
|
||||
export interface ProgressionView {
|
||||
/** Dessine l'écran (null : les parties se lisent ; failed : échec). */
|
||||
render(data: ProgressionData | null, failed?: boolean): void;
|
||||
}
|
||||
|
||||
const SVG_NS = "http://www.w3.org/2000/svg";
|
||||
/** Hauteur de la courbe, et ses marges intérieures (les valeurs au-dessus
|
||||
* des points, le dernier point plus gros). */
|
||||
const CURVE_HEIGHT = 168;
|
||||
const PAD = { left: 12, right: 12, top: 30, bottom: 12 };
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
function svgElement<K extends keyof SVGElementTagNameMap>(
|
||||
parent: Element,
|
||||
tag: K,
|
||||
attributes: Record<string, string | number>,
|
||||
): SVGElementTagNameMap[K] {
|
||||
const child = document.createElementNS(SVG_NS, tag);
|
||||
for (const [name, value] of Object.entries(attributes)) {
|
||||
child.setAttribute(name, String(value));
|
||||
}
|
||||
parent.append(child);
|
||||
return child;
|
||||
}
|
||||
|
||||
/** Le nom du niveau de l'ordinateur d'une partie. */
|
||||
function levelName(entry: ProgressionEntry): string {
|
||||
const level = computerLevelOf(entry.level);
|
||||
return level === null ? entry.level : MATCH_TEXTS.levels[level].name;
|
||||
}
|
||||
|
||||
export function createProgressionView(options: ProgressionViewOptions): ProgressionView {
|
||||
const { section } = options;
|
||||
section.replaceChildren();
|
||||
// Pas de « Retour » (charte 1h) : la barre du haut et le retour du
|
||||
// téléphone suffisent, comme sur la liste des leçons.
|
||||
element(section, "h1", {
|
||||
id: "progression-title",
|
||||
className: "progression-title",
|
||||
text: PROGRESSION_TEXTS.title,
|
||||
});
|
||||
const summary = element(section, "p", {
|
||||
id: "progression-summary",
|
||||
className: "progression-summary",
|
||||
});
|
||||
summary.setAttribute("role", "status");
|
||||
const body = element(section, "div", { className: "progression-body" });
|
||||
// La courbe se redessine à la largeur de la page (rotation).
|
||||
let drawnCurve: (() => void) | null = null;
|
||||
window.addEventListener("resize", () => {
|
||||
if (!section.hidden) {
|
||||
drawnCurve?.();
|
||||
}
|
||||
});
|
||||
|
||||
// Ce qui est dessiné : chaque analyse rangée en arrière-plan redemande
|
||||
// l'écran, qui ne change que quand le taux d'une partie arrive.
|
||||
let drawn = "";
|
||||
|
||||
function render(data: ProgressionData | null, failed = false): void {
|
||||
const key = JSON.stringify(
|
||||
data === null
|
||||
? { failed }
|
||||
: {
|
||||
phrases: data.phrases !== null,
|
||||
entries: data.entries.map((e) => [
|
||||
e.id,
|
||||
e.result,
|
||||
e.cube,
|
||||
e.level,
|
||||
e.errorRate?.perDecision ?? null,
|
||||
]),
|
||||
},
|
||||
);
|
||||
if (key === drawn) {
|
||||
return;
|
||||
}
|
||||
drawn = key;
|
||||
body.replaceChildren();
|
||||
drawnCurve = null;
|
||||
delete section.dataset.ready;
|
||||
if (data === null) {
|
||||
summary.textContent = failed ? PROGRESSION_TEXTS.failed : PROGRESSION_TEXTS.loading;
|
||||
return;
|
||||
}
|
||||
if (data.entries.length === 0) {
|
||||
summary.textContent = PROGRESSION_TEXTS.empty;
|
||||
const actions = element(body, "div", {
|
||||
className: "progression-actions progression-empty",
|
||||
});
|
||||
const start = element(actions, "a", {
|
||||
id: "progression-new-game",
|
||||
className: "button",
|
||||
text: PROGRESSION_TEXTS.newGame,
|
||||
});
|
||||
start.href = routeHash({ screen: "newGame" });
|
||||
section.dataset.ready = "true";
|
||||
return;
|
||||
}
|
||||
const points = progressionCurve(data.entries);
|
||||
const trend = progressionTrend(points);
|
||||
summary.textContent = data.phrases === null ? "" : progressionSentence(data.phrases, trend);
|
||||
|
||||
if (points.length >= CURVE_MIN_GAMES) {
|
||||
const card = element(body, "section", {
|
||||
id: "progression-curve-card",
|
||||
className: "analysis-card",
|
||||
});
|
||||
element(card, "h3", { className: "overline", text: PROGRESSION_TEXTS.curveTitle });
|
||||
const svg = document.createElementNS(SVG_NS, "svg");
|
||||
svg.id = "progression-curve";
|
||||
svg.setAttribute("class", "progression-curve");
|
||||
svg.setAttribute("role", "img");
|
||||
svg.setAttribute(
|
||||
"aria-label",
|
||||
PROGRESSION_TEXTS.curveLabel(points.map((p) => errorRateMillis(p.perDecision))),
|
||||
);
|
||||
card.append(svg);
|
||||
const link = element(card, "a", {
|
||||
id: "progression-glossary",
|
||||
className: "link-button",
|
||||
text: PROGRESSION_TEXTS.whatIsIt,
|
||||
});
|
||||
link.href = routeHash({ screen: "glossary", term: "taux-d-erreur" });
|
||||
// Le vert dit « mieux », jamais « dernier » : seulement si le
|
||||
// taux baisse.
|
||||
const better = trend.kind === "down";
|
||||
drawnCurve = () => drawCurve(svg, points, better);
|
||||
drawnCurve();
|
||||
}
|
||||
|
||||
const waiting = data.entries.filter((e) => e.errorRate === null).length;
|
||||
if (waiting > 0) {
|
||||
element(body, "p", {
|
||||
id: "progression-analysing",
|
||||
className: "muted",
|
||||
text: PROGRESSION_TEXTS.analysing(waiting),
|
||||
}).setAttribute("role", "status");
|
||||
}
|
||||
|
||||
// « Demander des exercices à une IA » : la feuille du prompt, étape
|
||||
// 1f ; bouton secondaire avec l'icône de copie.
|
||||
if (points.length > 0) {
|
||||
const actions = element(body, "div", { className: "progression-actions" });
|
||||
const exercises = element(actions, "a", {
|
||||
id: "progression-exercises",
|
||||
className: "button secondary ask-ai",
|
||||
});
|
||||
exercises.href = routeHash({ screen: "progression", ai: true });
|
||||
exercises.append(icon("copy"), PROGRESSION_TEXTS.exercises);
|
||||
}
|
||||
|
||||
element(body, "h3", { className: "overline", text: PROGRESSION_TEXTS.listTitle });
|
||||
const list = element(body, "ol", { id: "progression-list", className: "progression-list" });
|
||||
// La plus récente d'abord, comme « Mes parties ».
|
||||
for (const entry of [...data.entries].reverse()) {
|
||||
list.append(item(entry));
|
||||
}
|
||||
section.dataset.ready = String(waiting === 0);
|
||||
}
|
||||
|
||||
function item(entry: ProgressionEntry): HTMLElement {
|
||||
const li = document.createElement("li");
|
||||
li.className = "progression-item";
|
||||
li.dataset.id = entry.id;
|
||||
const date = PROGRESSION_TEXTS.date(entry.startedAt);
|
||||
const result = PROGRESSION_TEXTS.result(entry.result, entry.cube);
|
||||
// Une ligne, un seul lien : le bilan (l'analyse s'ouvre depuis
|
||||
// lui), chevron à droite.
|
||||
const game = element(li, "a", { className: "progression-game" });
|
||||
game.href = routeHash({ screen: "summary", game: entry.id });
|
||||
game.setAttribute("aria-label", PROGRESSION_TEXTS.openSummary(result, date));
|
||||
const text = element(game, "span", { className: "progression-text" });
|
||||
element(text, "strong", { text: result });
|
||||
element(text, "span", {
|
||||
className: "caption",
|
||||
text: `${date} · ${PROGRESSION_TEXTS.level(levelName(entry))}`,
|
||||
});
|
||||
const rate = element(game, "span", { className: "progression-rate" });
|
||||
if (entry.errorRate === null) {
|
||||
rate.classList.add("pending");
|
||||
rate.textContent = PROGRESSION_TEXTS.rateLoading;
|
||||
} else {
|
||||
rate.dataset.rate = String(entry.errorRate.perDecision);
|
||||
element(rate, "strong", { text: String(errorRateMillis(entry.errorRate.perDecision)) });
|
||||
element(rate, "span", { className: "caption", text: PROGRESSION_TEXTS.rateLabel });
|
||||
}
|
||||
game.append(icon("chevron"));
|
||||
return li;
|
||||
}
|
||||
|
||||
return { render };
|
||||
}
|
||||
|
||||
/** La courbe : un point par partie analysée, de gauche (la plus ancienne)
|
||||
* à droite, de 0 (la ligne du bas, seule ligne d'axe) au plus haut taux. Le
|
||||
* dernier point est plus gros, à l'encre du texte, en verdict-good si le
|
||||
* taux baisse (better) ; les valeurs, en millièmes, s'écrivent au-dessus du
|
||||
* premier et du dernier point seulement (charte 1h). */
|
||||
function drawCurve(
|
||||
svg: SVGSVGElement,
|
||||
points: readonly { readonly id: string; readonly perDecision: number }[],
|
||||
better: boolean,
|
||||
): void {
|
||||
const width = Math.round(svg.getBoundingClientRect().width || 360);
|
||||
const height = CURVE_HEIGHT;
|
||||
svg.setAttribute("viewBox", `0 0 ${width} ${height}`);
|
||||
svg.replaceChildren();
|
||||
const millis = points.map((p) => errorRateMillis(p.perDecision));
|
||||
const top = Math.max(1, ...millis);
|
||||
const inner = width - PAD.left - PAD.right;
|
||||
const step = points.length > 1 ? inner / (points.length - 1) : 0;
|
||||
const x = (i: number) => PAD.left + i * step;
|
||||
const y = (value: number) => PAD.top + (1 - value / top) * (height - PAD.top - PAD.bottom);
|
||||
svgElement(svg, "line", {
|
||||
x1: PAD.left,
|
||||
x2: width - PAD.right,
|
||||
y1: y(0),
|
||||
y2: y(0),
|
||||
class: "curve-axis",
|
||||
});
|
||||
const line = points.map((p, i) => `${x(i)},${y(p.perDecision * 1000)}`).join(" ");
|
||||
svgElement(svg, "polyline", { points: line, class: "curve-line" });
|
||||
const lastIndex = points.length - 1;
|
||||
points.forEach((point, i) => {
|
||||
const last = i === lastIndex;
|
||||
const cy = y(point.perDecision * 1000);
|
||||
const dot = svgElement(svg, "circle", {
|
||||
cx: x(i),
|
||||
cy,
|
||||
r: last ? 7 : 4,
|
||||
class: last ? `progression-dot last${better ? " better" : ""}` : "progression-dot",
|
||||
"data-id": point.id,
|
||||
"data-rate": String(point.perDecision),
|
||||
});
|
||||
dot.setAttribute("aria-hidden", "true");
|
||||
if (i === 0 || last) {
|
||||
const value = svgElement(svg, "text", {
|
||||
x: x(i),
|
||||
y: cy - (last ? 14 : 11),
|
||||
class: last
|
||||
? `progression-value last${better ? " better" : ""}`
|
||||
: "progression-value",
|
||||
"text-anchor": i === 0 && !last ? "start" : last && i > 0 ? "end" : "middle",
|
||||
});
|
||||
value.textContent = String(millis[i]);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
type ConceptExample,
|
||||
conceptPrompt,
|
||||
decisionPrompt,
|
||||
exercisesPrompt,
|
||||
summaryPrompt,
|
||||
} from "../domain/prompts/prompts.js";
|
||||
import {
|
||||
@@ -40,6 +41,9 @@ export interface PromptsAccess {
|
||||
readonly summary: (journal: GameJournal) => Promise<PromptPosition>;
|
||||
/** La position d'exemple d'un concept et son évaluation. */
|
||||
readonly concept: (journal: GameJournal | null) => Promise<ConceptExample>;
|
||||
/** La position de la plus coûteuse des erreurs de ces parties (null :
|
||||
* aucune imprécision ni erreur). */
|
||||
readonly exercises: (journals: readonly GameJournal[]) => Promise<PromptPosition | null>;
|
||||
}
|
||||
|
||||
export interface PromptSourceOptions {
|
||||
@@ -56,6 +60,9 @@ export interface PromptSource {
|
||||
summary(journal: GameJournal): Promise<PromptResult>;
|
||||
/** Le prompt d'un terme du glossaire (nom de sa fiche, sans .md). */
|
||||
concept(term: string, journal: GameJournal | null): Promise<PromptResult>;
|
||||
/** Le prompt « Créer des exercices » (progression, étape 1h), sur les
|
||||
* parties analysées, de la plus ancienne à la plus récente. */
|
||||
exercises(journals: readonly GameJournal[]): Promise<PromptResult>;
|
||||
}
|
||||
|
||||
async function fetchText(url: string): Promise<string> {
|
||||
@@ -109,6 +116,17 @@ export function createPromptSource(options: PromptSourceOptions): PromptSource {
|
||||
]);
|
||||
return { text: summaryPrompt(all, phrases, { journal, position }) };
|
||||
},
|
||||
async exercises(journals) {
|
||||
const [all, phrases, position] = await Promise.all([
|
||||
templates(),
|
||||
options.phrases(),
|
||||
options.access.exercises(journals),
|
||||
]);
|
||||
if (position === null) {
|
||||
return { message: ASK_AI_TEXTS.noMistake };
|
||||
}
|
||||
return { text: exercisesPrompt(all, phrases, { journals, position }) };
|
||||
},
|
||||
async concept(term, journal) {
|
||||
const [all, entry, example] = await Promise.all([
|
||||
templates(),
|
||||
|
||||
+61
-1
@@ -24,6 +24,19 @@ export type Route =
|
||||
| { readonly screen: "tutorial"; readonly step: number | null; readonly practice: boolean }
|
||||
/** #/tutoriel/fin : la fin du tutoriel. */
|
||||
| { readonly screen: "tutorialEnd" }
|
||||
/** #/lecons : la liste des leçons (étape 1g). */
|
||||
| { readonly screen: "lessons" }
|
||||
/** #/lecons/<n> (son premier écran), une étape de la leçon n :
|
||||
* #/lecons/<n>/<étape>, et son exercice « à vous » :
|
||||
* #/lecons/<n>/<étape>/a-vous. */
|
||||
| {
|
||||
readonly screen: "lesson";
|
||||
readonly lesson: number;
|
||||
readonly step: number | null;
|
||||
readonly practice: boolean;
|
||||
}
|
||||
/** #/lecons/<n>/fin : la fin de la leçon n. */
|
||||
| { readonly screen: "lessonEnd"; readonly lesson: number }
|
||||
/** #/installer : la marche à suivre pour installer l'application. */
|
||||
| { readonly screen: "install" }
|
||||
/** #/parties, et une partie : #/parties/<identifiant>. */
|
||||
@@ -41,6 +54,10 @@ export type Route =
|
||||
readonly turn: number | null;
|
||||
readonly ai?: boolean;
|
||||
}
|
||||
/** #/progression : l'historique des parties et la courbe du taux
|
||||
* d'erreur (étape 1h) ; le prompt « Créer des exercices » :
|
||||
* #/progression/ia. */
|
||||
| { readonly screen: "progression"; readonly ai?: boolean }
|
||||
/** #/menu. */
|
||||
| { readonly screen: "menu" }
|
||||
/** #/nouvelle-partie : le choix de la partie (type, niveau, conseil). */
|
||||
@@ -91,6 +108,9 @@ export function parseRoute(hash: string): Route {
|
||||
if (head === "tutoriel") {
|
||||
return tutorialRoute(rest) ?? unknown;
|
||||
}
|
||||
if (head === "lecons") {
|
||||
return lessonRoute(rest) ?? unknown;
|
||||
}
|
||||
if (head === "parties" && rest.length > 1) {
|
||||
return gameReviewRoute(rest) ?? unknown;
|
||||
}
|
||||
@@ -114,6 +134,12 @@ export function parseRoute(hash: string): Route {
|
||||
return name === null ? { screen: "about" } : unknown;
|
||||
case "menu":
|
||||
return name === null ? { screen: "menu" } : unknown;
|
||||
case "progression":
|
||||
return name === null
|
||||
? { screen: "progression" }
|
||||
: name === "ia"
|
||||
? { screen: "progression", ai: true }
|
||||
: unknown;
|
||||
case "nouvelle-partie":
|
||||
return name === null ? { screen: "newGame" } : unknown;
|
||||
case "explication":
|
||||
@@ -130,7 +156,7 @@ export function parseRoute(hash: string): Route {
|
||||
/** Les écrans qui ont une feuille de prompt pour une IA. */
|
||||
export type AiRoute = Extract<
|
||||
Route,
|
||||
{ screen: "explanation" | "summary" | "analysis" | "glossary" }
|
||||
{ screen: "explanation" | "summary" | "analysis" | "glossary" | "progression" }
|
||||
>;
|
||||
|
||||
/** L'écran, avec sa feuille de prompt ouverte (null : écran sans prompt). */
|
||||
@@ -139,6 +165,7 @@ function aiRoute(route: Route): AiRoute | null {
|
||||
case "explanation":
|
||||
case "summary":
|
||||
case "analysis":
|
||||
case "progression":
|
||||
return route.ai === true ? null : { ...route, ai: true };
|
||||
case "glossary":
|
||||
return route.term === null || route.ai === true ? null : { ...route, ai: true };
|
||||
@@ -194,6 +221,29 @@ function tutorialRoute(rest: readonly string[]): Route | null {
|
||||
return { screen: "tutorial", step: Number(name), practice: part === "a-vous" };
|
||||
}
|
||||
|
||||
/** #/lecons, #/lecons/<n>, #/lecons/<n>/<étape>, #/lecons/<n>/<étape>/a-vous,
|
||||
* #/lecons/<n>/fin. */
|
||||
function lessonRoute(rest: readonly string[]): Route | null {
|
||||
const [number, name, part, ...more] = rest;
|
||||
if (number === undefined) {
|
||||
return { screen: "lessons" };
|
||||
}
|
||||
if (!/^[1-9]$/.test(number) || more.length > 0) {
|
||||
return null;
|
||||
}
|
||||
const lesson = Number(number);
|
||||
if (name === undefined) {
|
||||
return { screen: "lesson", lesson, step: null, practice: false };
|
||||
}
|
||||
if (name === "fin") {
|
||||
return part === undefined ? { screen: "lessonEnd", lesson } : null;
|
||||
}
|
||||
if (!/^[1-9][0-9]?$/.test(name) || (part !== undefined && part !== "a-vous")) {
|
||||
return null;
|
||||
}
|
||||
return { screen: "lesson", lesson, step: Number(name), practice: part === "a-vous" };
|
||||
}
|
||||
|
||||
/**
|
||||
* #/position/... : le Position ID (14 caractères), puis, après une barre, le
|
||||
* Match ID. Chaque identifiant est encodé avec encodeURIComponent, mais une
|
||||
@@ -246,6 +296,14 @@ function screenHash(route: Route): string {
|
||||
: `#/tutoriel/${route.step}${route.practice ? "/a-vous" : ""}`;
|
||||
case "tutorialEnd":
|
||||
return "#/tutoriel/fin";
|
||||
case "lessons":
|
||||
return "#/lecons";
|
||||
case "lesson":
|
||||
return route.step === null
|
||||
? `#/lecons/${route.lesson}`
|
||||
: `#/lecons/${route.lesson}/${route.step}${route.practice ? "/a-vous" : ""}`;
|
||||
case "lessonEnd":
|
||||
return `#/lecons/${route.lesson}/fin`;
|
||||
case "install":
|
||||
return "#/installer";
|
||||
case "games":
|
||||
@@ -256,6 +314,8 @@ function screenHash(route: Route): string {
|
||||
return route.turn === null
|
||||
? `#/parties/${route.game}/analyse`
|
||||
: `#/parties/${route.game}/analyse/${route.turn}`;
|
||||
case "progression":
|
||||
return "#/progression";
|
||||
case "menu":
|
||||
return "#/menu";
|
||||
case "newGame":
|
||||
|
||||
@@ -225,6 +225,9 @@ export function createSummaryView(options: SummaryViewOptions): SummaryView {
|
||||
const play = element(box, "div", { id: "gauge-play", className: "gauge" });
|
||||
play.setAttribute("aria-label", REVIEW_TEXTS.playGauge(levelName));
|
||||
play.setAttribute("role", "img");
|
||||
// Le taux d'erreur du bilan, que la courbe de la progression (1h)
|
||||
// reprend tel quel (vérifié par tests/browser/progression.mjs).
|
||||
play.dataset.errorRate = String(summary.player.errorRate.perDecision);
|
||||
element(play, "span", { className: "gauge-name", text: REVIEW_TEXTS.yourPlay });
|
||||
const track = element(play, "span", { className: "gauge-track" });
|
||||
element(track, "span", {
|
||||
|
||||
+1
-1
@@ -188,7 +188,7 @@ export const TEXTS = {
|
||||
) =>
|
||||
[
|
||||
winner === null
|
||||
? "Vous avez la main : vos pions ivoire, en bas."
|
||||
? "Vous avez la main : vos pions blancs, en bas."
|
||||
: `La partie est finie dans cette position : ${
|
||||
winner === "player" ? "vous gagnez" : "l'ordinateur gagne"
|
||||
}.`,
|
||||
|
||||
@@ -19,14 +19,19 @@ export function placeOf(tutorial: Tutorial, route: Route): TutorialPlace {
|
||||
if (route.screen === "tutorialEnd") {
|
||||
return "end";
|
||||
}
|
||||
if (route.screen !== "tutorial" || route.step === null) {
|
||||
return { step: 1, practice: false };
|
||||
}
|
||||
const step = tutorial.steps[route.step - 1];
|
||||
return route.screen === "tutorial"
|
||||
? existingPlace(tutorial, route.step, route.practice)
|
||||
: { step: 1, practice: false };
|
||||
}
|
||||
|
||||
/** Le même écran qu'une adresse d'étape, s'il existe ; sinon la première
|
||||
* étape (étape absente), ou l'explication (« à vous » absent). */
|
||||
function existingPlace(tutorial: Tutorial, at: number | null, practice: boolean): TutorialPlace {
|
||||
const step = at === null ? undefined : tutorial.steps[at - 1];
|
||||
if (step === undefined) {
|
||||
return { step: 1, practice: false };
|
||||
}
|
||||
return { step: step.number, practice: route.practice && step.practice !== null };
|
||||
return { step: step.number, practice: practice && step.practice !== null };
|
||||
}
|
||||
|
||||
/** L'adresse d'un écran du tutoriel. */
|
||||
@@ -36,6 +41,24 @@ export function placeRoute(place: TutorialPlace): Route {
|
||||
: { screen: "tutorial", step: place.step, practice: place.practice };
|
||||
}
|
||||
|
||||
/** L'écran d'une leçon que désigne une adresse (#/lecons/<n>/...), ramené à
|
||||
* un écran qui existe, comme placeOf pour le tutoriel. */
|
||||
export function lessonPlaceOf(lesson: Tutorial, route: Route): TutorialPlace {
|
||||
if (route.screen === "lessonEnd") {
|
||||
return "end";
|
||||
}
|
||||
return route.screen === "lesson"
|
||||
? existingPlace(lesson, route.step, route.practice)
|
||||
: { step: 1, practice: false };
|
||||
}
|
||||
|
||||
/** L'adresse d'un écran de la leçon number. */
|
||||
export function lessonPlaceRoute(number: number, place: TutorialPlace): Route {
|
||||
return place === "end"
|
||||
? { screen: "lessonEnd", lesson: number }
|
||||
: { screen: "lesson", lesson: number, step: place.step, practice: place.practice };
|
||||
}
|
||||
|
||||
/** L'écran suivant (la fin après la dernière étape). */
|
||||
export function nextPlace(tutorial: Tutorial, place: TutorialPlace): TutorialPlace {
|
||||
if (place === "end") {
|
||||
|
||||
@@ -24,8 +24,11 @@ export interface TutorialScreen {
|
||||
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. */
|
||||
/** 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. */
|
||||
@@ -90,6 +93,17 @@ 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");
|
||||
@@ -110,6 +124,7 @@ 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") ?? ""),
|
||||
|
||||
+199
-120
@@ -2,11 +2,14 @@
|
||||
// #/tutoriel/fin) : la barre de progression, le titre, le plateau, le texte,
|
||||
// et les écrans « à vous » où le joueur fait le geste sur le vrai plateau
|
||||
// (docs/identite/charte/accueil-tutoriel-aide.md, « Tutoriel des règles »).
|
||||
// Les leçons (#/lecons/<n>/..., étape 1g) reprennent le même écran, avec
|
||||
// leur texte et leurs adresses (Course).
|
||||
//
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
//
|
||||
// Le texte vient de docs/tutoriel/regles.md (copie du site) ; les plateaux,
|
||||
// Le texte vient de docs/tutoriel/regles.md ou de docs/lecons/ (copies du
|
||||
// site) ; les plateaux,
|
||||
// les coups illustrés et le jugement des gestes viennent du domaine, branché
|
||||
// sur le moteur (TutorialAccess, assemblé dans main.ts). Aucune règle n'est
|
||||
// écrite ici.
|
||||
@@ -18,11 +21,14 @@ import type { Side } from "../domain/game/side.js";
|
||||
import type { PracticeVerdict, TutorialBoard } from "../domain/learning/tutorial.js";
|
||||
import {
|
||||
boardModel,
|
||||
homeBoard,
|
||||
moveTargets,
|
||||
moveTrace,
|
||||
onlyHalfMove,
|
||||
outerBoard,
|
||||
type Place,
|
||||
placeOf as placeOfChecker,
|
||||
pointSlot,
|
||||
} from "./board-model.js";
|
||||
import {
|
||||
type BoardDecor,
|
||||
@@ -36,20 +42,8 @@ import { boardGeometry, columnLeft, type Geometry, PORTRAIT_ASPECT } from "./geo
|
||||
import { HOME_TEXTS } from "./home-texts.js";
|
||||
import { icon } from "./icons.js";
|
||||
import { type Route, routeHash } from "./router.js";
|
||||
import {
|
||||
nextPlace,
|
||||
placeOf,
|
||||
placeRoute,
|
||||
previousPlace,
|
||||
progress,
|
||||
type TutorialPlace,
|
||||
} from "./tutorial-nav.js";
|
||||
import {
|
||||
type Paragraph,
|
||||
parseTutorial,
|
||||
type Tutorial,
|
||||
type TutorialScreen,
|
||||
} from "./tutorial-text.js";
|
||||
import { nextPlace, previousPlace, progress, type TutorialPlace } from "./tutorial-nav.js";
|
||||
import type { Paragraph, Tutorial, TutorialScreen } from "./tutorial-text.js";
|
||||
|
||||
/** Le coup illustré se joue tout seul après une seconde. */
|
||||
const AUTOPLAY_MS = 1000;
|
||||
@@ -58,7 +52,32 @@ const PAUSE_MS = 120;
|
||||
/** Un pion refusé reste un instant à sa place avant de revenir. */
|
||||
const REFUSED_MS = 450;
|
||||
|
||||
const SOURCE = "docs/tutoriel/regles.md";
|
||||
/** Le texte du tutoriel, copié dans le site (make site). */
|
||||
export const TUTORIAL_SOURCE = "docs/tutoriel/regles.md";
|
||||
|
||||
/**
|
||||
* Un texte du site, lu une fois puis gardé : la fonction renvoyée le
|
||||
* télécharge à son premier appel et le relit avec parse. Un échec n'est
|
||||
* pas gardé : l'appel suivant réessaie.
|
||||
*/
|
||||
export function cachedText<T>(source: string, parse: (text: string) => T): () => Promise<T> {
|
||||
let loaded: Promise<T> | null = null;
|
||||
return () => {
|
||||
loaded ??= fetch(source)
|
||||
.then((response) => {
|
||||
if (!response.ok) {
|
||||
throw new Error(`${source} : ${response.status}`);
|
||||
}
|
||||
return response.text();
|
||||
})
|
||||
.then(parse)
|
||||
.catch((error: unknown) => {
|
||||
loaded = null;
|
||||
throw error;
|
||||
});
|
||||
return loaded;
|
||||
};
|
||||
}
|
||||
|
||||
/** Un écran « à vous » où l'on joue un coup (MovePractice du domaine). */
|
||||
export interface PracticeSession {
|
||||
@@ -84,14 +103,53 @@ export interface TutorialAccess {
|
||||
shouldTake(position: string): Promise<boolean>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Un parcours d'écrans courts : le tutoriel des règles, ou une leçon (étape
|
||||
* 1g), qui en reprennent la forme (charte, « 1g. Leçons »). Ce qui les
|
||||
* distingue : leur texte, leurs adresses, leurs libellés, et ce que retient
|
||||
* la progression.
|
||||
*/
|
||||
export interface Course {
|
||||
/** Préfixe des identifiants des éléments de l'écran (« tutorial »,
|
||||
* « lesson ») : deux parcours peuvent coexister dans la page. */
|
||||
readonly id: string;
|
||||
/** Le texte du parcours que désigne l'adresse. */
|
||||
load(route: Route): Promise<Tutorial>;
|
||||
/** L'écran que désigne l'adresse, ramené à un écran qui existe. */
|
||||
placeOf(text: Tutorial, route: Route): TutorialPlace;
|
||||
/** L'adresse d'un écran du même parcours que route. */
|
||||
routeOf(route: Route, place: TutorialPlace): Route;
|
||||
/** La dernière étape déjà vue : la barre de progression n'en mène pas
|
||||
* plus loin. */
|
||||
seen(route: Route): number;
|
||||
/** L'arrivée sur un écran. */
|
||||
visit(route: Route, place: TutorialPlace): void;
|
||||
/** Un écran « à vous » réussi. */
|
||||
solved(route: Route, place: TutorialPlace): void;
|
||||
/** « Quitter » : où il mène. */
|
||||
quitHref(route: Route): string;
|
||||
/** « Retour » sur le premier écran. */
|
||||
firstBackHref(route: Route): string;
|
||||
/** L'action de l'écran de fin. */
|
||||
endHref(route: Route): string;
|
||||
readonly texts: {
|
||||
readonly progress: string;
|
||||
readonly stepLink: (n: number, title: string) => string;
|
||||
readonly quit: string;
|
||||
readonly quitLabel: string;
|
||||
readonly overline: (text: Tutorial, n: number, total: number, practice: boolean) => string;
|
||||
readonly endOverline: (text: Tutorial) => string;
|
||||
readonly endTitle: (text: Tutorial) => string;
|
||||
readonly loading: string;
|
||||
readonly failed: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface TutorialViewOptions {
|
||||
readonly section: HTMLElement;
|
||||
readonly access: TutorialAccess;
|
||||
readonly engineReady: Promise<unknown>;
|
||||
/** La dernière étape vue, et son enregistrement à l'arrivée sur un
|
||||
* écran. */
|
||||
readonly seen: () => number;
|
||||
readonly visit: (place: TutorialPlace) => void;
|
||||
readonly course: Course;
|
||||
/** Un geste juste : la vibration « pion posé ». */
|
||||
readonly placed: () => void;
|
||||
readonly reducedMotion: MediaQueryList;
|
||||
@@ -149,79 +207,99 @@ export function fillParagraph(target: HTMLElement, paragraph: Paragraph): void {
|
||||
|
||||
const SVG_NS = "http://www.w3.org/2000/svg";
|
||||
|
||||
/** Le sens du jeu des pions du joueur, en pointillés : du haut à droite
|
||||
* (flèche 24) vers la gauche, puis en bas jusqu'à la flèche 1 et la sortie.
|
||||
* Tracé sous les pions et sous les numéros des flèches, qu'il ne cache
|
||||
* jamais. pxPerUnit : pixels à l'écran par unité du dessin (le liseré
|
||||
* ivoire fait 8 px, les points 4 px, quelle que soit la taille). */
|
||||
function drawPath(svg: SVGSVGElement, g: Geometry, pxPerUnit: number): void {
|
||||
/** Le sens du jeu : deux flèches circulaires, une claire pour les pions du
|
||||
* joueur (du haut à droite vers la gauche, puis en bas jusqu'à son jan
|
||||
* intérieur), une sombre pour ceux de l'ordinateur, en sens inverse et à
|
||||
* l'intérieur de la première. Chacune a un liseré de la couleur opposée et
|
||||
* finit par une pointe ; jamais en brique (la brique veut dire « à faire »).
|
||||
* Dessinées sous les pions, qu'elles ne cachent jamais. pxPerUnit : pixels
|
||||
* à l'écran par unité du dessin (trait de 5 px sur un liseré de 9 px,
|
||||
* quelle que soit la taille du plateau). */
|
||||
function drawDirections(svg: SVGSVGElement, g: Geometry, pxPerUnit: number): void {
|
||||
const px = (n: number) => n / pxPerUnit;
|
||||
const center = (column: number) => columnLeft(g, column) + g.pointWidth / 2;
|
||||
const top = g.height * 0.3;
|
||||
const bottom = g.height * 0.7;
|
||||
const right = center(11);
|
||||
const left = center(0) - g.pointWidth * 0.25;
|
||||
const turn = g.pointWidth * 0.6;
|
||||
const end = g.trayLeft + g.tray * 0.25;
|
||||
const d =
|
||||
`M ${right} ${top} L ${left + turn} ${top} Q ${left} ${top} ${left} ${top + turn} ` +
|
||||
`L ${left} ${bottom - turn} Q ${left} ${bottom} ${left + turn} ${bottom} L ${end} ${bottom}`;
|
||||
const group = document.createElementNS(SVG_NS, "g");
|
||||
group.setAttribute("class", "tutorial-path");
|
||||
group.setAttribute("role", "img");
|
||||
group.setAttribute("aria-label", HOME_TEXTS.pathLabel);
|
||||
const px = (n: number) => n / pxPerUnit;
|
||||
const width = px(4);
|
||||
for (const [className, stroke] of [
|
||||
["path-edge", px(8)],
|
||||
["path-dots", width],
|
||||
] as const) {
|
||||
const path = document.createElementNS(SVG_NS, "path");
|
||||
path.setAttribute("class", className);
|
||||
path.setAttribute("d", d);
|
||||
path.setAttribute("stroke-width", String(stroke));
|
||||
if (className === "path-dots") {
|
||||
path.setAttribute("stroke-dasharray", `0.1 ${px(10)}`);
|
||||
// Chaque flèche part du jan intérieur adverse (sa flèche 24), longe les
|
||||
// deux jans extérieurs, tourne au bout du sien et finit dans son jan
|
||||
// intérieur. Celle de l'ordinateur passe à l'intérieur de la claire.
|
||||
const column = (point: number) => pointSlot(point).column;
|
||||
const loops = [
|
||||
{
|
||||
side: "player",
|
||||
from: g.height * 0.29,
|
||||
to: g.height * 0.71,
|
||||
left: center(column(outerBoard("player").points[0] ?? 12)) - g.pointWidth * 0.1,
|
||||
},
|
||||
{
|
||||
side: "computer",
|
||||
from: g.height * 0.62,
|
||||
to: g.height * 0.38,
|
||||
left: center(column(outerBoard("computer").points[0] ?? 13) + 1) + g.pointWidth * 0.5,
|
||||
},
|
||||
] as const;
|
||||
const head = px(18);
|
||||
const half = px(10);
|
||||
for (const { side, from, to, left } of loops) {
|
||||
const other = side === "player" ? "computer" : "player";
|
||||
const start = center(column(homeBoard(other).points[5] ?? 1));
|
||||
const end = center(column(homeBoard(side).points[4] ?? 2)) + g.pointWidth * 0.3;
|
||||
const turn = Math.min(Math.abs(to - from) / 2, g.pointWidth * 0.9);
|
||||
const down = to > from ? 1 : -1;
|
||||
const d =
|
||||
`M ${start} ${from} L ${left + turn} ${from} Q ${left} ${from} ${left} ${from + down * turn} ` +
|
||||
`L ${left} ${to - down * turn} Q ${left} ${to} ${left + turn} ${to} L ${end - head / 2} ${to}`;
|
||||
for (const [part, stroke] of [
|
||||
["edge", px(9)],
|
||||
["line", px(5)],
|
||||
] as const) {
|
||||
const path = document.createElementNS(SVG_NS, "path");
|
||||
path.setAttribute("class", `direction-${part} direction-${side}`);
|
||||
path.setAttribute("d", d);
|
||||
path.setAttribute("stroke-width", String(stroke));
|
||||
group.append(path);
|
||||
}
|
||||
group.append(path);
|
||||
}
|
||||
const head = document.createElementNS(SVG_NS, "path");
|
||||
head.setAttribute("class", "path-head");
|
||||
const size = px(7);
|
||||
head.setAttribute(
|
||||
"d",
|
||||
`M ${end - size} ${bottom - size} L ${end} ${bottom} L ${end - size} ${bottom + size}`,
|
||||
);
|
||||
head.setAttribute("stroke-width", String(px(3)));
|
||||
group.append(head);
|
||||
// Sous les pions, et sous les numéros des flèches, remontés au-dessus
|
||||
// du tracé ; les zones à toucher restent devant tout.
|
||||
const pieces = svg.querySelector(":scope > g.pieces");
|
||||
svg.insertBefore(group, pieces);
|
||||
for (const number of svg.querySelectorAll(":scope > .point-number")) {
|
||||
svg.insertBefore(number, pieces);
|
||||
const tip = document.createElementNS(SVG_NS, "path");
|
||||
tip.setAttribute("class", `direction-head direction-${side}`);
|
||||
tip.setAttribute(
|
||||
"d",
|
||||
`M ${end - head} ${to - half} L ${end} ${to} L ${end - head} ${to + half} Z`,
|
||||
);
|
||||
tip.setAttribute("stroke-width", String(px(2)));
|
||||
group.append(tip);
|
||||
}
|
||||
// Sous les pions ; les zones à toucher restent devant tout.
|
||||
svg.insertBefore(group, svg.querySelector(":scope > g.pieces"));
|
||||
}
|
||||
|
||||
/** Crée l'écran du tutoriel dans section (vide dans index.html). */
|
||||
export function createTutorialView(options: TutorialViewOptions): TutorialView {
|
||||
const { section, access } = options;
|
||||
const { section, access, course } = options;
|
||||
const idOf = (name: string) => `${course.id}-${name}`;
|
||||
section.replaceChildren();
|
||||
const head = element(section, "header", { className: "tutorial-head" });
|
||||
const bar = element(head, "nav", { id: "tutorial-progress", className: "tutorial-progress" });
|
||||
bar.setAttribute("aria-label", HOME_TEXTS.tutorialProgress);
|
||||
const skip = element(head, "a", { id: "tutorial-skip", className: "tutorial-skip" });
|
||||
const bar = element(head, "nav", { id: idOf("progress"), className: "tutorial-progress" });
|
||||
bar.setAttribute("aria-label", course.texts.progress);
|
||||
const skip = element(head, "a", { id: idOf("skip"), className: "tutorial-skip" });
|
||||
skip.href = "#/";
|
||||
skip.textContent = HOME_TEXTS.tutorialSkip;
|
||||
skip.setAttribute("aria-label", HOME_TEXTS.tutorialSkipLabel);
|
||||
const overline = element(section, "p", { id: "tutorial-overline", className: "overline" });
|
||||
const title = element(section, "h2", { id: "tutorial-title", className: "tutorial-title" });
|
||||
skip.textContent = course.texts.quit;
|
||||
skip.setAttribute("aria-label", course.texts.quitLabel);
|
||||
const overline = element(section, "p", { id: idOf("overline"), className: "overline" });
|
||||
// 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" });
|
||||
const boardBox = element(section, "div", { className: "tutorial-board-box" });
|
||||
const svg = document.createElementNS(SVG_NS, "svg");
|
||||
svg.id = "tutorial-board";
|
||||
svg.id = idOf("board");
|
||||
svg.setAttribute("class", "board tutorial-board");
|
||||
svg.setAttribute("role", "img");
|
||||
boardBox.append(svg);
|
||||
const replay = element(boardBox, "button", { id: "tutorial-replay", className: "icon-button" });
|
||||
const replay = element(titleRow, "button", {
|
||||
id: idOf("replay"),
|
||||
className: "icon-button tutorial-replay",
|
||||
});
|
||||
replay.type = "button";
|
||||
replay.append(icon("replay"));
|
||||
replay.setAttribute("aria-label", HOME_TEXTS.replayLabel);
|
||||
@@ -229,39 +307,43 @@ export function createTutorialView(options: TutorialViewOptions): TutorialView {
|
||||
// Le texte, la consigne « à vous » et la réponse : en paysage, la seule
|
||||
// partie qui défile si l'écran est trop bas (app.css).
|
||||
const body = element(section, "div", { className: "tutorial-body" });
|
||||
const text = element(body, "div", { id: "tutorial-text", className: "tutorial-text" });
|
||||
const task = element(body, "p", { id: "tutorial-task", className: "tutorial-task" });
|
||||
const text = element(body, "div", { id: idOf("text"), className: "tutorial-text" });
|
||||
const task = element(body, "p", { id: idOf("task"), className: "tutorial-task" });
|
||||
const feedback = element(body, "p", {
|
||||
id: "tutorial-feedback",
|
||||
id: idOf("feedback"),
|
||||
className: "tutorial-feedback",
|
||||
});
|
||||
feedback.setAttribute("role", "status");
|
||||
const cube = element(section, "div", { id: "tutorial-cube", className: "tutorial-cube" });
|
||||
const cube = element(section, "div", { id: idOf("cube"), className: "tutorial-cube" });
|
||||
const drop = element(cube, "button", {
|
||||
id: "tutorial-drop",
|
||||
id: idOf("drop"),
|
||||
className: "secondary",
|
||||
text: HOME_TEXTS.drop,
|
||||
});
|
||||
drop.type = "button";
|
||||
const take = element(cube, "button", {
|
||||
id: "tutorial-take",
|
||||
id: idOf("take"),
|
||||
className: "secondary",
|
||||
text: HOME_TEXTS.take,
|
||||
});
|
||||
take.type = "button";
|
||||
const nav = element(section, "div", { className: "tutorial-nav" });
|
||||
const back = element(nav, "a", {
|
||||
id: "tutorial-back",
|
||||
id: idOf("back"),
|
||||
className: "button secondary",
|
||||
text: HOME_TEXTS.back,
|
||||
});
|
||||
const next = element(nav, "a", {
|
||||
id: "tutorial-next",
|
||||
id: idOf("next"),
|
||||
className: "button",
|
||||
text: HOME_TEXTS.next,
|
||||
});
|
||||
|
||||
let tutorial: Promise<Tutorial> | null = null;
|
||||
// Le dernier texte lu : tant qu'il n'y en a pas, l'écran dit qu'il se
|
||||
// charge.
|
||||
let lastText: Tutorial | null = null;
|
||||
// L'adresse affichée.
|
||||
let shownRoute: Route | null = null;
|
||||
// Chaque affichage a son numéro : un affichage dépassé ne touche plus
|
||||
// à l'écran (le joueur est déjà ailleurs).
|
||||
let shown = 0;
|
||||
@@ -285,22 +367,6 @@ export function createTutorialView(options: TutorialViewOptions): TutorialView {
|
||||
let selected: Place | null = null;
|
||||
let solved = false;
|
||||
|
||||
const load = () => {
|
||||
tutorial ??= fetch(SOURCE)
|
||||
.then((response) => {
|
||||
if (!response.ok) {
|
||||
throw new Error(`${SOURCE} : ${response.status}`);
|
||||
}
|
||||
return response.text();
|
||||
})
|
||||
.then(parseTutorial)
|
||||
.catch((error: unknown) => {
|
||||
tutorial = null;
|
||||
throw error;
|
||||
});
|
||||
return tutorial;
|
||||
};
|
||||
|
||||
/** En paysage, le plateau occupe toute la hauteur, à gauche de la
|
||||
* colonne du texte (app.css), avec la géométrie du jeu en paysage. */
|
||||
function geometry(): Geometry {
|
||||
@@ -359,7 +425,7 @@ export function createTutorialView(options: TutorialViewOptions): TutorialView {
|
||||
};
|
||||
renderBoard(svg, g, boardModel(current), decor);
|
||||
if (screen?.path === true) {
|
||||
drawPath(svg, g, (svg.getBoundingClientRect().width || g.width) / g.width);
|
||||
drawDirections(svg, g, (svg.getBoundingClientRect().width || g.width) / g.width);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -439,6 +505,9 @@ export function createTutorialView(options: TutorialViewOptions): TutorialView {
|
||||
take.disabled = true;
|
||||
drop.disabled = true;
|
||||
options.placed();
|
||||
if (shownRoute !== null && place !== null) {
|
||||
course.solved(shownRoute, place);
|
||||
}
|
||||
}
|
||||
|
||||
async function playHalfMove(halfMove: HalfMove): Promise<void> {
|
||||
@@ -533,17 +602,21 @@ export function createTutorialView(options: TutorialViewOptions): TutorialView {
|
||||
take.addEventListener("click", () => void answer(true));
|
||||
drop.addEventListener("click", () => void answer(false));
|
||||
|
||||
function renderProgress(total: number, at: TutorialPlace, steps: Tutorial["steps"]): void {
|
||||
function renderProgress(route: Route, at: TutorialPlace, loaded: Tutorial): void {
|
||||
const steps = loaded.steps;
|
||||
const total = steps.length;
|
||||
bar.replaceChildren();
|
||||
for (const segment of progress(total, at, options.seen())) {
|
||||
for (const segment of progress(total, at, course.seen(route))) {
|
||||
const stepTitle = steps[segment.step - 1]?.title ?? "";
|
||||
const label = HOME_TEXTS.tutorialStepLink(segment.step, stepTitle);
|
||||
const label = course.texts.stepLink(segment.step, stepTitle);
|
||||
const item = segment.link
|
||||
? document.createElement("a")
|
||||
: document.createElement("span");
|
||||
item.className = `segment${segment.done ? " done" : ""}`;
|
||||
if (item instanceof HTMLAnchorElement) {
|
||||
item.href = routeHash({ screen: "tutorial", step: segment.step, practice: false });
|
||||
item.href = routeHash(
|
||||
course.routeOf(route, { step: segment.step, practice: false }),
|
||||
);
|
||||
item.setAttribute("aria-label", label);
|
||||
} else {
|
||||
item.setAttribute("aria-hidden", "true");
|
||||
@@ -556,8 +629,8 @@ export function createTutorialView(options: TutorialViewOptions): TutorialView {
|
||||
bar.setAttribute(
|
||||
"aria-description",
|
||||
at === "end"
|
||||
? HOME_TEXTS.tutorialEndOverline
|
||||
: HOME_TEXTS.tutorialOverline(at.step, total, at.practice),
|
||||
? course.texts.endOverline(loaded)
|
||||
: course.texts.overline(loaded, at.step, total, at.practice),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -586,42 +659,48 @@ export function createTutorialView(options: TutorialViewOptions): TutorialView {
|
||||
drop.disabled = false;
|
||||
let loaded: Tutorial;
|
||||
try {
|
||||
if (tutorial === null) {
|
||||
title.textContent = HOME_TEXTS.tutorialLoading;
|
||||
if (lastText === null) {
|
||||
title.textContent = course.texts.loading;
|
||||
text.replaceChildren();
|
||||
svg.replaceChildren();
|
||||
}
|
||||
loaded = await load();
|
||||
loaded = await course.load(route);
|
||||
} catch {
|
||||
if (id === shown) {
|
||||
title.textContent = HOME_TEXTS.tutorialFailed;
|
||||
title.textContent = course.texts.failed;
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (id !== shown) {
|
||||
return;
|
||||
}
|
||||
const at = placeOf(loaded, route);
|
||||
const wanted = routeHash(placeRoute(at));
|
||||
lastText = loaded;
|
||||
const at = course.placeOf(loaded, route);
|
||||
const wanted = routeHash(course.routeOf(route, at));
|
||||
if (routeHash(route) !== wanted) {
|
||||
options.replace(wanted);
|
||||
return;
|
||||
}
|
||||
place = at;
|
||||
options.visit(at);
|
||||
shownRoute = route;
|
||||
course.visit(route, at);
|
||||
const total = loaded.steps.length;
|
||||
renderProgress(total, at, loaded.steps);
|
||||
renderProgress(route, at, loaded);
|
||||
skip.href = course.quitHref(route);
|
||||
const before = previousPlace(loaded, at);
|
||||
back.href = before === null ? "#/accueil" : routeHash(placeRoute(before));
|
||||
back.href =
|
||||
before === null
|
||||
? course.firstBackHref(route)
|
||||
: routeHash(course.routeOf(route, before));
|
||||
if (at === "end") {
|
||||
overline.textContent = HOME_TEXTS.tutorialEndOverline;
|
||||
title.textContent = HOME_TEXTS.tutorialEndTitle;
|
||||
overline.textContent = course.texts.endOverline(loaded);
|
||||
title.textContent = course.texts.endTitle(loaded);
|
||||
text.replaceChildren();
|
||||
for (const paragraph of loaded.end.paragraphs) {
|
||||
fillParagraph(element(text, "p"), paragraph);
|
||||
}
|
||||
next.textContent = loaded.end.action;
|
||||
next.href = "#/";
|
||||
next.href = course.endHref(route);
|
||||
next.classList.remove("secondary");
|
||||
screen = loaded.steps[0]?.explain ?? null;
|
||||
} else {
|
||||
@@ -631,7 +710,7 @@ export function createTutorialView(options: TutorialViewOptions): TutorialView {
|
||||
return;
|
||||
}
|
||||
screen = { ...current, path: current.path && !at.practice };
|
||||
overline.textContent = HOME_TEXTS.tutorialOverline(at.step, total, at.practice);
|
||||
overline.textContent = course.texts.overline(loaded, at.step, total, at.practice);
|
||||
title.textContent = step.title;
|
||||
text.replaceChildren();
|
||||
const paragraphs = at.practice ? [] : current.paragraphs;
|
||||
@@ -652,7 +731,7 @@ export function createTutorialView(options: TutorialViewOptions): TutorialView {
|
||||
}
|
||||
const after = nextPlace(loaded, at);
|
||||
next.textContent = HOME_TEXTS.next;
|
||||
next.href = routeHash(placeRoute(after));
|
||||
next.href = routeHash(course.routeOf(route, after));
|
||||
next.classList.toggle("secondary", at.practice);
|
||||
}
|
||||
const currentScreen = screen;
|
||||
|
||||
@@ -35,6 +35,35 @@ test("ouverture 3-1 : 24/23 13/10 est une erreur très mauvaise, 8/5 6/5 le meil
|
||||
Math.abs(analysis.equityLoss - (analysis.played.equity - analysis.best.equity)) < 1e-6,
|
||||
);
|
||||
assert.ok(analysis.equityLoss < -SKILL_THRESHOLDS.veryBad);
|
||||
// Le classement de l'analyse, comme gnubg de bureau
|
||||
// (tests/reference/analysis/move-opening-31-error.json) : le meilleur
|
||||
// coup et le coup joué, une erreur, réévalués à 2 plis ; les suivants
|
||||
// restés à 1 pli, puis à 0, écartés par les filtres.
|
||||
assert.deepEqual(analysis.ranking[0], analysis.best);
|
||||
assert.deepEqual(analysis.ranking[1], analysis.played);
|
||||
assert.deepEqual(
|
||||
analysis.ranking.map((move) => move.ply),
|
||||
[2, 2, 1, 1, 1, 0, 0, 0, 0, 0],
|
||||
);
|
||||
});
|
||||
|
||||
test("ouverture 3-1 jouée 8/5 6/5 : un seul coup passe le filtre, tout reste à 1 pli", async () => {
|
||||
const engine = await loadNodeEngine();
|
||||
const analysis = await engine.analyseMove(
|
||||
START_POSITION,
|
||||
centered,
|
||||
[3, 1],
|
||||
"4HPwATCwZ/ABMA",
|
||||
2,
|
||||
);
|
||||
assert.ok(analysis !== null);
|
||||
assert.equal(analysis.rank, 1);
|
||||
assert.deepEqual(analysis.ranking[0], analysis.best);
|
||||
assert.deepEqual(analysis.best, analysis.played);
|
||||
assert.deepEqual(
|
||||
analysis.ranking.slice(0, 5).map((move) => move.ply),
|
||||
[1, 1, 1, 1, 1],
|
||||
);
|
||||
});
|
||||
|
||||
test("sans coup possible, il n'y a rien à analyser", async () => {
|
||||
|
||||
@@ -73,7 +73,6 @@ function fakeEngine(overrides: Partial<Engine> = {}): Engine {
|
||||
};
|
||||
},
|
||||
analyseMove: async () => null,
|
||||
rankMoves: async () => [],
|
||||
analyseCube: async () => ({
|
||||
analysed: false,
|
||||
equities: null,
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { test } from "node:test";
|
||||
import { homeModel } from "../src/ui/home-model.js";
|
||||
import { installPlatform } from "../src/ui/install.js";
|
||||
import {
|
||||
DEFAULT_SETTINGS,
|
||||
@@ -45,6 +46,20 @@ test("réglages d'une version précédente : les nouveaux champs prennent leur v
|
||||
assert.equal(parseSettings('{"tutorialSeen": -3, "welcomed": "oui"}').tutorialSeen, 0);
|
||||
});
|
||||
|
||||
test("carte de la partie : sans partie en cours, ni miniature ni compte de la partie finie", () => {
|
||||
const settings = { ...DEFAULT_SETTINGS, welcomed: true, gamesFinished: 1 };
|
||||
// Partie en cours, ou moteur qui se charge : la miniature et le compte.
|
||||
assert.deepEqual(
|
||||
[homeModel(settings, true, false).board, homeModel(settings, true, false).resume],
|
||||
[true, true],
|
||||
);
|
||||
assert.equal(homeModel(settings, null, false).board, true);
|
||||
// Partie finie : le titre et « Nouvelle partie » seulement (recette 1h).
|
||||
const over = homeModel(settings, false, false);
|
||||
assert.equal(over.board, false);
|
||||
assert.equal(over.resume, false);
|
||||
});
|
||||
|
||||
test("premier lancement : tant que rien n'a été fait, une seule fois", () => {
|
||||
assert.ok(isFirstLaunch(DEFAULT_SETTINGS));
|
||||
// La partie commencée d'office au chargement ne compte pas.
|
||||
|
||||
@@ -9,7 +9,7 @@ import assert from "node:assert/strict";
|
||||
import { readdirSync, readFileSync, statSync } from "node:fs";
|
||||
import { test } from "node:test";
|
||||
import { createSettingsStore } from "../src/storage/settings-store.js";
|
||||
import { OPENING_BOARDS, pipLead } from "../src/ui/board-model.js";
|
||||
import { homeBoard, OPENING_BOARDS, outerBoard, pipLead } from "../src/ui/board-model.js";
|
||||
import { BOARD_HEIGHT, boardGeometry, MAX_STACK, stackY } from "../src/ui/geometry.js";
|
||||
import { createHaptics, HAPTIC_PATTERNS } from "../src/ui/haptics.js";
|
||||
import { ICON_PATHS } from "../src/ui/icons.js";
|
||||
@@ -37,15 +37,32 @@ const LANDSCAPES: [number, number][] = [
|
||||
[800, 308],
|
||||
];
|
||||
|
||||
test("géométrie en portrait : les mesures du plateau d'origine", () => {
|
||||
test("géométrie en portrait : les mesures du plateau d'origine, numéros sur le cadre", () => {
|
||||
const g = boardGeometry("portrait", 640 / 520, 396 / 640);
|
||||
assert.equal(g.width, 640);
|
||||
assert.equal(g.pointWidth, 44);
|
||||
assert.equal(g.radius, 20);
|
||||
assert.equal(g.step, 42);
|
||||
// Pions d'un peu moins de 19 unités (20 avant les numéros sur le cadre).
|
||||
assert.ok(g.radius >= 18.5 && g.radius <= 19, `pions de ${g.radius}`);
|
||||
assert.equal(g.step, 2 * g.radius + 2);
|
||||
assert.equal(g.trayLeft + g.tray + g.frame, 640);
|
||||
// Numéros d'au moins 12 px à l'écran, à 412 px de large.
|
||||
// Numéros d'au moins 12 px à l'écran, à 412 px de large, logés dans les
|
||||
// bandes haute et basse du cadre (retours des premiers joueurs).
|
||||
assert.ok(g.numberSize * (396 / 640) >= 12, `numéros de ${g.numberSize} unités`);
|
||||
assert.ok(g.numberSize + 8 <= g.edge, `bande de ${g.edge} pour des numéros de ${g.numberSize}`);
|
||||
});
|
||||
|
||||
test("géométrie en portrait : numéros d'au moins 12 px sur le cadre dès 275 px de large", () => {
|
||||
for (const width of [275, 320, 358, 380, 396, 600]) {
|
||||
const px = width / 640;
|
||||
const g = boardGeometry("portrait", 640 / 520, px);
|
||||
assert.ok(g.numberSize * px >= 12, `${width} px : numéros de ${g.numberSize * px} px`);
|
||||
assert.ok(g.numberSize < g.edge, `${width} px : numéros hors du cadre`);
|
||||
assert.ok(g.edge <= 36, `${width} px : bande de ${g.edge}`);
|
||||
}
|
||||
// Une miniature : la bande reste de 36 unités, les numéros y tiennent.
|
||||
const thumbnail = boardGeometry("portrait", 640 / 520, 200 / 640);
|
||||
assert.equal(thumbnail.edge, 36);
|
||||
assert.ok(thumbnail.numberSize < thumbnail.edge);
|
||||
});
|
||||
|
||||
test("géométrie en paysage : le plateau remplit l'écran, flèches de 44 px et pions de 22 px au moins", () => {
|
||||
@@ -93,6 +110,13 @@ test("en paysage, les pions de la barre s'arrêtent avant les pastilles des comp
|
||||
}
|
||||
});
|
||||
|
||||
test("les jans de chaque camp, numérotés pour le joueur, dans le sens de marche", () => {
|
||||
assert.deepEqual(homeBoard("player").points, [6, 5, 4, 3, 2, 1]);
|
||||
assert.deepEqual(outerBoard("player").points, [12, 11, 10, 9, 8, 7]);
|
||||
assert.deepEqual(homeBoard("computer").points, [19, 20, 21, 22, 23, 24]);
|
||||
assert.deepEqual(outerBoard("computer").points, [13, 14, 15, 16, 17, 18]);
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------- Vibrations
|
||||
|
||||
test("vibrations : les motifs de la charte, et rien quand elles sont coupées", () => {
|
||||
|
||||
@@ -0,0 +1,440 @@
|
||||
// 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,266 @@
|
||||
// Tests de la progression (étape 1h) : le taux d'erreur de chaque partie
|
||||
// terminée est celui du bilan, lu dans les analyses rangées sans rien
|
||||
// recalculer ; la courbe, sa tendance et la phrase du maître ; le prompt
|
||||
// « Créer des exercices » sur plusieurs parties (thèmes des erreurs, niveau
|
||||
// du joueur à partir de 20 décisions cumulées).
|
||||
//
|
||||
// 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 { test } from "node:test";
|
||||
import {
|
||||
missingAnalyses,
|
||||
readyForSummary,
|
||||
runAnalysis,
|
||||
summarizeAnalysis,
|
||||
} from "../src/domain/analysis/game-analysis.js";
|
||||
import { PHRASE_KEYS, progressionSentence } from "../src/domain/analysis/phrases.js";
|
||||
import {
|
||||
CURVE_MIN_GAMES,
|
||||
errorRateMillis,
|
||||
type FinishedGame,
|
||||
progressionCurve,
|
||||
progressionEntries,
|
||||
progressionTrend,
|
||||
} from "../src/domain/analysis/progression.js";
|
||||
import type { Engine } from "../src/domain/game/engine.js";
|
||||
import type { GameJournal } from "../src/domain/game/events.js";
|
||||
import { currentLine } from "../src/domain/game/events.js";
|
||||
import { Game } from "../src/domain/game/game.js";
|
||||
import { replay } from "../src/domain/game/state.js";
|
||||
import {
|
||||
exercisesPosition,
|
||||
exercisesPrompt,
|
||||
LEVEL_MIN_DECISIONS,
|
||||
LEVEL_NAMES,
|
||||
mistakeThemes,
|
||||
} from "../src/domain/prompts/prompts.js";
|
||||
import { PROMPT_FILES, parsePromptTemplate } from "../src/domain/prompts/templates.js";
|
||||
import { HOME_TEXTS } from "../src/ui/home-texts.js";
|
||||
import { PROGRESSION_TEXTS } from "../src/ui/progression-texts.js";
|
||||
import { parseRoute, routeHash } from "../src/ui/router.js";
|
||||
import { loadPhrases } from "./support/advice.js";
|
||||
import { loadNodeEngine } from "./support/node-engine.js";
|
||||
import { playToTheEnd, seededDice } from "./support/play.js";
|
||||
|
||||
const root = new URL("../../../", import.meta.url);
|
||||
const phrases = loadPhrases();
|
||||
const point = (id: string, perDecision: number) => ({ id, perDecision });
|
||||
|
||||
async function analyseAll(engine: Engine, journal: GameJournal): Promise<GameJournal> {
|
||||
let current = journal;
|
||||
for (let i = 0; i < 500; i++) {
|
||||
const next = missingAnalyses(current)[0];
|
||||
if (next === undefined) {
|
||||
return current;
|
||||
}
|
||||
current = await runAnalysis(engine, current, next);
|
||||
}
|
||||
throw new Error("l'analyse ne finit pas");
|
||||
}
|
||||
|
||||
/** Une course courte jouée jusqu'au bout (le joueur prend le choice-ième
|
||||
* coup légal), non analysée. */
|
||||
async function race(engine: Engine, seed: number, choice: number): Promise<GameJournal> {
|
||||
const game = Game.start(engine, {
|
||||
setup: { position: "trsXAADs7mwAAA", firstTurn: "player" },
|
||||
level: "intermediate",
|
||||
dice: seededDice(seed),
|
||||
});
|
||||
await playToTheEnd(game, engine, choice);
|
||||
return game.journal;
|
||||
}
|
||||
|
||||
function finished(id: string, startedAt: number, journal: GameJournal): FinishedGame {
|
||||
return {
|
||||
id,
|
||||
startedAt,
|
||||
level: "intermediate",
|
||||
result: replay(currentLine(journal)).result,
|
||||
journal,
|
||||
};
|
||||
}
|
||||
|
||||
test("l'adresse #/progression et celle de « Créer des exercices »", () => {
|
||||
assert.deepEqual(parseRoute("#/progression"), { screen: "progression" });
|
||||
assert.equal(routeHash({ screen: "progression", ai: true }), "#/progression/ia");
|
||||
});
|
||||
|
||||
test("le résultat d'une partie dit d'où viennent les points : gammon, videau", () => {
|
||||
const result = PROGRESSION_TEXTS.result;
|
||||
const lost = (kind: "single" | "gammon" | "backgammon", points: number) =>
|
||||
({ winner: "computer", kind, points }) as const;
|
||||
assert.equal(result({ winner: "player", kind: "single", points: 1 }, 1), "Gagnée, 1 point");
|
||||
assert.equal(result(lost("single", 1), 1), "Perdue, 1 point");
|
||||
assert.equal(result(lost("gammon", 2), 1), "Perdue, gammon, 2 points");
|
||||
assert.equal(result(lost("single", 2), 2), "Perdue, videau à 2, 2 points");
|
||||
assert.equal(result(lost("gammon", 4), 2), "Perdue, gammon, videau à 2, 4 points");
|
||||
assert.equal(result(lost("backgammon", 12), 4), "Perdue, backgammon, videau à 4, 12 points");
|
||||
assert.equal(result(null, 1), "Partie terminée");
|
||||
});
|
||||
|
||||
test("la ligne de l'accueil : « aucune gagnée », comme « aucune leçon terminée »", () => {
|
||||
assert.equal(HOME_TEXTS.progress(3, 0), "3 parties jouées · aucune gagnée");
|
||||
assert.equal(HOME_TEXTS.progress(3, 1), "3 parties jouées · 1 gagnée");
|
||||
assert.equal(HOME_TEXTS.progress(0, 0), "aucune partie jouée");
|
||||
});
|
||||
|
||||
test("le taux d'erreur s'écrit en millièmes, comme les mEMG de GNU Backgammon", () => {
|
||||
assert.equal(errorRateMillis(0.0142), 14);
|
||||
assert.equal(errorRateMillis(0.0075), 8);
|
||||
assert.equal(errorRateMillis(0), 0);
|
||||
});
|
||||
|
||||
test("la tendance : moyennes des deux moitiés, arrondies en millièmes", () => {
|
||||
assert.deepEqual(progressionTrend([]), { kind: "waiting" });
|
||||
assert.deepEqual(progressionTrend([point("a", 0.02), point("b", 0.01)]), { kind: "waiting" });
|
||||
assert.equal(CURVE_MIN_GAMES, 3);
|
||||
// Trois parties : la première contre la dernière, celle du milieu ne
|
||||
// compte pas.
|
||||
assert.deepEqual(progressionTrend([point("a", 0.014), point("b", 0.05), point("c", 0.007)]), {
|
||||
kind: "down",
|
||||
games: 3,
|
||||
from: 14,
|
||||
to: 7,
|
||||
});
|
||||
assert.deepEqual(
|
||||
progressionTrend([
|
||||
point("a", 0.01),
|
||||
point("b", 0.012),
|
||||
point("c", 0.02),
|
||||
point("d", 0.016),
|
||||
]),
|
||||
{ kind: "up", games: 4, from: 11, to: 18 },
|
||||
);
|
||||
// 0,0101 et 0,0104 s'écrivent tous deux 10 : stable.
|
||||
assert.equal(
|
||||
progressionTrend([point("a", 0.0101), point("b", 0.03), point("c", 0.0104)]).kind,
|
||||
"steady",
|
||||
);
|
||||
});
|
||||
|
||||
test("la phrase du maître : des faits, sans point d'exclamation, sous 140 caractères", () => {
|
||||
for (const key of ["progression.baisse", "progression.hausse", "progression.stable"]) {
|
||||
assert.ok(PHRASE_KEYS.includes(key), key);
|
||||
}
|
||||
assert.equal(
|
||||
progressionSentence(phrases, { kind: "down", games: 8, from: 14, to: 7 }),
|
||||
"Sur vos 8 dernières parties, vous faites moins d'erreurs : votre taux d'erreur est passé de 14 à 7.",
|
||||
);
|
||||
assert.equal(
|
||||
progressionSentence(phrases, { kind: "waiting" }),
|
||||
"Votre courbe apparaîtra après trois parties.",
|
||||
);
|
||||
for (const trend of [
|
||||
{ kind: "down", games: 20, from: 140, to: 70 },
|
||||
{ kind: "up", games: 20, from: 70, to: 140 },
|
||||
{ kind: "steady", games: 20, from: 12, to: 12 },
|
||||
] as const) {
|
||||
const sentence = progressionSentence(phrases, trend);
|
||||
assert.ok(sentence.length < 140, sentence);
|
||||
assert.ok(!sentence.includes("!"), sentence);
|
||||
assert.ok(sentence.includes(String(trend.to)), sentence);
|
||||
}
|
||||
assert.ok(
|
||||
progressionSentence(phrases, { kind: "up", games: 5, from: 7, to: 9 }).includes(
|
||||
"Les leçons sont là",
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test("la progression lit le taux du bilan, sans rien recalculer ; une partie non analysée attend", async () => {
|
||||
const engine = await loadNodeEngine();
|
||||
const raw = [await race(engine, 3, 3), await race(engine, 7, 2), await race(engine, 11, 1)];
|
||||
const analysed = await Promise.all(raw.map((j) => analyseAll(engine, j)));
|
||||
// Données dans le désordre : la progression les range par date.
|
||||
const games = [
|
||||
finished("c", 3000, analysed[2] as GameJournal),
|
||||
finished("a", 1000, analysed[0] as GameJournal),
|
||||
finished("b", 2000, analysed[1] as GameJournal),
|
||||
finished("d", 4000, raw[0] as GameJournal),
|
||||
];
|
||||
const entries = progressionEntries(games);
|
||||
assert.deepEqual(
|
||||
entries.map((e) => e.id),
|
||||
["a", "b", "c", "d"],
|
||||
);
|
||||
for (const [i, entry] of entries.slice(0, 3).entries()) {
|
||||
assert.deepEqual(
|
||||
entry.errorRate,
|
||||
summarizeAnalysis(analysed[i] as GameJournal).player.errorRate,
|
||||
);
|
||||
}
|
||||
assert.equal(readyForSummary(raw[0] as GameJournal), false);
|
||||
assert.equal(entries[3]?.errorRate, null, "partie non analysée : pas de taux");
|
||||
const curve = progressionCurve(entries);
|
||||
assert.deepEqual(
|
||||
curve.map((p) => p.id),
|
||||
["a", "b", "c"],
|
||||
);
|
||||
assert.equal(progressionTrend(curve).kind === "waiting", false);
|
||||
});
|
||||
|
||||
test("« Créer des exercices » : erreurs, thèmes comptés sur les faits du moteur, niveau à 20 décisions cumulées", async () => {
|
||||
const engine = await loadNodeEngine();
|
||||
const template = parsePromptTemplate(
|
||||
readFileSync(new URL(`docs/prompts/${PROMPT_FILES.exercises}`, root), "utf8"),
|
||||
);
|
||||
const templates = { move: "", cube: "", summary: "", concept: "", exercises: template };
|
||||
const journals: GameJournal[] = [];
|
||||
for (const [seed, choice] of [
|
||||
[3, 3],
|
||||
[7, 2],
|
||||
[11, 1],
|
||||
[5, 4],
|
||||
[9, 3],
|
||||
] as const) {
|
||||
journals.push(await analyseAll(engine, await race(engine, seed, choice)));
|
||||
}
|
||||
const decisions = journals.map((j) => summarizeAnalysis(j).player.errorRate.decisions);
|
||||
assert.ok((decisions[0] ?? 0) < LEVEL_MIN_DECISIONS, `décisions : ${decisions.join()}`);
|
||||
let cumulated = 0;
|
||||
for (let n = 1; n <= journals.length; n++) {
|
||||
const some = journals.slice(0, n);
|
||||
cumulated += decisions[n - 1] ?? 0;
|
||||
const position = await exercisesPosition(engine, some);
|
||||
if (position === null) {
|
||||
continue;
|
||||
}
|
||||
const prompt = exercisesPrompt(templates, phrases, { journals: some, position });
|
||||
assert.ok(!/[{}]/.test(prompt), prompt);
|
||||
assert.ok(prompt.includes("Ce qui revient dans ces erreurs"), prompt);
|
||||
assert.match(prompt, /- \d+ coups? et \d+ décisions? de videau/);
|
||||
if (cumulated >= LEVEL_MIN_DECISIONS) {
|
||||
assert.match(prompt, /Je suis un joueur de niveau /);
|
||||
assert.ok(Object.values(LEVEL_NAMES).some((name) => prompt.includes(name)));
|
||||
} else {
|
||||
assert.ok(prompt.includes("Je débute au backgammon."), prompt);
|
||||
}
|
||||
}
|
||||
assert.ok(cumulated >= LEVEL_MIN_DECISIONS, `décisions cumulées : ${cumulated}`);
|
||||
});
|
||||
|
||||
test("les thèmes des erreurs : faits du moteur seulement, une ligne par fait présent", () => {
|
||||
const facts = (hits: number, madePoints: number[]) => ({ hits, madePoints });
|
||||
const move = (best: ReturnType<typeof facts>, played: ReturnType<typeof facts>) => ({
|
||||
advice: { kind: "move", bestFacts: best, playedFacts: played } as never,
|
||||
});
|
||||
const cube = { advice: { kind: "cube" } as never };
|
||||
assert.deepEqual(
|
||||
mistakeThemes([
|
||||
move(facts(1, []), facts(0, [])),
|
||||
move(facts(0, [5]), facts(0, [])),
|
||||
move(facts(0, []), facts(1, [])),
|
||||
cube,
|
||||
]),
|
||||
[
|
||||
"- 3 coups et 1 décision de videau",
|
||||
"- 1 fois, le coup de GNU Backgammon frappait un pion et le mien non",
|
||||
"- 1 fois, le coup de GNU Backgammon faisait un point que le mien ne faisait pas",
|
||||
"- 1 fois, mon coup frappait un pion et celui de GNU Backgammon non",
|
||||
],
|
||||
);
|
||||
assert.deepEqual(mistakeThemes([cube]), ["- 0 coup et 1 décision de videau"]);
|
||||
});
|
||||
@@ -239,7 +239,97 @@ test("prompt d'un coup : la position, les identifiants et le top 3 du moteur, m
|
||||
assert.ok(shown.includes(`${row.move.notation}|${formatEquity(row.loss)}`));
|
||||
}
|
||||
assert.ok(prompt.includes(`1. ${advice.best.notation}`), prompt);
|
||||
assert.ok(prompt.includes("analyse à 2 plis"), prompt);
|
||||
assert.match(prompt, /\(analyse à [12] plis?\)/);
|
||||
}
|
||||
});
|
||||
|
||||
/** Une équité écrite (« −0,212 ») proche d'une équité de gnubg de bureau :
|
||||
* à l'arrondi près, plus la tolérance des positions de référence (0,002 ;
|
||||
* tests/reference/README.md), le moteur WebAssembly ne calculant pas au
|
||||
* bit près comme gnubg de bureau (ADR 0005). */
|
||||
function assertNear(text: string, value: number, message: string): void {
|
||||
const written = Number(text.replace("−", "-").replace("+", "").replace(",", "."));
|
||||
assert.ok(Math.abs(written - value) <= 0.0005 + 0.002, `${text} ≠ ${value}\n${message}`);
|
||||
}
|
||||
|
||||
/** Le niveau du titre du classement, et chaque ligne du classement : coup,
|
||||
* équité, écart, niveau propre (null : celui du titre). */
|
||||
function rankingOfPrompt(prompt: string) {
|
||||
const title = /^Classement de GNU Backgammon \(analyse à (\d) plis?\)/m.exec(prompt);
|
||||
assert.ok(title, prompt);
|
||||
const lines = [
|
||||
...prompt.matchAll(
|
||||
/^(\d+)\. (.+?)( \(mon coup\))? : équité (\S+), écart (\S+?)(?:, évalué à (\d) plis?)?$/gm,
|
||||
),
|
||||
].map((m) => ({
|
||||
rank: Number(m[1]),
|
||||
notation: m[2],
|
||||
mine: m[3] !== undefined,
|
||||
equity: m[4],
|
||||
loss: m[5],
|
||||
ply: m[6] === undefined ? null : Number(m[6]),
|
||||
}));
|
||||
return { ply: Number(title[1]), lines };
|
||||
}
|
||||
|
||||
test("prompt d'un coup : le niveau du titre est celui des lignes, les équités celles de l'analyse", async () => {
|
||||
const engine = await loadNodeEngine();
|
||||
// gnubg de bureau, analyse de partie à 2 plis (tests/reference/analysis/).
|
||||
const reference = (id: string) =>
|
||||
JSON.parse(readFileSync(new URL(`tests/reference/analysis/${id}.json`, root), "utf8"))
|
||||
.plies["2"];
|
||||
for (const [dice, move, id] of [
|
||||
// Erreur : gnubg réévalue à 2 plis le meilleur coup et le coup joué ;
|
||||
// les autres restent au niveau où ses filtres les ont laissés.
|
||||
[[3, 1], "24/23 13/10", "move-opening-31-error"],
|
||||
// Meilleur coup : un seul coup passe le filtre de 1 pli, gnubg
|
||||
// s'arrête là, tout le classement est à 1 pli.
|
||||
[[3, 1], "8/5 6/5", "move-opening-31-best"],
|
||||
[[6, 5], "13/8 13/7", "move-opening-65-error"],
|
||||
] as const) {
|
||||
const played = await playOpening(engine, dice, move);
|
||||
const advice = await adviceOn(engine, played);
|
||||
assert.ok(advice.kind === "move");
|
||||
played.game.recordAdvice(advice);
|
||||
const position = await positionBefore(engine, played.game.journal, played.eventId);
|
||||
const prompt = decisionPrompt(templates, phrases, {
|
||||
journal: played.game.journal,
|
||||
advice,
|
||||
position,
|
||||
});
|
||||
const { ply, lines } = rankingOfPrompt(prompt);
|
||||
const expected = reference(id);
|
||||
|
||||
// Le titre dit le niveau du meilleur coup, celui que gnubg de bureau
|
||||
// donne ; une ligne sans niveau propre est à ce niveau, une ligne
|
||||
// avec un niveau propre, à un niveau inférieur.
|
||||
assert.equal(ply, expected.ranking[0].ply, prompt);
|
||||
assert.equal(ply, advice.best.ply);
|
||||
assert.ok(lines.length >= 3, prompt);
|
||||
for (const line of lines) {
|
||||
const reference = expected.ranking.find(
|
||||
(m: { move: string }) => m.move === line.notation,
|
||||
);
|
||||
assert.ok(reference, `${line.notation} absent du classement de gnubg`);
|
||||
assert.equal(line.ply ?? ply, reference.ply, `${line.notation}\n${prompt}`);
|
||||
assert.ok(line.ply === null || line.ply < ply, prompt);
|
||||
assertNear(line.equity, reference.equity, `${line.notation}\n${prompt}`);
|
||||
}
|
||||
|
||||
// Les équités et l'écart du meilleur coup et du vôtre sont ceux de
|
||||
// l'analyse, que disent la feuille du maître et l'écran d'analyse.
|
||||
const best = lines[0];
|
||||
const mine = lines.find((line) => line.mine);
|
||||
assert.ok(best && mine, prompt);
|
||||
assert.equal(best.notation, advice.best.notation);
|
||||
assert.equal(best.equity, formatEquity(advice.best.equity));
|
||||
assert.equal(best.loss, formatEquity(0));
|
||||
assert.equal(mine.notation, advice.played.notation);
|
||||
assert.equal(mine.rank, advice.rank);
|
||||
assert.equal(mine.equity, formatEquity(advice.played.equity));
|
||||
assert.equal(mine.loss, formatEquity(advice.equityLoss));
|
||||
assertNear(mine.loss, expected.equity_loss, prompt);
|
||||
assert.ok(shownRanking(advice).includes(`${mine.notation}|${mine.loss}`));
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -34,12 +34,21 @@ 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" }],
|
||||
["#/parties/mh2k3x-a1b2c3/bilan", { screen: "summary", game: "mh2k3x-a1b2c3" }],
|
||||
["#/parties/mh2k3x-a1b2c3/analyse", { screen: "analysis", game: "mh2k3x-a1b2c3", turn: null }],
|
||||
["#/parties/mh2k3x-a1b2c3/analyse/7", { screen: "analysis", game: "mh2k3x-a1b2c3", turn: 7 }],
|
||||
// La progression (étape 1h) et son prompt « Créer des exercices ».
|
||||
["#/progression", { screen: "progression" }],
|
||||
["#/progression/ia", { screen: "progression", ai: true }],
|
||||
["#/menu", { screen: "menu" }],
|
||||
["#/nouvelle-partie", { screen: "newGame" }],
|
||||
["#/explication/12", { screen: "explanation", event: "12" }],
|
||||
@@ -99,6 +108,14 @@ test("une adresse inconnue est reconnue comme telle", () => {
|
||||
"#/tutoriel/3/b",
|
||||
"#/tutoriel/3/a-vous/x",
|
||||
"#/tutoriel/fin/a-vous",
|
||||
"#/lecons/0",
|
||||
"#/lecons/12",
|
||||
"#/lecons/x",
|
||||
"#/lecons/2/0",
|
||||
"#/lecons/2/x",
|
||||
"#/lecons/2/3/b",
|
||||
"#/lecons/2/3/a-vous/x",
|
||||
"#/lecons/2/fin/a-vous",
|
||||
"#/installer/x",
|
||||
"#/a-propos/x",
|
||||
"#/position",
|
||||
@@ -113,6 +130,8 @@ test("une adresse inconnue est reconnue comme telle", () => {
|
||||
"#/menu/ia",
|
||||
"#/parties/ab/ia",
|
||||
"#/explication/12/ia/ia",
|
||||
"#/progression/x",
|
||||
"#/progression/ia/ia",
|
||||
]) {
|
||||
assert.equal(parseRoute(hash).screen, "unknown", hash);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
// Étapes du scénario docs/scenarios/suivre-ma-progression.feature (ADR
|
||||
// 0008) : des courses courtes jouées par le domaine sur le vrai moteur, avec
|
||||
// des dés reproductibles, enregistrées dans le stockage des parties (sur un
|
||||
// localStorage simulé), analysées par l'analyse en arrière-plan de
|
||||
// l'interface (review-runner.ts, sans DOM) ; l'écran de progression lit
|
||||
// ensuite le stockage comme app.ts (domain/analysis/progression.ts).
|
||||
//
|
||||
// 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 { Before, BeforeAll, Given, setDefaultTimeout, Then, When } from "@cucumber/cucumber";
|
||||
import {
|
||||
readyForSummary,
|
||||
runAnalysis,
|
||||
summarizeAnalysis,
|
||||
} from "../../src/domain/analysis/game-analysis.js";
|
||||
import { progressionSentence } from "../../src/domain/analysis/phrases.js";
|
||||
import {
|
||||
CURVE_MIN_GAMES,
|
||||
type FinishedGame,
|
||||
type ProgressionEntry,
|
||||
progressionCurve,
|
||||
progressionEntries,
|
||||
progressionTrend,
|
||||
} from "../../src/domain/analysis/progression.js";
|
||||
import type { Engine } from "../../src/domain/game/engine.js";
|
||||
import type { GameJournal } from "../../src/domain/game/events.js";
|
||||
import { Game } from "../../src/domain/game/game.js";
|
||||
import { levelOf } from "../../src/domain/game/saved-game.js";
|
||||
import {
|
||||
exercisesPosition,
|
||||
exercisesPrompt,
|
||||
LEVEL_MIN_DECISIONS,
|
||||
} from "../../src/domain/prompts/prompts.js";
|
||||
import { PROMPT_FILES, parsePromptTemplate } from "../../src/domain/prompts/templates.js";
|
||||
import type { GameLibrary } from "../../src/storage/game-library.js";
|
||||
import { memoryStorage, openGameLibrary } from "../../src/storage/open-library.js";
|
||||
import { createReviewRunner } from "../../src/ui/review-runner.js";
|
||||
import { loadPhrases } from "../support/advice.js";
|
||||
import { loadNodeEngine } from "../support/node-engine.js";
|
||||
import { playToTheEnd, seededDice } from "../support/play.js";
|
||||
|
||||
setDefaultTimeout(120_000);
|
||||
|
||||
const root = new URL("../../../../", import.meta.url);
|
||||
const phrases = loadPhrases();
|
||||
/** Les courses jouées : graine des dés et choix du joueur. */
|
||||
const RACES = [
|
||||
[3, 3],
|
||||
[7, 2],
|
||||
[11, 1],
|
||||
] as const;
|
||||
|
||||
let engine: Engine;
|
||||
let library: GameLibrary;
|
||||
/** Les identifiants des parties enregistrées, dans l'ordre où elles ont
|
||||
* été jouées. */
|
||||
let ids: string[];
|
||||
let entries: ProgressionEntry[];
|
||||
/** Les analyses demandées au moteur, par partie. */
|
||||
let analyses: Map<string, number>;
|
||||
let prompt: string;
|
||||
let clock: number;
|
||||
|
||||
BeforeAll(async () => {
|
||||
engine = await loadNodeEngine();
|
||||
});
|
||||
|
||||
Before(async () => {
|
||||
clock = 1_000_000;
|
||||
library = (await openGameLibrary({ localStorage: memoryStorage() }, { now: () => clock }))
|
||||
.library;
|
||||
ids = [];
|
||||
analyses = new Map();
|
||||
});
|
||||
|
||||
/** Analyse en arrière-plan ce qui manque à la partie id, comme
|
||||
* l'application, et l'enregistre. */
|
||||
async function analyse(id: string): Promise<void> {
|
||||
const saved = await library.load(id);
|
||||
assert.ok(saved !== null);
|
||||
const runner = createReviewRunner((journal, task) => {
|
||||
analyses.set(id, (analyses.get(id) ?? 0) + 1);
|
||||
return runAnalysis(engine, journal, task);
|
||||
});
|
||||
let latest = saved.journal;
|
||||
await new Promise<void>((resolve) => {
|
||||
runner.start(id, saved.journal, (j) => (latest = j), resolve);
|
||||
});
|
||||
await library.saveAnalyses(id, latest, { level: levelOf(latest) });
|
||||
}
|
||||
|
||||
/** Ce que lit l'écran de progression : les parties terminées et leur
|
||||
* journal enregistré. */
|
||||
async function openProgression(): Promise<void> {
|
||||
const games: FinishedGame[] = [];
|
||||
for (const summary of await library.list()) {
|
||||
const saved = await library.load(summary.id);
|
||||
if (summary.status === "over" && saved !== null) {
|
||||
games.push({ ...summary, journal: saved.journal });
|
||||
}
|
||||
}
|
||||
entries = progressionEntries(games);
|
||||
}
|
||||
|
||||
Given("vous avez terminé {word} parties contre l'ordinateur", async (count: string) => {
|
||||
const n = { deux: 2, trois: 3 }[count] ?? Number(count);
|
||||
for (const [seed, choice] of RACES.slice(0, n)) {
|
||||
const game = Game.start(engine, {
|
||||
setup: { position: "trsXAADs7mwAAA", firstTurn: "player" },
|
||||
level: "intermediate",
|
||||
dice: seededDice(seed),
|
||||
});
|
||||
await playToTheEnd(game, engine, choice);
|
||||
clock += 60_000;
|
||||
const summary = await library.save(`partie-${seed}`, game.journal, {
|
||||
level: "intermediate",
|
||||
});
|
||||
assert.equal(summary.status, "over");
|
||||
ids.push(summary.id);
|
||||
}
|
||||
});
|
||||
|
||||
Given("le maître les a analysées", async () => {
|
||||
for (const id of ids) {
|
||||
await analyse(id);
|
||||
}
|
||||
});
|
||||
|
||||
Given("le maître n'a analysé que les deux premières", async () => {
|
||||
for (const id of ids.slice(0, 2)) {
|
||||
await analyse(id);
|
||||
}
|
||||
analyses.clear();
|
||||
});
|
||||
|
||||
When("vous ouvrez votre progression", async () => {
|
||||
await openProgression();
|
||||
});
|
||||
|
||||
Then("la courbe a trois points, chacun égal au taux d'erreur du bilan de sa partie", async () => {
|
||||
const curve = progressionCurve(entries);
|
||||
assert.equal(curve.length, 3);
|
||||
// De la plus ancienne partie à la plus récente.
|
||||
assert.deepEqual(
|
||||
curve.map((p) => p.id),
|
||||
ids,
|
||||
);
|
||||
for (const point of curve) {
|
||||
const saved = await library.load(point.id);
|
||||
assert.ok(saved !== null && readyForSummary(saved.journal));
|
||||
assert.equal(
|
||||
point.perDecision,
|
||||
summarizeAnalysis(saved.journal).player.errorRate.perDecision,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
Then("la phrase du maître compare vos premières parties aux dernières", () => {
|
||||
const trend = progressionTrend(progressionCurve(entries));
|
||||
assert.notEqual(trend.kind, "waiting");
|
||||
const sentence = progressionSentence(phrases, trend);
|
||||
assert.ok(sentence.startsWith("Sur vos 3 dernières parties"), sentence);
|
||||
assert.ok(!sentence.includes("!"), sentence);
|
||||
});
|
||||
|
||||
Then("il n'y a pas encore de courbe", () => {
|
||||
assert.equal(entries.length, 2);
|
||||
assert.ok(
|
||||
entries.every((e) => e.errorRate !== null),
|
||||
"les deux parties ont leur taux",
|
||||
);
|
||||
assert.ok(progressionCurve(entries).length < CURVE_MIN_GAMES);
|
||||
});
|
||||
|
||||
Then(/^la phrase de votre progression est « (.+) »$/, (sentence: string) => {
|
||||
assert.equal(
|
||||
progressionSentence(phrases, progressionTrend(progressionCurve(entries))),
|
||||
sentence,
|
||||
);
|
||||
});
|
||||
|
||||
Then("la troisième partie attend son analyse, sans taux d'erreur", () => {
|
||||
assert.equal(entries.length, 3);
|
||||
assert.equal(entries[2]?.id, ids[2]);
|
||||
assert.equal(entries[2]?.errorRate, null);
|
||||
assert.equal(progressionCurve(entries).length, 2);
|
||||
});
|
||||
|
||||
When("son analyse en arrière-plan est finie", async () => {
|
||||
// Comme l'écran : les parties qui en ont besoin, une à une.
|
||||
for (const entry of entries) {
|
||||
if (entry.errorRate === null) {
|
||||
await analyse(entry.id);
|
||||
}
|
||||
}
|
||||
await openProgression();
|
||||
});
|
||||
|
||||
Then("les deux premières parties n'ont pas été analysées de nouveau", () => {
|
||||
assert.deepEqual([...analyses.keys()], [ids[2]]);
|
||||
});
|
||||
|
||||
When("vous demandez des exercices", async () => {
|
||||
await openProgression();
|
||||
const journals: GameJournal[] = [];
|
||||
for (const entry of entries) {
|
||||
const saved = await library.load(entry.id);
|
||||
assert.ok(saved !== null);
|
||||
journals.push(saved.journal);
|
||||
}
|
||||
const position = await exercisesPosition(engine, journals);
|
||||
assert.ok(position !== null, "aucune erreur dans ces parties");
|
||||
const exercises = parsePromptTemplate(
|
||||
readFileSync(new URL(`docs/prompts/${PROMPT_FILES.exercises}`, root), "utf8"),
|
||||
);
|
||||
prompt = exercisesPrompt({ move: "", cube: "", summary: "", concept: "", exercises }, phrases, {
|
||||
journals,
|
||||
position,
|
||||
});
|
||||
});
|
||||
|
||||
Then("la question cite vos erreurs avec leur coût et ce qui y revient", () => {
|
||||
assert.match(prompt, /- Partie \d, coup \d+.*écart −\d,\d{3}/);
|
||||
assert.ok(prompt.includes("Ce qui revient dans ces erreurs"), prompt);
|
||||
assert.match(prompt, /- \d+ coups? et \d+ décisions? de videau/);
|
||||
assert.ok(prompt.includes("Voici mes 3 dernières parties"), prompt);
|
||||
});
|
||||
|
||||
Then(
|
||||
"elle ne donne votre niveau que si vos parties comptent au moins {int} décisions",
|
||||
(min: number) => {
|
||||
assert.equal(min, LEVEL_MIN_DECISIONS);
|
||||
const decisions = entries.reduce((sum, e) => sum + (e.errorRate?.decisions ?? 0), 0);
|
||||
if (decisions >= min) {
|
||||
assert.match(prompt, /Je suis un joueur de niveau /);
|
||||
} else {
|
||||
assert.ok(prompt.includes("Je débute au backgammon."), prompt);
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,205 @@
|
||||
// Étapes du scénario docs/scenarios/suivre-une-lecon.feature (ADR 0008) :
|
||||
// le texte des leçons est lu dans sa source (docs/lecons/), chaque geste est
|
||||
// jugé par le domaine branché sur le vrai moteur, et la progression passe
|
||||
// par le stockage des parties, comme dans l'application.
|
||||
//
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { BeforeAll, Given, Then, When } from "@cucumber/cucumber";
|
||||
import type { Engine } from "../../src/domain/game/engine.js";
|
||||
import {
|
||||
type LessonProgress,
|
||||
lessonState,
|
||||
NO_PROGRESS,
|
||||
withSolved,
|
||||
withVisit,
|
||||
} from "../../src/domain/learning/lessons.js";
|
||||
import {
|
||||
MovePractice,
|
||||
type PracticeVerdict,
|
||||
tutorialBoard,
|
||||
} from "../../src/domain/learning/tutorial.js";
|
||||
import {
|
||||
createKeyValueBackend,
|
||||
type KeyValueStorage,
|
||||
} from "../../src/storage/key-value-backend.js";
|
||||
import { createLessonStore, type LessonStore } from "../../src/storage/lesson-store.js";
|
||||
import { memoryStorage } from "../../src/storage/open-library.js";
|
||||
import { LESSON_COUNT, type Lesson, outlineOf, parseLesson } from "../../src/ui/lesson-text.js";
|
||||
import { LESSON_TEXTS } from "../../src/ui/lesson-texts.js";
|
||||
import { lessonCards, nextLessonCard } from "../../src/ui/lessons-model.js";
|
||||
import type { TutorialScreen } from "../../src/ui/tutorial-text.js";
|
||||
import { loadNodeEngine } from "../support/node-engine.js";
|
||||
|
||||
const root = new URL("../../../../", import.meta.url);
|
||||
|
||||
let engine: Engine;
|
||||
let lessons: Lesson[];
|
||||
let storage: KeyValueStorage;
|
||||
let store: LessonStore;
|
||||
let progress: LessonProgress = NO_PROGRESS;
|
||||
let screen: TutorialScreen;
|
||||
let practice: MovePractice | null = null;
|
||||
let verdict: PracticeVerdict = "continue";
|
||||
|
||||
BeforeAll(async () => {
|
||||
engine = await loadNodeEngine();
|
||||
lessons = Array.from({ length: LESSON_COUNT }, (_, i) =>
|
||||
parseLesson(i + 1, readFileSync(new URL(`docs/lecons/lecon-${i + 1}.md`, root), "utf8")),
|
||||
);
|
||||
});
|
||||
|
||||
function lesson(n: number): Lesson {
|
||||
const found = lessons[n - 1];
|
||||
assert.ok(found !== undefined, `leçon ${n} absente`);
|
||||
return found;
|
||||
}
|
||||
|
||||
/** Enregistre la progression, comme l'application à chaque écran. */
|
||||
async function save(next: LessonProgress): Promise<void> {
|
||||
progress = next;
|
||||
await store.save(progress);
|
||||
}
|
||||
|
||||
Given("vous ouvrez les leçons pour la première fois", async () => {
|
||||
storage = memoryStorage();
|
||||
store = createLessonStore(createKeyValueBackend(storage));
|
||||
progress = await store.load();
|
||||
assert.deepEqual(progress, NO_PROGRESS);
|
||||
});
|
||||
|
||||
Then("il y en a cinq, chacune avec deux à quatre exercices", () => {
|
||||
assert.equal(lessons.length, 5);
|
||||
for (const card of lessonCards(lessons, progress)) {
|
||||
assert.equal(card.state.kind, "toStart");
|
||||
assert.ok(card.state.total >= 2 && card.state.total <= 4);
|
||||
}
|
||||
});
|
||||
|
||||
Then("chaque position des leçons se lit sur le plateau", async () => {
|
||||
for (const current of lessons.flatMap((l) => l.steps.flatMap((s) => [s.explain, s.practice]))) {
|
||||
if (current !== null) {
|
||||
const board = await tutorialBoard(engine, current.position, current.onRoll);
|
||||
assert.equal(board.sides.player.points.length, 24);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Then("l'accueil vous propose la leçon {int}", (n: number) => {
|
||||
assert.equal(nextLessonCard(lessons, progress)?.number, n);
|
||||
});
|
||||
|
||||
When("vous réussissez le premier exercice de la leçon {int}", async (n: number) => {
|
||||
const first = outlineOf(lesson(n)).exercises[0];
|
||||
assert.ok(first !== undefined);
|
||||
await save(withSolved(withVisit(progress, n, first), n, first));
|
||||
});
|
||||
|
||||
Then(
|
||||
"la leçon {int} en est à « {int} exercice sur {int} »",
|
||||
(n: number, solved: number, total: number) => {
|
||||
const state = lessonState(progress, outlineOf(lesson(n)));
|
||||
assert.deepEqual(state, { kind: "inProgress", solved, total });
|
||||
assert.equal(LESSON_TEXTS.state(state), `${solved} exercice sur ${total}`);
|
||||
},
|
||||
);
|
||||
|
||||
Then("l'accueil vous propose de continuer la leçon {int}", (n: number) => {
|
||||
const card = nextLessonCard(lessons, progress);
|
||||
assert.equal(card?.number, n);
|
||||
assert.equal(card?.state.kind, "inProgress");
|
||||
});
|
||||
|
||||
Given("vous êtes au premier exercice de la leçon {int}", async (n: number) => {
|
||||
const step = lesson(n).steps.find((s) => s.practice !== null);
|
||||
assert.ok(step?.practice != null && step.practice.roll !== null);
|
||||
screen = step.practice;
|
||||
practice = await MovePractice.start(
|
||||
engine,
|
||||
screen.position,
|
||||
screen.roll as [number, number],
|
||||
screen.expected,
|
||||
);
|
||||
verdict = "continue";
|
||||
});
|
||||
|
||||
When(
|
||||
"vous avancez un pion de la flèche {int} à la flèche {int}",
|
||||
async (from: number, to: number) => {
|
||||
assert.ok(practice !== null);
|
||||
verdict = await practice.play({ from, to });
|
||||
if (verdict === "wrong") {
|
||||
practice.reset();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
Then("le pion revient, et le maître vous met sur la voie", () => {
|
||||
assert.equal(verdict, "wrong");
|
||||
assert.deepEqual(practice?.pendingHalfMoves, []);
|
||||
assert.match(screen.wrong.map((part) => part.text).join(""), /^Ce coup est permis, mais/);
|
||||
});
|
||||
|
||||
When("vous faites le point {int}", async (point: number) => {
|
||||
assert.ok(practice !== null && screen.roll !== null);
|
||||
// Le coup du maître de cet exercice, demi-coup par demi-coup : il
|
||||
// amène bien deux pions sur la flèche demandée.
|
||||
const legal = await engine.legalMoves(screen.position, screen.roll);
|
||||
const move = legal.find((m) => m.notation === screen.expected[0]);
|
||||
assert.ok(move !== undefined);
|
||||
assert.equal(move.halfMoves.filter((h) => h.to === point).length, 2);
|
||||
for (const halfMove of move.halfMoves) {
|
||||
verdict = await practice.play(halfMove);
|
||||
}
|
||||
});
|
||||
|
||||
Then("le maître salue votre coup", () => {
|
||||
assert.equal(verdict, "right");
|
||||
assert.match(screen.right.map((part) => part.text).join(""), /^C'est (aussi )?mon coup/);
|
||||
});
|
||||
|
||||
Then("c'est le meilleur coup du moteur", async () => {
|
||||
assert.ok(screen.roll !== null);
|
||||
const best = await engine.bestMove(
|
||||
screen.position,
|
||||
{ value: 1, owner: "centered" },
|
||||
screen.roll,
|
||||
2,
|
||||
);
|
||||
assert.equal(best?.notation, screen.expected[0]);
|
||||
});
|
||||
|
||||
Given("vous avez quitté la leçon {int} à sa deuxième étape", async (n: number) => {
|
||||
storage = memoryStorage();
|
||||
store = createLessonStore(createKeyValueBackend(storage));
|
||||
progress = NO_PROGRESS;
|
||||
await save(withVisit(withVisit(progress, n, 1), n, 2));
|
||||
});
|
||||
|
||||
When("vous revenez plus tard aux leçons", async () => {
|
||||
store = createLessonStore(createKeyValueBackend(storage));
|
||||
progress = await store.load();
|
||||
});
|
||||
|
||||
Then("« Continuer » vous ramène à la deuxième étape de la leçon {int}", (n: number) => {
|
||||
const card = lessonCards(lessons, progress).find((c) => c.number === n);
|
||||
assert.equal(card?.href, `#/lecons/${n}/2`);
|
||||
assert.equal(nextLessonCard(lessons, progress)?.number, n);
|
||||
});
|
||||
|
||||
When("vous réussissez tous les exercices de la leçon {int}", async (n: number) => {
|
||||
let next = progress;
|
||||
for (const step of outlineOf(lesson(n)).exercises) {
|
||||
next = withSolved(withVisit(next, n, step), n, step);
|
||||
}
|
||||
await save(next);
|
||||
});
|
||||
|
||||
Then("la leçon {int} est terminée", (n: number) => {
|
||||
const state = lessonState(progress, outlineOf(lesson(n)));
|
||||
assert.equal(state.kind, "done");
|
||||
assert.equal(LESSON_TEXTS.state(state), "Terminée");
|
||||
});
|
||||
@@ -222,6 +222,57 @@ test("les identifiants de partie sont distincts", () => {
|
||||
assert.match(newGameId(), /^[0-9a-z]+-[0-9a-z]{6}$/);
|
||||
});
|
||||
|
||||
// Régression du test navigateur « mes parties » sous Firefox (disque lent) :
|
||||
// la nouvelle partie n'est pas perdue quand son enregistrement attend
|
||||
// derrière ceux, lents, de l'analyse de la partie finie ; seule une lecture
|
||||
// directe du stockage, hors de la file, peut la précéder.
|
||||
test("stockage lent : la nouvelle partie est enregistrée après l'analyse de l'ancienne", async () => {
|
||||
const storage = memoryStorage();
|
||||
const raw = createKeyValueBackend(storage);
|
||||
const pause = () => new Promise((resolve) => setTimeout(resolve, 30));
|
||||
const slow = {
|
||||
...raw,
|
||||
write: async (...args: Parameters<typeof raw.write>) => {
|
||||
await pause();
|
||||
await raw.write(...args);
|
||||
},
|
||||
setCurrentId: async (id: string) => {
|
||||
await pause();
|
||||
await raw.setCurrentId(id);
|
||||
},
|
||||
};
|
||||
const games = createGameLibrary(slow, { now: clock() });
|
||||
const engine = {} as Engine;
|
||||
const session = createGameSession(games, {
|
||||
start: () => Game.start(engine, { dice: fixedDice([5, 2]) }),
|
||||
resume: (j) => Game.resume(engine, j),
|
||||
});
|
||||
const finished = session.newGame();
|
||||
const finishedId = session.shownId as string;
|
||||
session.save(finished.journal);
|
||||
assert.equal((await games.current())?.summary.id, finishedId);
|
||||
|
||||
// L'analyse de la partie finie s'enregistre en arrière-plan, puis
|
||||
// « Rejouer » : nouvelle partie, enregistrée sans attendre.
|
||||
for (let i = 0; i < 5; i++) {
|
||||
void games.saveAnalyses(finishedId, finished.journal, { level: "2ply" });
|
||||
}
|
||||
const next = session.newGame();
|
||||
session.save(next.journal);
|
||||
const nextId = session.shownId as string;
|
||||
assert.notEqual(nextId, finishedId);
|
||||
|
||||
// Lue directement, tout de suite : encore l'ancienne partie.
|
||||
assert.equal(await raw.currentId(), finishedId);
|
||||
// Lue par la bibliothèque, après les écritures en attente : la nouvelle,
|
||||
// avec son journal ; l'ancienne reste là.
|
||||
const current = await games.current();
|
||||
assert.equal(current?.summary.id, nextId);
|
||||
assert.deepEqual(current?.journal, next.journal);
|
||||
assert.equal(await raw.currentId(), nextId);
|
||||
assert.deepEqual((await games.load(finishedId))?.journal, finished.journal);
|
||||
});
|
||||
|
||||
test("session : « Reprendre l'ancienne » par l'identifiant, et reprise d'une branche", async () => {
|
||||
const games = library();
|
||||
// Game.start et Game.resume n'interrogent pas le moteur.
|
||||
|
||||
@@ -101,8 +101,8 @@ test("chaque position du tutoriel est valide pour le moteur", async () => {
|
||||
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`);
|
||||
assert.equal(count("player"), 15, `${name} : pions blancs`);
|
||||
assert.equal(count("computer"), 15, `${name} : pions noirs`);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -209,14 +209,14 @@ test("aide contextuelle : chaque question ouverte depuis la partie existe dans l
|
||||
});
|
||||
|
||||
test("glossaire : nom anglais, renvois, recherche sans accents", () => {
|
||||
const entry = parseGlossaryEntry(read("docs/glossaire/camp.md"));
|
||||
const entry = parseGlossaryEntry(read("docs/glossaire/jan-interieur.md"));
|
||||
assert.ok(entry !== null);
|
||||
assert.equal(entry.english, "Home board");
|
||||
assert.deepEqual(
|
||||
entry.seeAlso.map((s) => s.name),
|
||||
["sortie", "barre", "point"],
|
||||
["jan-exterieur", "sortie", "barre", "point"],
|
||||
);
|
||||
assert.ok(matchesSearch(entry, "camp"));
|
||||
assert.ok(matchesSearch(entry, "jan interieur"));
|
||||
assert.ok(matchesSearch(entry, "HOME"));
|
||||
assert.ok(matchesSearch(entry, "fleches sortir"));
|
||||
assert.ok(!matchesSearch(entry, "videau"));
|
||||
|
||||
@@ -184,3 +184,46 @@ a plus besoin (`app/src/ui/review-runner.ts`).
|
||||
La bibliothèque gagne `saveAnalyses`, qui enregistre le journal analysé
|
||||
d'une partie sans en faire la partie en cours ni changer la date de sa
|
||||
dernière action (l'ordre de « Mes parties »).
|
||||
|
||||
### 2026-11-11 — Version 2 des conseils : le classement de l'analyse
|
||||
|
||||
Le classement des coups d'un conseil (`ranking`) venait d'un second appel au
|
||||
moteur, comme la commande « hint » de gnubg, alors que le coup joué et le
|
||||
meilleur coup venaient de l'analyse de partie. Les deux listes diffèrent :
|
||||
quand le coup joué est une erreur, l'analyse réévalue le meilleur coup et le
|
||||
coup joué au niveau demandé, ce que « hint » ne fait pas quand un seul coup
|
||||
passe ses filtres (ouverture 3-1 jouée 24/23 13/10 : 8/5 6/5 à +0,200 à
|
||||
2 plis dans l'analyse, +0,204 à 1 pli dans « hint »). Le classement vient
|
||||
désormais de l'analyse même (`engine_analyse_move`, champ `ranking`), comme
|
||||
le panneau d'analyse de gnubg de bureau.
|
||||
|
||||
- `ADVICE_VERSION` passe à 2 (`app/src/domain/game/advice.ts`). Selon la
|
||||
règle de l'amendement du 2026-11-10, un conseil de la version 1 est oublié
|
||||
à 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 ».
|
||||
|
||||
@@ -60,3 +60,22 @@ couleur, en monochrome et en taille réelle d'icône (48 et 24 px).
|
||||
en dessous de 32 px.
|
||||
- Les icônes Android (premier plan, arrière-plan, monochrome) sont fournies en
|
||||
SVG ; leur conversion en `VectorDrawable` se fera avec l'application Android.
|
||||
|
||||
## Amendements
|
||||
|
||||
### 2026-11-11 — Pions blancs et noirs, avec un contour
|
||||
|
||||
Retour des premiers joueurs, relayé par Pierre : les pions sombres se voyaient
|
||||
mal sur la barre, et les joueurs préféraient le blanc et le noir à l'ivoire et
|
||||
au noyer. Après comparaison sur le canevas (rangée « Retours des premiers
|
||||
joueurs »), Pierre retient le blanc et le noir, avec un contour.
|
||||
|
||||
- Pions du joueur blancs (`#FAFAF7`), contour et filet gris foncé
|
||||
(`#55554F` en thème clair, pour 3:1 sur les flèches en chêne ; `#6E6E69` en
|
||||
sombre) ; pions de l'ordinateur noirs (`#1C1C1C`), contour et filet gris
|
||||
clair (`#B5B5B0`), dans les deux thèmes. Dés et videau suivent les pions.
|
||||
- Le contour assure la lisibilité partout : pions noirs sur la barre (6,8:1
|
||||
en thème clair, 8,5:1 en sombre), pions blancs sur le fond clair (3,8:1).
|
||||
- Le logo ne change pas : ses pions restent ivoire et noyer, qui sont la
|
||||
matière de la marque, distincte des pièces du jeu.
|
||||
- Valeurs dans `docs/identite/charte/tokens.json`.
|
||||
|
||||
@@ -57,3 +57,30 @@ 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,3 +192,55 @@ 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.
|
||||
|
||||
### 2026-11-12 — Adresses de la progression
|
||||
|
||||
Ajoutées avec l'écran de progression (étape 1h), dans le même schéma :
|
||||
|
||||
| Écran | Adresse |
|
||||
| --- | --- |
|
||||
| Progression : courbe du taux d'erreur et parties terminées | `#/progression` |
|
||||
| Prompt « Demander des exercices à une IA », depuis la progression | `#/progression/ia` |
|
||||
|
||||
- La ligne de progression de l'accueil est un lien vers `#/progression`.
|
||||
L'écran est une page, comme la liste des leçons : pas de bouton
|
||||
« Retour », la barre du haut, Échap et le retour du téléphone suffisent.
|
||||
Échap revient à l'écran d'avant, l'accueil s'il a été ouvert par son
|
||||
adresse.
|
||||
- Chaque partie de la liste est un lien vers son bilan
|
||||
(`#/parties/<id>/bilan`), d'où s'ouvre son analyse. Fermer le bilan
|
||||
ramène à la progression.
|
||||
- « Demander des exercices à une IA » suit la règle des prompts (amendement du
|
||||
2026-11-10) : le bouton ouvre la feuille du prompt, dans sa propre
|
||||
feuille au-dessus de la progression (« ‹ Progression »), où le texte se
|
||||
relit avant d'être copié. Rechargée, l'adresse recalcule le prompt à
|
||||
partir des analyses enregistrées ; sans partie analysée, ou sans erreur,
|
||||
la feuille le dit, jamais une page cassée.
|
||||
- Comme « Mes parties », ces adresses n'ont de sens que sur l'appareil qui
|
||||
a joué les parties : ce ne sont pas des liens à partager.
|
||||
|
||||
+4
-2
@@ -23,9 +23,11 @@ Repris du cadrage (modules, écrans clés, IA générative en V1) et des ADR 001
|
||||
| 1d. Conseil du maître (fait) | Après chaque coup du joueur, jamais avant (ADR 0011) : appréciation du moteur (bon coup, imprécision, erreur, grosse erreur) avec mot, icône et couleur, phrase du maître fondée sur les chiffres du moteur, coup recommandé montré sur demande ; aussi pour les décisions de videau ; calcul en arrière-plan sans retarder le jeu | 1a |
|
||||
| 1e. Fin de partie et analyse (fait) | Bilan (ADR 0011) : résultat, coups par appréciation, taux d'erreur comparé à la chance, « le coup à revoir » ; écran d'analyse : graphe d'équité (toucher un point ouvre la position), liste des erreurs filtrable, visualiseur pas à pas coup joué contre meilleur coup, « Rejouer d'ici » | 1a, 1b, 1d |
|
||||
| 1f. Prompts à copier (fait) | Gabarits versionnés et testés (`docs/prompts/` ou équivalent, source unique), bouton « Demander à une IA » sur le plateau, le conseil du maître, l'analyse et la fin de partie ; le moteur décide, l'IA explique | 1d, 1e |
|
||||
| 1g. Accueil, tutoriel et leçons | Accueil (reprendre, nouvelle partie, prochaine leçon, progression résumée), tutoriel des règles interactif et illustré (0e.5), cinq premières leçons numérotées (ADR 0011) avec positions et « à vous de jouer », progression locale ; contenus CC BY-SA 4.0 en brouillon, à relire par un joueur fort | 0e.5, 1a |
|
||||
| 1h. Progression | Historique des parties et courbe du taux d'erreur | 1b, 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 |
|
||||
| 1h. Progression (fait) | 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 |
|
||||
| 1k. Retours des joueurs, 2e lot | Crawford signalé sur la barre ; abandon (simple, gammon, backgammon) accepté ou refusé par l'ordinateur selon GNU Backgammon (plan `2026-11-12-retours-joueurs-2.md`) | 1c, 1e |
|
||||
| 1l. Analyse du match | Bilan et courbe d'un match entier (chances de gagner le match), erreurs de videau sur tout le match, prompt et export SGF du match (plan `2026-11-12-analyse-du-match.md`) | 1c, 1e, 1f |
|
||||
| 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
|
||||
|
||||
@@ -40,7 +40,6 @@ 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 |
|
||||
| [Conseil du maître](conseil-du-maitre.md) | Advice |
|
||||
@@ -54,11 +53,14 @@ exportée. `make test` vérifie le lien dans les deux sens : chaque fiche avec
|
||||
| [Gammon](gammon.md) | Gammon |
|
||||
| [Identifiant de match](identifiant-de-match.md) | Match ID |
|
||||
| [Identifiant de position](identifiant-de-position.md) | Position ID |
|
||||
| [Jan extérieur](jan-exterieur.md) | Outer board |
|
||||
| [Jan intérieur](jan-interieur.md) | Home board |
|
||||
| [Jet](jet.md) | Roll |
|
||||
| [Lien de position](lien-de-position.md) | Position link |
|
||||
| [Match](match.md) | Match |
|
||||
| [Moteur](moteur.md) | Engine |
|
||||
| [Niveau de jeu](niveau-de-jeu.md) | Level |
|
||||
| [Notation des coups](notation-des-coups.md) | Move notation |
|
||||
| [Partie](partie.md) | Game |
|
||||
| [Partie enregistrée](partie-enregistree.md) | Saved game |
|
||||
| [Passer](passer.md) | Drop |
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
---
|
||||
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)
|
||||
@@ -0,0 +1,15 @@
|
||||
---
|
||||
term: Jan extérieur
|
||||
english: Outer board
|
||||
code: OuterBoard
|
||||
---
|
||||
# Jan extérieur
|
||||
|
||||
**En bref** : les six flèches qui précèdent le jan intérieur d'un joueur : ses pions y passent avant d'y entrer.
|
||||
|
||||
Votre jan extérieur est en bas à gauche (vos flèches 7 à 12) ; celui de
|
||||
l'ordinateur, en haut à gauche (vos flèches 13 à 18). Vos pions partent du jan
|
||||
intérieur de l'ordinateur, traversent les deux jans extérieurs, puis entrent
|
||||
dans votre jan intérieur.
|
||||
|
||||
Voir aussi : [Jan intérieur](jan-interieur.md), [Point](point.md)
|
||||
@@ -0,0 +1,16 @@
|
||||
---
|
||||
term: Jan intérieur
|
||||
english: Home board
|
||||
code: HomeBoard
|
||||
---
|
||||
# Jan intérieur
|
||||
|
||||
**En bref** : les six dernières flèches d'un joueur avant la sortie : il y rassemble ses quinze pions pour les sortir, et les pions adverses frappés y rentrent.
|
||||
|
||||
Le plateau se partage en quatre jans de six flèches. Votre jan intérieur est
|
||||
en bas à droite (vos flèches 1 à 6) ; celui de l'ordinateur, en haut à droite
|
||||
(vos flèches 19 à 24) : c'est là que rentrent vos pions frappés. Un pion
|
||||
adverse resté dans votre jan intérieur à la fin de la partie en fait un
|
||||
backgammon.
|
||||
|
||||
Voir aussi : [Jan extérieur](jan-exterieur.md), [Sortie](sortie.md), [Barre](barre.md), [Point](point.md)
|
||||
@@ -0,0 +1,27 @@
|
||||
---
|
||||
term: Notation des coups
|
||||
english: Move notation
|
||||
code: MoveNotation
|
||||
---
|
||||
# Notation des coups
|
||||
|
||||
**En bref** : un coup s'écrit case de départ/case d'arrivée pour chaque pion, comme 13/7 ; une étoile signale un pion adverse frappé (24/22*), « off » un pion sorti (6/off), « bar » un pion qui rentre depuis la barre (bar/22), et (2) un déplacement fait deux fois avec un double (8/4(2)).
|
||||
|
||||
Ustagammon écrit les coups comme GNU Backgammon, le moteur qui les calcule.
|
||||
Chaque joueur numérote les cases de son propre point de vue, de 1 (la case la
|
||||
plus proche de sa sortie) à 24 :
|
||||
|
||||
- `13/7` : un pion part de la case 13 et arrive sur la case 7. Un coup de deux
|
||||
dés compte en général deux déplacements, séparés par une espace :
|
||||
`13/7 8/7`.
|
||||
- `24/22*` : l'étoile dit que le pion arrivé sur la 22 y a frappé un pion
|
||||
seul de l'adversaire, qui part sur la barre.
|
||||
- `6/off` : le pion de la case 6 sort du plateau.
|
||||
- `bar/22` : un pion qui était sur la barre rentre sur la case 22.
|
||||
- `8/4(2)` : le même déplacement, fait deux fois. Avec un double, on joue
|
||||
quatre fois le dé : `8/4(2) 6/2(2)` déplace deux pions de 8 à 4 et deux de
|
||||
6 à 2.
|
||||
- `13/1*` : un seul pion qui avance avec ses deux dés s'écrit d'un trait,
|
||||
de sa case de départ à sa case d'arrivée.
|
||||
|
||||
Voir aussi : [Coup](coup.md), [Demi-coup](demi-coup.md), [Barre](barre.md), [Sortie](sortie.md), [Blot](blot.md)
|
||||
@@ -27,6 +27,10 @@ les noms des niveaux de l'ordinateur, dans le même ordre :
|
||||
| 0,020 à 0,032 | Intermediate | Amateur |
|
||||
| 0,032 et plus | Beginner | Débutant |
|
||||
|
||||
L'écran de progression l'écrit en millièmes, comme GNU Backgammon (« mEMG ») :
|
||||
14 veut dire 0,014 d'équité perdue par décision. Sa courbe montre ce taux
|
||||
partie après partie ; plus elle descend, mieux vous jouez.
|
||||
|
||||
Le bilan montre à côté la [chance](chance.md) de vos dés sur la partie : par
|
||||
tour, la chance de vos jets ; « un peu » pour vous ou contre vous au-delà de
|
||||
0,03 par tour, « nettement » au-delà de 0,06, comme GNU Backgammon. La
|
||||
|
||||
@@ -76,9 +76,11 @@ interrompre.
|
||||
demande au moteur si le coup du joueur fait partie des coups permis attendus.
|
||||
- **Les pions bougent** : sur un écran d'explication, le coup illustré se joue
|
||||
tout seul après une seconde, pion par pion (`duration-fast`), puis reste
|
||||
affiché ; « Revoir » le rejoue. Sur les écrans à flèches (sens du jeu), un
|
||||
tracé en pointillés `text` sur un liseré ivoire, jamais en brique (la brique
|
||||
veut dire « à faire »).
|
||||
affiché ; « Revoir » le rejoue. Sur l'écran du sens du jeu, **deux flèches
|
||||
circulaires** : une claire pour vos pions, une sombre pour ceux de
|
||||
l'ordinateur, chacune dans le sens de son joueur et finissant par une pointe,
|
||||
dessinées sous les pions, avec un liseré de la couleur opposée pour rester
|
||||
lisibles sur le plateau ; jamais en brique (la brique veut dire « à faire »).
|
||||
- Un geste juste : coche `verdict-good`, vibration « pion posé », phrase
|
||||
courte, « Continuer » devient l'action principale. Un geste faux : le pion
|
||||
revient, et une phrase dit pourquoi, sans le mot « erreur ».
|
||||
@@ -87,14 +89,14 @@ interrompre.
|
||||
|
||||
### Texte, première version
|
||||
|
||||
1. **Le but du jeu.** « Vous avez quinze pions ivoire, l'ordinateur quinze
|
||||
pions noyer. Chacun fait faire le tour du plateau à ses pions, puis les
|
||||
1. **Le but du jeu.** « Vous avez quinze pions blancs, l'ordinateur quinze
|
||||
pions noirs. Chacun fait faire le tour du plateau à ses pions, puis les
|
||||
sort. Le premier qui a sorti tous les siens gagne la partie. »
|
||||
2. **Dans quel sens avancer.** « 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. »
|
||||
2. **Dans quel sens avancer.** « Vos pions suivent la flèche claire : ils
|
||||
partent d'en haut à droite, passent à gauche, puis reviennent en bas jusqu'à
|
||||
votre jan intérieur, les flèches 1 à 6. C'est là que vous les rassemblez
|
||||
avant de les sortir. Les pions de l'ordinateur suivent la flèche sombre, en
|
||||
sens inverse, jusqu'à son jan intérieur, les flèches 19 à 24. »
|
||||
3. **Les dés et les doubles.** « 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
|
||||
@@ -106,16 +108,16 @@ interrompre.
|
||||
point 7 avec 6-1.
|
||||
5. **Rentrer depuis la barre.** « Votre pion frappé attend sur la barre, au
|
||||
milieu. Avant de jouer quoi que ce soit d'autre, il doit rentrer dans le
|
||||
camp adverse, en haut à droite. Chaque dé indique une flèche d'entrée :
|
||||
jan intérieur de l'ordinateur, 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.** « Quand vos quinze pions sont dans les six dernières
|
||||
flèches, vous pouvez commencer à les sortir : un 6 sort un pion de la
|
||||
6. **Sortir ses pions.** « Quand vos quinze pions sont dans votre jan intérieur
|
||||
(flèches 1 à 6), 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* : sortir deux pions avec 6-3.
|
||||
7. **Gammon et backgammon.** « Une partie gagnée vaut 1 point. Si
|
||||
l'adversaire n'a sorti aucun pion, c'est un gammon : 2 points ; s'il lui
|
||||
reste en plus un pion sur la barre ou dans votre camp, c'est un
|
||||
reste en plus un pion sur la barre ou dans votre jan intérieur, c'est un
|
||||
backgammon : 3 points. En partie libre, gammon et backgammon ne comptent
|
||||
que si le videau a été doublé : c'est la règle de Jacoby. »
|
||||
8. **Le videau.** « Le cube aux faces 2, 4, 8, 16, 32 et 64 sert à miser.
|
||||
@@ -164,7 +166,7 @@ ouvrir, partager et retrouver avec le bouton « retour » du téléphone.
|
||||
| « Je découvre », « Toutes les leçons » | `#/tutoriel/1` |
|
||||
| Étape *n* du tutoriel (« Continuer », « Retour », segments de la barre de progression) | `#/tutoriel/<n>` |
|
||||
| « Je connais les règles », « Reprendre la partie » | l'écran de jeu |
|
||||
| Un terme du jeu dans un texte (blot, barre, camp…) | `#/glossaire/<terme>` |
|
||||
| Un terme du jeu dans un texte (blot, barre, jan intérieur…) | `#/glossaire/<terme>` |
|
||||
| Une position illustrée, « Voir sur le plateau » | `#/position/<Position ID>/<Match ID>` |
|
||||
| Aide, onglet « Jouer » | `#/aide` ; une question ouverte : `#/aide/<scénario>` |
|
||||
| Menu | `#/menu` |
|
||||
@@ -174,3 +176,20 @@ ouvrir, partager et retrouver avec le bouton « retour » du téléphone.
|
||||
- Le bouton « retour » du téléphone ferme d'abord la feuille ou la question
|
||||
ouverte, puis revient à l'écran précédent ; il ne quitte jamais une partie
|
||||
sans retour possible.
|
||||
|
||||
## Vocabulaire : les jans
|
||||
|
||||
Le plateau se partage en quatre jans de six flèches. Les nommer simplifie les
|
||||
explications (retour des premiers joueurs, 2026-11-11) :
|
||||
|
||||
| Flèches | Nom |
|
||||
| --- | --- |
|
||||
| 1 à 6 | votre jan intérieur |
|
||||
| 7 à 12 | votre jan extérieur |
|
||||
| 13 à 18 | le jan extérieur de l'ordinateur |
|
||||
| 19 à 24 | le jan intérieur de l'ordinateur |
|
||||
|
||||
Le tutoriel, l'aide, le maître et les leçons emploient ces mots, avec un lien
|
||||
vers leur fiche du glossaire à la première apparition. Fiches à créer :
|
||||
`jan-interieur.md` (anglais : home board) et `jan-exterieur.md` (anglais :
|
||||
outer board). « Camp » n'est plus employé dans les textes.
|
||||
|
||||
@@ -38,8 +38,8 @@ Ouvert depuis le menu, l'accueil et la fin de partie. Une seule page, sans
|
||||
3. **Pendant la partie** : interrupteur « Conseil du maître », sous-titre
|
||||
« Une ligne après chacun de vos coups ».
|
||||
4. **Barre d'action collée en bas** : un seul bouton principal, qui dit ce
|
||||
qu'il lance (« Commencer la partie » ou « Commencer le match en 5
|
||||
points »), et la mention « Vos choix sont gardés pour la prochaine fois. ».
|
||||
qu'il lance (« Commencer la partie » ou « Commencer le match » ; choix
|
||||
de Pierre du 2026-11-11, la longueur se lit déjà sur le bouton choisi), et la mention « Vos choix sont gardés pour la prochaine fois. ».
|
||||
|
||||
Par défaut : partie libre, Intermédiaire, conseil activé. Fermer (croix en
|
||||
haut à gauche, ou retour du téléphone) ne change rien à la partie en cours.
|
||||
@@ -160,7 +160,7 @@ remplace en place, sans saut.
|
||||
lecture simple du jeu et des dés (« Votre jeu a fait la différence, avec
|
||||
des dés un peu contre vous. »), construite à partir du taux d'erreur et de
|
||||
la chance donnés par le moteur, jamais inventée.
|
||||
2. Trois cases côte à côte : le nombre de bons coups, d'imprécisions et
|
||||
2. Trois tuiles côte à côte : le nombre de bons coups, d'imprécisions et
|
||||
d'erreurs (grosses erreurs comprises), chiffre en 28 px, mot et icône en
|
||||
couleur d'appréciation.
|
||||
3. Deux jauges sur une ligne chacune :
|
||||
@@ -236,8 +236,18 @@ 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,
|
||||
« Passer », « Retour », « Continuer », écrans « à vous » contrôlés par le
|
||||
moteur).
|
||||
« 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 ».
|
||||
|
||||
## 1h. Progression (`#/progression`)
|
||||
|
||||
@@ -245,14 +255,28 @@ Maquette « Progression ».
|
||||
|
||||
- Une phrase de synthèse, factuelle (« Sur vos 8 dernières parties, vous
|
||||
faites moins d'erreurs : votre taux d'erreur est passé de 14 à 7. »). Si la
|
||||
tendance monte, la phrase reste encourageante et sans reproche (« Votre
|
||||
taux d'erreur remonte un peu ces derniers jours. Les leçons sont là
|
||||
pour reprendre un point. »).
|
||||
tendance monte, la phrase reste encourageante et sans reproche, sans
|
||||
minimiser ni dramatiser (« Sur vos 8 dernières parties, votre taux
|
||||
d'erreur remonte, de 7 à 12. Les leçons sont là pour reprendre un
|
||||
point. »).
|
||||
- La courbe du taux d'erreur par partie terminée, avec le rappel « plus bas,
|
||||
c'est mieux » dans son titre, le dernier point en `verdict-good`, et un lien
|
||||
vers la fiche « taux d'erreur ».
|
||||
- La liste des parties (résultat, date, niveau de l'ordinateur, taux d'erreur
|
||||
en chiffre gras aligné à droite), chacune ouvrant son bilan.
|
||||
c'est mieux » dans son titre et un lien vers la fiche « taux d'erreur ».
|
||||
Le dernier point est plus gros, à l'encre du texte ; il ne passe en
|
||||
`verdict-good` que si la tendance baisse (le vert dit « mieux », jamais
|
||||
« dernier »). Les valeurs s'écrivent au-dessus du premier et du dernier
|
||||
point seulement ; l'axe ne porte que la ligne du 0, sans chiffres qui
|
||||
doublent ceux des points.
|
||||
- La liste des parties : chaque ligne est un seul lien vers son bilan
|
||||
(chevron à droite), sans bouton par ligne ; l'analyse s'ouvre depuis le
|
||||
bilan. Sur la ligne : le résultat court (« Gagnée, 1 point », « Perdue,
|
||||
gammon, 2 points », jamais « partie simple »), puis en `caption`
|
||||
`text-muted` la date et le niveau (« 12 nov. 2026 · contre
|
||||
Intermédiaire », le niveau sans couleur), et à droite le taux d'erreur en
|
||||
chiffre gras.
|
||||
- Sans partie terminée : une phrase et « Nouvelle partie » en action
|
||||
principale, pleine largeur en portrait.
|
||||
- Pas de bouton « Retour » dans l'en-tête : la barre du haut et le retour du
|
||||
téléphone suffisent, comme sur la liste des leçons.
|
||||
- Moins de trois parties analysées : pas de courbe, une phrase « Votre
|
||||
courbe apparaîtra après trois parties. ».
|
||||
|
||||
@@ -260,14 +284,225 @@ Maquette « Progression ».
|
||||
|
||||
- **Glisser-déposer** : le pion suit le doigt, agrandi à 115 % et décalé de
|
||||
12 px vers le haut pour rester visible sous le doigt ; à son départ, un pion
|
||||
fantôme en pointillés ; les cases d'arrivée permises gardent leur voile et
|
||||
leur anneau (révision UX). Lâché sur une case permise : il s'y pose
|
||||
fantôme en pointillés ; les flèches d'arrivée permises gardent leur voile et
|
||||
leur anneau (révision UX). Lâché sur une flèche permise : il s'y pose
|
||||
(`duration-fast`, vibration « pion posé ») ; lâché ailleurs : il revient à
|
||||
sa place en `duration-fast`, sans vibration ni message.
|
||||
- **Double toucher** : joue le coup évident quand il existe (un seul coup légal
|
||||
complet commence par ce pion, ou le moteur n'en propose qu'un) ; sinon, il
|
||||
vaut un simple toucher, sans message d'erreur.
|
||||
- **Toucher simple** : inchangé (pion puis case, ou avance directe s'il n'a
|
||||
qu'une case d'arrivée).
|
||||
- **Toucher simple** : inchangé (pion puis flèche, ou avance directe s'il n'a
|
||||
qu'une flèche d'arrivée).
|
||||
- Les trois gestes coexistent ; aucun n'est expliqué par une fenêtre : le
|
||||
tutoriel et l'aide en parlent.
|
||||
|
||||
## Retours des joueurs, 2e lot : Crawford sur la barre, abandon
|
||||
|
||||
Maquettes : rangée « Retours des joueurs, 2e lot » du canevas, et les
|
||||
artboards « Match en 5 points » et « Match en paysage » de la rangée 1c,
|
||||
mis à jour.
|
||||
|
||||
### Crawford sur la barre
|
||||
|
||||
Pendant la partie Crawford, le videau ne peut pas être proposé : c'est ce
|
||||
que le joueur doit voir, là où il regarde, sur le plateau.
|
||||
|
||||
- Le mot **« Crawford »** est écrit à la verticale sur la barre (lecture de
|
||||
bas en haut), en capitales, `font-weight` 800, espacement 0,12 em, dans
|
||||
une capsule au contour de 1,5 px. Texte et contour prennent la couleur
|
||||
des numéros du cadre (`#F3EAD8` en clair, `#CDBFA8` en sombre), soit plus
|
||||
de 9:1 sur le bois de la barre. Pas de brique : la brique veut dire « à
|
||||
faire », et Crawford est un état.
|
||||
- **Taille** : 11 px au moins à l'écran, en portrait comme en paysage. La
|
||||
capsule fait environ 15 px de large en portrait, dans une barre d'environ
|
||||
22 px.
|
||||
- **Place** : dans la moitié haute de la barre, entre le bord du cadre et le
|
||||
premier pion frappé. En paysage, elle se place sous la pastille du compte
|
||||
de points de l'ordinateur, sans la toucher (8 px d'écart au moins).
|
||||
- **Pions frappés** : la capsule est dessinée sous eux. Si la moitié haute
|
||||
porte plus de pions frappés que la moitié basse, elle passe dans la
|
||||
moitié basse, symétriquement (au-dessus de la pastille de votre compte en
|
||||
paysage). Les pions ne bougent jamais pour elle.
|
||||
- **Le videau** reste à sa place, à 40 % d'opacité pendant cette partie.
|
||||
Le bouton « Doubler » disparaît de la barre d'actions.
|
||||
- **La ligne du match** en portrait ne dit plus « partie Crawford » :
|
||||
« Match en 5 points » suffit, la barre porte l'information. En paysage,
|
||||
le préfixe de la consigne disparaît aussi.
|
||||
- **Accessibilité** : `role="note"`, avec le libellé « Partie Crawford : le
|
||||
videau ne peut pas être proposé pendant cette partie. ». Ce n'est pas une
|
||||
cible tactile : la capsule ne doit pas gêner les touchers sur les flèches
|
||||
7 et 18. L'explication vient de l'aide et de la fiche du glossaire.
|
||||
- **Apparition** : un fondu en `duration-base` (160 ms) au début de la partie
|
||||
Crawford, et rien d'autre. Elle disparaît à la partie suivante.
|
||||
|
||||
### Abandonner une partie
|
||||
|
||||
Comme dans gnubg : le joueur propose d'abandonner en concédant une partie
|
||||
simple, un gammon ou un backgammon, et l'ordinateur accepte ou refuse selon
|
||||
le moteur.
|
||||
|
||||
- **Quand** : à votre tour, avant de lancer les dés, comme pour doubler.
|
||||
C'est le seul moment où le drapeau est présent ; ailleurs, il n'apparaît
|
||||
pas. Jamais dans le tutoriel ni dans les leçons.
|
||||
- **Le drapeau** : une icône de drapeau au trait (20 px, couleur du texte,
|
||||
jamais en brique), dans un bouton rond de 52 px.
|
||||
- En portrait, il est à gauche dans la barre d'actions, avant
|
||||
« Doubler » et « Lancer les dés ».
|
||||
- En paysage, il est dans la colonne de commandes, au-dessus du bouton
|
||||
d'annulation.
|
||||
- Son libellé accessible est « Abandonner la partie ». Le menu propose
|
||||
aussi la ligne « Abandonner la partie », active au même moment
|
||||
seulement.
|
||||
- **La feuille de choix**, qui monte depuis le bas (artboard « Abandon :
|
||||
feuille de choix ») :
|
||||
- le titre « Abandonner la partie » ;
|
||||
- une phrase : « Vous proposez de perdre tout de suite. L'ordinateur
|
||||
accepte ou refuse, selon le moteur. », complétée par « Le videau est
|
||||
à 2. » quand il a été doublé ;
|
||||
- un choix unique en cartes de 56 px : « Une partie simple »,
|
||||
« Un gammon », « Un backgammon », chacune avec les points en jeu à
|
||||
droite, videau compris ;
|
||||
- en match, les points se lisent comme le score qui en résulterait :
|
||||
« vous 2, l'ordinateur 5 · fin du match » ;
|
||||
- seuls les choix qui peuvent changer le résultat sont proposés. Ce sont
|
||||
ceux que le moteur juge possibles ; en partie libre, sans videau
|
||||
doublé, la règle de Jacoby fait que gammon et backgammon ne comptent
|
||||
pas, donc seule « Une partie simple » reste, sans liste de choix ;
|
||||
- le choix par défaut est le plus petit ;
|
||||
- les actions sont « Continuer la partie » (secondaire) et « Proposer »
|
||||
(principale) ;
|
||||
- la feuille elle-même tient lieu de confirmation : pas de deuxième
|
||||
question.
|
||||
- **La réponse** arrive après un temps court. Pendant le calcul,
|
||||
« Proposer » devient « L'ordinateur réfléchit… », désactivé.
|
||||
- **Il accepte** : la feuille se ferme et la partie se termine comme une
|
||||
défaite ordinaire, avec la fin de partie puis le bilan. Le résultat
|
||||
s'écrit « Perdue par abandon, gammon, 2 points » dans la fin de partie,
|
||||
le bilan et la progression. L'abandon accepté ne s'annule pas : c'est
|
||||
la seule action du jeu sans retour, et la feuille l'a dit.
|
||||
- **Il refuse** : la feuille se ferme, la partie continue et le drapeau
|
||||
reste disponible. Un message du maître s'affiche au-dessus de la barre
|
||||
d'actions (artboard « Abandon : l'ordinateur refuse ») : le drapeau,
|
||||
puis « L'ordinateur refuse. » en gras, puis la raison donnée par le
|
||||
moteur. Le message reste jusqu'au lancer des dés.
|
||||
- **Ton** : des faits, ni reproche ni encouragement à abandonner. Phrases
|
||||
à ajouter à `docs/maitre/phrases.md`, puis à valider par Pierre :
|
||||
- `abandon.refus` : « L'ordinateur refuse. D'après le moteur, il peut
|
||||
espérer plus que {concession} en continuant. La partie continue. »
|
||||
({concession} : « une partie simple », « un gammon ») ;
|
||||
- `abandon.accepte` : « L'ordinateur accepte votre abandon : vous perdez
|
||||
{concession}, {points}. ».
|
||||
- **Glossaire** : nouvelle fiche `abandon.md` (anglais : resign). En bref :
|
||||
« Abandonner, c'est concéder la partie avant la fin, en proposant de
|
||||
perdre une partie simple, un gammon ou un backgammon. L'adversaire
|
||||
accepte ou refuse ; s'il refuse, la partie continue. »
|
||||
- **Hors périmètre** : l'ordinateur qui propose lui-même d'abandonner
|
||||
(gnubg sait le faire). C'est à décider plus tard, avec Pierre.
|
||||
|
||||
## Bilan du match (`#/matchs/<id>/bilan`)
|
||||
|
||||
Demande de Pierre (2026-11-12) : une analyse du match entier, surtout pour
|
||||
le videau. Maquettes : rangée « Analyse du match entier » du canevas
|
||||
(« Bilan du match », page entière, et « Vos parties : un match = une
|
||||
ligne »). L'exemple suit le match de Pierre : mené 0–4, gagné 5–4.
|
||||
|
||||
Le bilan d'une partie (1e) reste tel quel. Le bilan du match a la même
|
||||
forme et les mêmes composants, de haut en bas :
|
||||
|
||||
1. **L'en-tête** : en `caption`, « Fin du match · 5 points · <date> » ; en
|
||||
`display`, « Vous gagnez le match » ou « L'ordinateur gagne le match » ;
|
||||
puis une phrase factuelle avec le score final et le plus grand retard
|
||||
remonté, s'il y en a un (« 5 à 4, en cinq parties, après avoir été mené 0
|
||||
à 4. »). Pour un match en cours : « Match en cours · 1–2 » et « Reprendre
|
||||
le match » en action principale.
|
||||
2. **La courbe des chances de gagner le match**, la pièce maîtresse :
|
||||
- une seule ligne continue, de 0 à 100 %, de la première décision à la
|
||||
dernière : la fin d'une partie est le début de la suivante ;
|
||||
- les parties sont séparées par un trait vertical `border` ; au-dessus de
|
||||
chaque partie, le score **au début** de celle-ci (« 0–2 ») ; en
|
||||
dessous, son nom court (« P2 ») ;
|
||||
- la partie Crawford a un fond `surface-sunken` ;
|
||||
- la ligne des 50 % est en pointillés, avec son libellé ;
|
||||
- trois marques, sans autre couleur sur la courbe : le moment décisif
|
||||
(anneau `last`, or), la décision à revoir (point `verdict-blunder`) et
|
||||
la fin (point `verdict-good` si le match est gagné, `text` sinon) ;
|
||||
- une légende de trois éléments sous la courbe ;
|
||||
- **lecture au doigt** : glisser sur la courbe affiche une bulle (partie,
|
||||
coup, score, chances en %), avec un trait vertical qui suit le doigt et
|
||||
une vibration légère à chaque changement de partie ; lâcher garde la
|
||||
bulle ; toucher la bulle ouvre la décision dans l'analyse. Au clavier :
|
||||
← → d'une décision à l'autre, Entrée pour ouvrir.
|
||||
C'est la seule cible de la courbe : tout son cadre (au moins 150 px de
|
||||
haut) sert de zone tactile.
|
||||
- le libellé accessible résume la courbe en une phrase (départ, plus bas,
|
||||
fin, moment décisif).
|
||||
3. **« Partie par partie »** : une liste, une ligne de 52 px par partie (son
|
||||
numéro, son résultat court comme dans la progression, qui a marqué
|
||||
combien, et le score **après** la partie en chiffres gras à droite) ;
|
||||
chaque ligne ouvre le bilan de la partie. Le score se lit toujours
|
||||
« vous – l'ordinateur », et une phrase `caption` le rappelle sous la
|
||||
liste.
|
||||
4. **« Votre jeu sur le match »** : trois tuiles, le taux d'erreur de tout
|
||||
le match, celui des pions et celui du videau, en millièmes comme ailleurs.
|
||||
En dessous, les jauges « Votre jeu » et « Les dés » du bilan de partie,
|
||||
calculées sur tout le match.
|
||||
5. **« Le videau »**, le cœur de la demande :
|
||||
- une phrase : « 8 décisions de videau ; pour 2 d'entre elles, j'aurais
|
||||
fait autrement. » ;
|
||||
- une ligne par décision qui diffère de celle du moteur, regroupée par
|
||||
sorte, dans l'ordre : mauvais double, double manqué, prise fautive,
|
||||
refus fautif. Sur la ligne : la sorte, « Partie 2, coup 6, à 0–2 » (le
|
||||
score compte beaucoup au videau), et la perte de chances de gagner le
|
||||
match en %, colorée comme le verdict ;
|
||||
- les sortes sans écart tiennent en une phrase (« Prises et refus :
|
||||
aucune différence avec mes choix. ») ;
|
||||
- chaque ligne ouvre la décision dans l'analyse.
|
||||
6. **« Le moment décisif »** : la décision ou le jet qui a fait le plus
|
||||
varier les chances de gagner le match. Une phrase factuelle (« Avec 5-5,
|
||||
vos chances de gagner le match passent de 24 % à 46 %. C'est le plus
|
||||
grand écart du match. ») et le lien « Voir ce coup ». Pas de jugement :
|
||||
ce peut être un coup de dés.
|
||||
7. **« La décision à revoir »** : la plus grande perte de chances de gagner
|
||||
le match, pions ou videau, présentée comme « le coup à revoir » du bilan
|
||||
de partie (miniature, verdict, phrase du maître, « Revoir » et
|
||||
« Demander à une IA »). Le prompt est le gabarit « bilan de mon match ».
|
||||
8. **« Exporter le match (SGF) »**, un lien avec une phrase : « pour l'ouvrir
|
||||
dans GNU Backgammon ».
|
||||
|
||||
**Barre d'actions** : « Revoir le match » (secondaire, ouvre l'analyse à
|
||||
la première décision de la partie 1) et « Nouveau match » (principale).
|
||||
|
||||
**Unités** : en match, les pertes s'expriment en **chances de gagner le
|
||||
match** (%), à une décimale, et jamais en équité. Le taux d'erreur reste en
|
||||
millièmes, comme dans la progression.
|
||||
|
||||
### Accès
|
||||
|
||||
- **Fin d'un match** : la fin de partie de la dernière partie propose « Bilan
|
||||
du match » en action principale, à la place de « Nouvelle partie » ; le
|
||||
bilan de cette partie garde un lien « Voir le bilan du match ».
|
||||
- **Bilan d'une partie de match** : sous l'en-tête, une ligne `caption`
|
||||
« Partie 2 du match en 5 points · <lien>Bilan du match</lien> ».
|
||||
- **Analyse d'une partie de match** : en tête, « Partie 2 sur 5 · 0–2 »,
|
||||
avec « ‹ » et « › » (44 px) pour passer à la partie précédente ou
|
||||
suivante. La courbe de l'analyse reste celle de la partie.
|
||||
- **Listes (« Vos parties », progression)** : un match tient en **une
|
||||
ligne** :
|
||||
- le titre « Match en 5 points · gagné 5–4 » (« perdu », « en cours,
|
||||
1–2 ») ;
|
||||
- un trait par partie, `verdict-good` si vous l'avez gagnée,
|
||||
`border-strong` sinon, et un trait en pointillés pour la partie à
|
||||
venir d'un match en cours ;
|
||||
- en `caption`, la date, le nombre de parties et le niveau ;
|
||||
- à droite, le taux d'erreur du match ; pour un match en cours,
|
||||
« Reprendre » en brique ;
|
||||
- la ligne ouvre le bilan du match (le match en cours, s'il n'est pas
|
||||
fini). Les parties libres gardent leur ligne.
|
||||
- **Courbe de la progression** : inchangée, un point par partie, y compris
|
||||
les parties de match.
|
||||
|
||||
### Paysage
|
||||
|
||||
Deux colonnes : à gauche, l'en-tête, la courbe et « Partie par partie » ; à
|
||||
droite, le jeu, le videau, le moment décisif et la décision à revoir. La
|
||||
barre d'actions reste en bas de la colonne de droite.
|
||||
|
||||
@@ -5,7 +5,7 @@ Ce système s'applique à la PWA (`web/`). Les noms des couleurs du plateau sont
|
||||
## Contenus et ton
|
||||
|
||||
- **Vouvoiement**, phrases courtes, verbes d'action. Le joueur est « vous », l'adversaire est « l'ordinateur » (ou le nom d'un joueur réel).
|
||||
- **Les mots du jeu**, toujours les mêmes, ceux du glossaire (`docs/glossaire/`) : flèche, pion, barre, videau, compte de points, sortir. Un terme technique s'explique la première fois qu'il apparaît, avec un lien vers sa fiche.
|
||||
- **Les mots du jeu**, toujours les mêmes, ceux du glossaire (`docs/glossaire/`) : flèche, pion, barre, videau, compte de points, sortir. Une flèche (le triangle) s’appelle toujours « flèche », jamais « case » ; un « point » est une flèche tenue par au moins deux pions du même joueur. Un terme technique s'explique la première fois qu'il apparaît, avec un lien vers sa fiche.
|
||||
- **Les boutons disent ce qu'ils font** : « Lancer les dés », « Annuler », « Doubler », « Prendre », « Passer », « Nouvelle partie ». Pas de « OK », pas de « Valider ». Les textes de l'app vivent dans `app/src/ui/texts.ts`.
|
||||
- **Le maître encourage, il ne juge pas.** « Bien joué : ce coup protège votre point 8. » plutôt que « Coup correct (−0,012) ». Les chiffres d'équité ne viennent que sur demande.
|
||||
- **Les erreurs disent quoi faire** : « Ce pion ne peut pas aller là : la flèche est tenue par deux pions adverses. »
|
||||
@@ -18,11 +18,11 @@ La palette, c'est le bois, les pions et le café, avec **un seul accent : le rou
|
||||
|
||||
- Fond de page `background`, cartes et panneaux `surface`, zones en retrait `surface-sunken`.
|
||||
- Texte `text` ; texte secondaire `muted`, jamais pour une consigne.
|
||||
- `accent` est rare et a toujours un sens : le bouton principal de l'écran (un seul), un lien, le pion sélectionné (`highlight`), la case où il peut aller (`target`), le focus clavier (`focus`). Ce n'est jamais une couleur de décoration ni une couleur d'erreur.
|
||||
- `accent` est rare et a toujours un sens : le bouton principal de l'écran (un seul), un lien, le pion sélectionné (`highlight`), la flèche où il peut aller (`target`), le focus clavier (`focus`). Ce n'est jamais une couleur de décoration ni une couleur d'erreur.
|
||||
- `accent-soft` : fond léger pour signaler « c'est à vous », avec du texte `text` dessus.
|
||||
- Les appréciations de coups ont leurs propres couleurs, distinctes de la brique : `verdict-good` (vert), `verdict-doubtful` (ocre), `verdict-bad` (lie-de-vin). **Toujours avec le mot et l'icône**, jamais la couleur seule.
|
||||
- `brand-laiton` (via `last-move`) marque le dernier coup joué, comme les charnières en laiton d'un plateau de tavla. Rien d'autre.
|
||||
- Le plateau : cadre `frame` en noyer, fond `bed`, flèches impaires en `point-a` (noyer clair) et paires en `point-b` (chêne), barre `bar`, sorties `tray`. Le joueur a les pions ivoire (`checker-player`, l'apprenti), l'ordinateur les pions noyer (`checker-computer`, le maître), comme dans le logo.
|
||||
- Le plateau : cadre `frame` en noyer, fond `bed`, flèches impaires en `point-a` (noyer clair) et paires en `point-b` (chêne), barre `bar`, sorties `tray`. Le joueur a les pions blancs (`checker-player`), l'ordinateur les pions noirs (`checker-computer`), chacun avec un contour et un filet (`checker-player-edge`, `checker-computer-edge`) qui le détachent de tous les fonds ; les pions du logo restent ivoire et noyer (ADR 0010, amendement du 2026-11-11).
|
||||
- **À éviter** : le vert feutre de casino, le noir et or, les dégradés, les néons.
|
||||
|
||||
Les deux thèmes, clair et sombre, sont complets. Tous les textes atteignent 4,5:1 sur leurs fonds dans les deux thèmes ; les contours et marques porteuses de sens, 3:1.
|
||||
|
||||
@@ -49,7 +49,7 @@ Chaque action a son raccourci, affiché sur le bouton en petite touche (`kbd`) q
|
||||
| --- | --- |
|
||||
| `Espace` | Lancer les dés ; jouer le coup quand il est complet ; passer quand aucun coup n'est possible |
|
||||
| `←` `→` | Choisir un pion jouable (le focus se voit : anneau `focus`) |
|
||||
| `↑` `↓` | Choisir sa case d'arrivée |
|
||||
| `↑` `↓` | Choisir sa flèche d'arrivée |
|
||||
| `Entrée` | Poser le pion |
|
||||
| `Z` | Annuler le demi-coup (aussi `Ctrl+Z`) |
|
||||
| `D` | Doubler |
|
||||
|
||||
@@ -8,6 +8,9 @@ Référence : `interaction.md`, maquettes « Révision UX » du canevas.
|
||||
## Toutes tailles
|
||||
|
||||
- [ ] Flèches impaires en `point-a`, paires en `point-b` ; 12 et 13 de couleurs différentes.
|
||||
- [ ] Numéros des flèches à l'extérieur du plateau, sur le cadre (bande haute et bande basse), en portrait comme en paysage ; jamais dans le fond du plateau entre les deux rangées. (Vérifié par `tests/browser/retours-joueurs.mjs`.)
|
||||
- [ ] Pions du joueur lisibles partout : en thème clair, contour `checker-player-edge` à 3:1 au moins sur les flèches en chêne et sur le fond. Règle automatique : `checkerContrastProblems` de `tests/browser/recette.mjs`.
|
||||
- [ ] Pions de l'ordinateur lisibles partout : contour et filet `checker-computer-edge` (gris clair) visibles sur la barre, sur les flèches sombres et en thème sombre. Règle automatique : là où sa couleur ne se détache pas de son fond (moins de 3:1), son contour s'en détache à 3:1 au moins (`checkerContrasts` de `tests/browser/recette.mjs`).
|
||||
- [ ] Une seule action principale (brique) visible à la fois.
|
||||
- [ ] Aucun `window.confirm` ni `alert` dans le code de l'app (`grep -rn "confirm(\|alert(" app/src` vide).
|
||||
- [ ] Aucune cible tactile sous 44 × 44 px (boutons, pictogrammes, dés, videau).
|
||||
|
||||
@@ -216,96 +216,96 @@
|
||||
{
|
||||
"name": "checker-player",
|
||||
"value": {
|
||||
"light": "#F7F0E2",
|
||||
"dark": "#EDE3CF"
|
||||
"light": "#FAFAF7",
|
||||
"dark": "#F2F2EF"
|
||||
},
|
||||
"usage": "Pions du joueur : ivoire, l'apprenti."
|
||||
"usage": "Pions du joueur : blancs."
|
||||
},
|
||||
{
|
||||
"name": "checker-player-edge",
|
||||
"value": {
|
||||
"light": "#8C7A62",
|
||||
"dark": "#8E7C64"
|
||||
"light": "#55554F",
|
||||
"dark": "#6E6E69"
|
||||
},
|
||||
"usage": "Contour et filet des pions du joueur."
|
||||
"usage": "Contour des pions blancs. Thème clair : #55554F, pour 3:1 au moins sur les flèches en chêne (3,5:1) et le fond (5,5:1). Thème sombre : le pion blanc se détache déjà par sa couleur (plus de 5:1)."
|
||||
},
|
||||
{
|
||||
"name": "checker-computer",
|
||||
"value": {
|
||||
"light": "#2E1C12",
|
||||
"dark": "#140D09"
|
||||
"light": "#1C1C1C",
|
||||
"dark": "#141414"
|
||||
},
|
||||
"usage": "Pions de l'ordinateur : noyer sombre, le maître."
|
||||
"usage": "Pions de l'ordinateur : noirs."
|
||||
},
|
||||
{
|
||||
"name": "checker-computer-edge",
|
||||
"value": {
|
||||
"light": "#120A05",
|
||||
"dark": "#A8957D"
|
||||
"light": "#B5B5B0",
|
||||
"dark": "#B5B5B0"
|
||||
},
|
||||
"usage": "Contour et filet des pions de l'ordinateur ; clair en thème sombre pour rester visible."
|
||||
"usage": "Contour et filet des pions noirs : gris clair dans les deux thèmes, pour qu'ils se détachent de la barre, des flèches sombres et du fond sombre."
|
||||
},
|
||||
{
|
||||
"name": "count-player",
|
||||
"value": {
|
||||
"light": "#2B1D15",
|
||||
"dark": "#2B1D15"
|
||||
"light": "#1C1C1C",
|
||||
"dark": "#1C1C1C"
|
||||
},
|
||||
"usage": "Nombre écrit sur une pile de pions du joueur."
|
||||
"usage": "Nombre écrit sur une pile de pions blancs."
|
||||
},
|
||||
{
|
||||
"name": "count-computer",
|
||||
"value": {
|
||||
"light": "#F3EAD8",
|
||||
"dark": "#F3EAD8"
|
||||
"light": "#FAFAF7",
|
||||
"dark": "#FAFAF7"
|
||||
},
|
||||
"usage": "Nombre écrit sur une pile de pions de l'ordinateur."
|
||||
"usage": "Nombre écrit sur une pile de pions noirs."
|
||||
},
|
||||
{
|
||||
"name": "die-player",
|
||||
"value": {
|
||||
"light": "#F7F0E2",
|
||||
"dark": "#EDE3CF"
|
||||
"light": "#FAFAF7",
|
||||
"dark": "#F2F2EF"
|
||||
},
|
||||
"usage": "Dés du joueur."
|
||||
"usage": "Dés du joueur, blancs comme ses pions."
|
||||
},
|
||||
{
|
||||
"name": "pip-player",
|
||||
"value": {
|
||||
"light": "#2B1D15",
|
||||
"dark": "#2B1D15"
|
||||
"light": "#1C1C1C",
|
||||
"dark": "#1C1C1C"
|
||||
},
|
||||
"usage": "Points des dés du joueur."
|
||||
},
|
||||
{
|
||||
"name": "die-computer",
|
||||
"value": {
|
||||
"light": "#2E1C12",
|
||||
"dark": "#140D09"
|
||||
"light": "#1C1C1C",
|
||||
"dark": "#141414"
|
||||
},
|
||||
"usage": "Dés de l'ordinateur."
|
||||
"usage": "Dés de l'ordinateur, noirs comme ses pions."
|
||||
},
|
||||
{
|
||||
"name": "pip-computer",
|
||||
"value": {
|
||||
"light": "#F3EAD8",
|
||||
"dark": "#EDE3CF"
|
||||
"light": "#FAFAF7",
|
||||
"dark": "#F2F2EF"
|
||||
},
|
||||
"usage": "Points des dés de l'ordinateur."
|
||||
},
|
||||
{
|
||||
"name": "cube",
|
||||
"value": {
|
||||
"light": "#F7F0E2",
|
||||
"dark": "#EDE3CF"
|
||||
"light": "#FAFAF7",
|
||||
"dark": "#F2F2EF"
|
||||
},
|
||||
"usage": "Face du videau."
|
||||
},
|
||||
{
|
||||
"name": "cube-text",
|
||||
"value": {
|
||||
"light": "#2B1D15",
|
||||
"dark": "#2B1D15"
|
||||
"light": "#1C1C1C",
|
||||
"dark": "#1C1C1C"
|
||||
},
|
||||
"usage": "Valeur du videau (2, 4, 8…)."
|
||||
},
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,84 @@
|
||||
# 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é.
|
||||
@@ -0,0 +1,66 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,62 @@
|
||||
# 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é.
|
||||
@@ -0,0 +1,76 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,64 @@
|
||||
# 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.
|
||||
+33
-2
@@ -1,7 +1,7 @@
|
||||
# Les phrases du maître
|
||||
|
||||
Les mots et les phrases du conseil du maître (étape 1d) et du bilan de fin
|
||||
de partie (étape 1e), en une seule source (ADR 0008) : `make site` copie ce
|
||||
Les mots et les phrases du conseil du maître (étape 1d), du bilan de fin
|
||||
de partie (étape 1e) et de l'écran de progression (étape 1h), en une seule source (ADR 0008) : `make site` copie ce
|
||||
dossier dans le site publié
|
||||
(`docs/maitre/`), l'application le lit à l'exécution
|
||||
(`app/src/domain/analysis/phrases.ts`), et `make test` rend chaque gabarit
|
||||
@@ -39,6 +39,9 @@ mots entre accolades sont remplis avec les données du moteur :
|
||||
| `{roll}` | le jet, « 3-1 » |
|
||||
| `{hits}` | le nombre de pions frappés, en chiffres |
|
||||
| `{dice}` | la chance de vos dés sur la partie, en mots (bilan, étape 1e) |
|
||||
| `{parties}` | le nombre de parties analysées sur la courbe (progression, étape 1h) |
|
||||
| `{debut}` | le taux d'erreur moyen de la plus ancienne moitié de ces parties, en millièmes (14 pour 0,014) |
|
||||
| `{fin}` | le même, sur la plus récente moitié |
|
||||
|
||||
Un fait (sections `fait.…`) s'ajoute à la phrase seulement si la ligne reste
|
||||
sous 140 caractères.
|
||||
@@ -281,3 +284,31 @@ erreurs.
|
||||
## bilan.aucun-coup
|
||||
|
||||
- Aucun coup à revoir : vous avez joué comme je l'aurais fait.
|
||||
|
||||
## progression.baisse
|
||||
|
||||
Écran de progression (étape 1h), au moins trois parties analysées : le
|
||||
taux d'erreur moyen de la plus récente moitié des parties est plus bas que
|
||||
celui de la plus ancienne (moyennes arrondies en millièmes ; la partie du
|
||||
milieu, quand leur nombre est impair, ne compte dans aucune).
|
||||
|
||||
- Sur vos {parties} dernières parties, vous faites moins d'erreurs : votre taux d'erreur est passé de {debut} à {fin}.
|
||||
|
||||
## progression.hausse
|
||||
|
||||
Le taux moyen remonte : la phrase reste encourageante, sans reproche, sans
|
||||
minimiser ni dramatiser.
|
||||
|
||||
- Sur vos {parties} dernières parties, votre taux d'erreur remonte, de {debut} à {fin}. Les leçons sont là pour reprendre un point.
|
||||
|
||||
## progression.stable
|
||||
|
||||
Les deux moyennes, arrondies, sont égales.
|
||||
|
||||
- Sur vos {parties} dernières parties, votre taux d'erreur reste à {fin}.
|
||||
|
||||
## progression.attente
|
||||
|
||||
Moins de trois parties analysées : pas encore de courbe.
|
||||
|
||||
- Votre courbe apparaîtra après trois parties.
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
# 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,30 +0,0 @@
|
||||
# Progression (1h)
|
||||
|
||||
- **Date** : 2026-11-03
|
||||
- **Étape** : 1h (`docs/etape-1.md`)
|
||||
- **Statut** : prévu (après 1e)
|
||||
- **Exécutant** : sous-agent general-purpose, dans un worktree
|
||||
|
||||
## Objectif
|
||||
|
||||
Mettre en place la progression du cadrage pour la V1 : l'historique des parties et la courbe du taux d'erreur.
|
||||
|
||||
## Consignes
|
||||
|
||||
1. **Écran `#/progression`.** Il est conforme à la charte et s'ajoute au tableau de l'ADR 0013. Il montre :
|
||||
- la courbe du taux d'erreur par partie terminée, en SVG à la main, sans dépendance ;
|
||||
- la liste des parties, reliée à leur bilan et à leur analyse (1e) ;
|
||||
- un résumé en une phrase.
|
||||
2. **Accueil.** Il reçoit la « ligne de progression » de la charte.
|
||||
3. **Données.** Elles proviennent des analyses enregistrées : rien n'est recalculé si l'analyse existe. Une partie non analysée est analysée en arrière-plan.
|
||||
4. **Tests.**
|
||||
- Scénarios Gherkin.
|
||||
- `make lint`, `make test`, `make test-browser` avec trois parties jouées, et captures.
|
||||
|
||||
## Critères de réussite
|
||||
|
||||
Après trois parties, la courbe a trois points égaux aux taux d'erreur des bilans. Les trois commandes passent.
|
||||
|
||||
## Résultat
|
||||
|
||||
À remplir à la clôture.
|
||||
@@ -0,0 +1,42 @@
|
||||
# Analyse d'un match entier
|
||||
|
||||
- **Date** : 2026-11-12
|
||||
- **Étape** : 1 (demande de Pierre)
|
||||
- **Statut** : prévu (après le 2e lot de retours des joueurs, et une fois les maquettes de la session « identité » disponibles)
|
||||
- **Exécutant** : sous-agent general-purpose, dans un worktree
|
||||
|
||||
## Objectif
|
||||
|
||||
Demande de Pierre du 2026-11-12 : « proposer une analyse complète du match, notamment pour l'utilisation du videau (pas que l'analyse d'une manche) ». Il cite son propre exemple : mené 4-0 après deux parties, il a gagné le match en 5 points trois parties plus tard.
|
||||
|
||||
Aujourd'hui, chaque partie d'un match a son bilan et son analyse (1e), mais rien ne montre le match dans son ensemble.
|
||||
|
||||
## Consignes
|
||||
|
||||
Respecter les ADR 0007, 0008, 0011 et 0013. Suivre les règles et maquettes de la session « identité ».
|
||||
|
||||
1. **Bilan du match.** Une adresse (par exemple `#/matchs/<id>/bilan`) donne :
|
||||
- le résultat et le déroulé du score, partie par partie ;
|
||||
- le taux d'erreur du joueur sur tout le match, à la manière de gnubg (statistiques du match) ;
|
||||
- les erreurs de videau regroupées : doubles manqués, mauvais doubles, prises et refus fautifs ;
|
||||
- le moment décisif ;
|
||||
- « le coup à revoir » du match.
|
||||
2. **Courbe du match.** Elle suit les chances de gagner le match (MWC, tables d'équité de match du moteur) à travers toutes les parties, et non les chances de gagner la partie. Toucher un point ouvre la décision.
|
||||
3. **Données.**
|
||||
- Réutiliser les analyses déjà rangées dans les journaux de chaque partie (`review`).
|
||||
- Relier les parties d'un même match. Aujourd'hui, une partie = un journal (choix de 1c). Il faut un identifiant de match commun, avec un format durable et une migration testée. Les matchs déjà joués sont rattachés si c'est possible, sinon on le dit honnêtement.
|
||||
- Les équités de match viennent du moteur. Le domaine ne fait aucun calcul de table d'équité.
|
||||
4. **Accès.** Depuis le bilan de la dernière partie d'un match, « Voir l'analyse du match ». Depuis « Mes parties » et la progression, le match apparaît comme une entrée.
|
||||
5. **Prompt.** Un gabarit « bilan de mon match », avec un bouton « Demander à une IA » (1f).
|
||||
6. **Export SGF.** Exporter le match entier : MI et toutes les parties.
|
||||
7. **Tests.** Scénario Gherkin d'un match en 5 points avec dés imposés, où le joueur remonte après avoir été mené. Tests unitaires de l'agrégation, référence MWC comparée à gnubg de bureau.
|
||||
|
||||
Tests : `make lint`, `make test`, `make test-browser`, captures pour la recette design.
|
||||
|
||||
## Critères de réussite
|
||||
|
||||
Sur un match joué jusqu'au bout, le bilan du match reprend les erreurs de videau de toutes les parties, et la courbe des chances de gagner le match est continue d'une partie à l'autre. Les trois commandes passent.
|
||||
|
||||
## Résultat
|
||||
|
||||
À remplir à la clôture.
|
||||
@@ -0,0 +1,53 @@
|
||||
# Retours des joueurs, 2e lot : Crawford sur la barre, abandon
|
||||
|
||||
- **Date** : 2026-11-12
|
||||
- **Étape** : 1 (retours des joueurs relayés par Pierre)
|
||||
- **Statut** : prévu (après 1h et 1i, et les maquettes de la session « identité »)
|
||||
- **Exécutant** : sous-agent general-purpose, dans un worktree
|
||||
|
||||
## Objectif
|
||||
|
||||
Deux retours de joueurs relayés par Pierre le 2026-11-12 :
|
||||
- « il serait bien que le Crawford soit en évidence sur la barre plutôt » ;
|
||||
- « possibilité de pouvoir abandonner (un petit drapeau ?) si on sait qu'on va perdre, et pouvoir proposer simple, gammon, backgammon sans que l'ordinateur ne se fasse arnaquer (il refuse un simple si on sait qu'il y aura gammon par exemple) ».
|
||||
|
||||
## Consignes
|
||||
|
||||
Respecter les ADR 0005, 0007, 0008, 0011 et 0013. Suivre les règles et les maquettes de la session « identité », qui seront ajoutées dans `docs/identite/charte/`.
|
||||
|
||||
1. **Crawford sur la barre.** L'indication de la partie Crawford est placée sur la barre du plateau, en portrait et en paysage, à l'endroit fixé par la charte.
|
||||
|
||||
2. **Abandon, côté moteur.**
|
||||
- La décision d'accepter ou de refuser un abandon (simple, gammon ou backgammon) reprend le code de GNU Backgammon de bureau qui répond à un abandon proposé par l'humain : évaluation de la position, équité de l'abandon (`getResignation`, `getResignEquity` ou leurs équivalents dans gnubg 1.08.003), comparaison à l'équité de la partie continuée. Les sources sont citées fichier et ligne.
|
||||
- Ces fichiers ne sont pas dans la copie : porter le code dans `engine.c`, comme pour la chance en 1a, sans modifier `engine/gnubg/`.
|
||||
- Exposer la décision par `engine.h`, `engine-cli` et l'adaptateur.
|
||||
- Ajouter des positions de référence comparées à gnubg de bureau (Docker), avec au moins :
|
||||
- un simple refusé parce que le gammon est probable ;
|
||||
- un simple accepté ;
|
||||
- un gammon accepté ;
|
||||
- un cas de match.
|
||||
|
||||
3. **Abandon, côté domaine.**
|
||||
- Un événement d'abandon, proposé puis accepté ou refusé, rétrocompatible : migration testée (ADR 0007).
|
||||
- Fin de partie avec les points selon la valeur de l'abandon et le videau.
|
||||
- Prise en compte dans le match (score, Crawford), le bilan, la progression et l'export SGF (convention de gnubg pour l'abandon).
|
||||
|
||||
4. **Abandon, côté interface.**
|
||||
- Le drapeau, la feuille de choix et la réponse de l'ordinateur, selon la charte.
|
||||
- Messages dans le ton du maître, ajoutés à `docs/maitre/phrases.md` ou à une source sœur.
|
||||
|
||||
5. **Aide et glossaire.**
|
||||
- Fiche « Abandon » (resign).
|
||||
- Scénarios Gherkin avec dés imposés : abandon accepté, abandon refusé, abandon en match.
|
||||
|
||||
Tests : `make lint`, `make test`, `make test-browser` (suite complète), avec captures pour la recette design.
|
||||
|
||||
## Critères de réussite
|
||||
|
||||
- Sur une position de référence où le gammon est probable, un abandon simple est refusé, comme dans gnubg de bureau.
|
||||
- Un abandon accepté termine la partie avec les bons points.
|
||||
- Les trois commandes passent.
|
||||
|
||||
## Résultat
|
||||
|
||||
À remplir à la clôture.
|
||||
@@ -23,7 +23,7 @@ d'après le cadrage, **à relire** par Pierre.
|
||||
| [videau.md](videau.md) | Expliquer cette décision de videau | Feuille du maître, bilan, analyse |
|
||||
| [bilan.md](bilan.md) | Bilan de ma partie | Bilan de fin de partie |
|
||||
| [concept.md](concept.md) | Expliquer ce concept | Fiche d'un terme du glossaire |
|
||||
| [exercices.md](exercices.md) | Créer des exercices | Progression (étape 1h, pas encore de bouton) |
|
||||
| [exercices.md](exercices.md) | Demander des exercices à une IA | Écran de progression (étape 1h) |
|
||||
|
||||
Chaque prompt contient :
|
||||
|
||||
@@ -54,5 +54,5 @@ Repères communs à plusieurs gabarits :
|
||||
| `{diagramme}` | le plateau en texte : vos pions X, ceux de l'ordinateur O, dans votre numérotation |
|
||||
| `{score}` | « partie libre » ou le score du match |
|
||||
| `{videau}` | sa valeur et qui le possède |
|
||||
| `{niveau_eval}` | le niveau d'évaluation de GNU Backgammon, « analyse à 2 plis » |
|
||||
| `{niveau_eval}` | le niveau d'évaluation de GNU Backgammon, « analyse à 2 plis » ; pour un classement de coups, celui auquel le moteur a évalué le meilleur coup (voir [coup.md](coup.md)) |
|
||||
| `{langue}` | la langue de la réponse : « français » |
|
||||
|
||||
@@ -12,7 +12,15 @@ Repères propres à ce gabarit :
|
||||
| `{coup_joue}` | votre coup, dans la notation de GNU Backgammon |
|
||||
| `{appreciation}` | le mot du maître : « Bon coup », « Imprécision », « Erreur » ou « Grosse erreur » |
|
||||
| `{rang}` | « le meilleur coup » ou « le 4e choix sur 15 coups possibles » |
|
||||
| `{classement}` | le classement du moteur : les trois meilleurs coups, et le vôtre s'il n'en est pas, avec leur équité et leur écart au meilleur, comme dans « Voir les chiffres du moteur » |
|
||||
| `{classement}` | le classement de l'analyse de votre coup : les trois meilleurs coups, et le vôtre s'il n'en est pas, avec leur équité et leur écart au meilleur, comme dans « Voir les chiffres du moteur » |
|
||||
|
||||
Le niveau du titre (`{niveau_eval}`) est celui auquel GNU Backgammon a
|
||||
évalué le meilleur coup. C'est d'ordinaire le niveau de l'analyse (2 plis),
|
||||
mais le moteur s'arrête plus tôt quand un seul coup passe ses filtres : à
|
||||
l'ouverture 3-1 jouée 8/5 6/5, tout le classement est à 1 pli. Les coups que
|
||||
ses filtres ont écartés gardent l'évaluation d'un niveau inférieur, et leur
|
||||
ligne le dit (« évalué à 1 pli »). Les équités et l'écart de votre coup sont
|
||||
ceux de l'analyse, que disent la feuille du maître et l'écran d'analyse.
|
||||
|
||||
## Gabarit
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
# Créer des exercices
|
||||
|
||||
Prévu pour l'écran de progression (étape 1h) : le gabarit est écrit et
|
||||
testé, sans bouton pour l'instant. Il rassemble les erreurs de vos
|
||||
dernières parties analysées. Texte sous licence CC BY-SA 4.0 (voir
|
||||
Le bouton « Demander des exercices à une IA » de l'écran de progression (étape 1h,
|
||||
`#/progression/ia`). Il rassemble les erreurs de vos dernières parties
|
||||
analysées (les 20 plus récentes au plus), et ce qui y revient. Texte sous licence CC BY-SA 4.0 (voir
|
||||
[README.md](README.md)).
|
||||
|
||||
Repères propres à ce gabarit :
|
||||
@@ -11,6 +11,7 @@ Repères propres à ce gabarit :
|
||||
| --- | --- |
|
||||
| `{parties}` | le nombre de parties analysées |
|
||||
| `{erreurs}` | vos erreurs les plus coûteuses sur ces parties, avec leur coût |
|
||||
| `{themes}` | ce qui revient dans toutes vos imprécisions et erreurs de ces parties, compté sur les plateaux décodés par le moteur : combien de coups et de décisions de videau ; combien de fois le coup du maître frappait un pion et le vôtre non, faisait un point que le vôtre ne faisait pas, ou l'inverse (votre coup frappait, le sien non) |
|
||||
| `{position_titre}` | la plus coûteuse d'entre elles |
|
||||
|
||||
## Gabarit
|
||||
@@ -23,6 +24,9 @@ Voici mes {parties} dernières parties contre l'ordinateur, analysées par GNU B
|
||||
Mes erreurs les plus coûteuses, avec leur coût en équité :
|
||||
{erreurs}
|
||||
|
||||
Ce qui revient dans ces erreurs, compté sur les plateaux décodés par GNU Backgammon :
|
||||
{themes}
|
||||
|
||||
{position_titre} (GNU Backgammon, Position ID : {position_id}, Match ID : {match_id} ; ces identifiants la décrivent du point de vue du joueur qui a la main : {au_trait}).
|
||||
Elle s'ouvre aussi à cette adresse : {lien}
|
||||
|
||||
|
||||
@@ -65,4 +65,6 @@ 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 |
|
||||
| [suivre-ma-progression.feature](suivre-ma-progression.feature) | Suivre sa progression : courbe du taux d'erreur, parties, exercices | Étape 1h |
|
||||
| [installer-l-application.feature](installer-l-application.feature) | Installer l'application | Étape 0e.5 |
|
||||
|
||||
@@ -4,7 +4,9 @@ Fonctionnalité: Choisir sa partie et jouer un match
|
||||
L'écran « Nouvelle partie » s'ouvre depuis le menu, l'accueil et, à la
|
||||
fin d'une partie, par « Changer les réglages ». Vous y choisissez une partie libre ou un match en 3, 5, 7
|
||||
ou 11 points, le niveau de l'ordinateur, et si le maître commente vos
|
||||
coups. Vos choix sont gardés pour la prochaine fois.
|
||||
coups. Vos choix sont gardés pour la prochaine fois. « Commencer la
|
||||
partie » (ou « Commencer le match ») lance aussitôt la partie, sur le
|
||||
plateau, même juste après le bilan d'une partie finie.
|
||||
|
||||
Scénario: Comment choisir le niveau de l'ordinateur ?
|
||||
Six niveaux, du Débutant, qui laisse passer des occasions, au Champion,
|
||||
|
||||
@@ -144,7 +144,7 @@ Fonctionnalité: Jouer une partie contre l'ordinateur
|
||||
|
||||
@position:BQAACAIAAAAAAA
|
||||
Scénario: Sortir ses pions dans l'ordre de son choix
|
||||
Une fois tous vos pions dans les six dernières flèches, vous les
|
||||
Une fois tous vos pions dans votre jan intérieur (flèches 1 à 6), vous les
|
||||
sortez dans l'ordre que vous voulez. Avec 6-1, un pion sur la flèche 6
|
||||
et un sur la flèche 1 : sortez l'un puis l'autre, ou l'inverse. Si vous
|
||||
ne sortez que le pion de la 6, « Jouer ce coup » apparaît déjà, car ce
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
# language: fr
|
||||
Fonctionnalité: Suivre ma progression
|
||||
|
||||
« Votre progression », depuis la ligne de progression de l'accueil, montre
|
||||
votre taux d'erreur partie après partie : plus il est bas, mieux vous avez
|
||||
joué. Chaque partie terminée y figure avec son résultat, sa date, le niveau
|
||||
de l'ordinateur et son taux d'erreur, celui de son bilan ; touchez-la pour
|
||||
rouvrir son bilan, et de là son analyse. Une phrase du maître dit, en chiffres,
|
||||
comment évolue votre taux d'erreur.
|
||||
|
||||
Scénario: Comment voir ma progression ?
|
||||
Après trois parties analysées, la courbe montre un point par partie, de
|
||||
la plus ancienne à la plus récente. Chaque point est le taux d'erreur du
|
||||
bilan de la partie, en millièmes : 14 veut dire que vous avez perdu en
|
||||
moyenne 0,014 d'équité par décision.
|
||||
|
||||
Étant donné que vous avez terminé trois parties contre l'ordinateur
|
||||
Et que le maître les a analysées
|
||||
Quand vous ouvrez votre progression
|
||||
Alors la courbe a trois points, chacun égal au taux d'erreur du bilan de sa partie
|
||||
Et la phrase du maître compare vos premières parties aux dernières
|
||||
|
||||
Scénario: Quand ma courbe apparaît-elle ?
|
||||
Il faut trois parties terminées et analysées. Avant, la liste de vos
|
||||
parties est déjà là, avec leur taux d'erreur.
|
||||
|
||||
Étant donné que vous avez terminé deux parties contre l'ordinateur
|
||||
Et que le maître les a analysées
|
||||
Quand vous ouvrez votre progression
|
||||
Alors il n'y a pas encore de courbe
|
||||
Et la phrase de votre progression est « Votre courbe apparaîtra après trois parties. »
|
||||
|
||||
Scénario: Et une partie que le maître n'a pas encore analysée ?
|
||||
Elle est analysée en arrière-plan quand vous ouvrez votre progression, et
|
||||
elle rejoint la courbe dès que son analyse est finie. Les parties déjà
|
||||
analysées ne sont jamais recalculées.
|
||||
|
||||
Étant donné que vous avez terminé trois parties contre l'ordinateur
|
||||
Et que le maître n'a analysé que les deux premières
|
||||
Quand vous ouvrez votre progression
|
||||
Alors la troisième partie attend son analyse, sans taux d'erreur
|
||||
Quand son analyse en arrière-plan est finie
|
||||
Alors la courbe a trois points, chacun égal au taux d'erreur du bilan de sa partie
|
||||
Et les deux premières parties n'ont pas été analysées de nouveau
|
||||
|
||||
Scénario: Comment créer des exercices sur mes erreurs ?
|
||||
« Demander des exercices à une IA » prépare une question : vos erreurs
|
||||
les plus coûteuses sur vos dernières parties, avec leur coût selon le
|
||||
maître, ce qui y revient (pions frappés, points faits par le coup du
|
||||
maître et pas par le vôtre), et la position de la plus coûteuse. Votre
|
||||
niveau n'y figure qu'à partir de 20 décisions analysées en tout.
|
||||
|
||||
Étant donné que vous avez terminé trois parties contre l'ordinateur
|
||||
Et que le maître les a analysées
|
||||
Quand vous demandez des exercices
|
||||
Alors la question cite vos erreurs avec leur coût et ce qui y revient
|
||||
Et elle ne donne votre niveau que si vos parties comptent au moins 20 décisions
|
||||
@@ -0,0 +1,46 @@
|
||||
# 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
|
||||
@@ -51,7 +51,8 @@ Le texte de la fin du tutoriel.
|
||||
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 ;
|
||||
- `chemin: oui` : le sens du jeu, deux flèches circulaires sur le plateau
|
||||
(claire pour vos pions, sombre pour ceux de l'ordinateur) ;
|
||||
- `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ù
|
||||
|
||||
+11
-9
@@ -1,13 +1,15 @@
|
||||
# Les règles du backgammon
|
||||
|
||||
Tutoriel d'Ustagammon, en huit étapes. Texte sous licence CC BY-SA 4.0,
|
||||
validé par Pierre le 9 novembre 2026. Format : voir README.md.
|
||||
validé par Pierre le 9 novembre 2026 ; pions blancs et noirs, flèches
|
||||
circulaires et jans repris de la charte le 11 novembre 2026
|
||||
(docs/identite/charte/accueil-tutoriel-aide.md). 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).
|
||||
Vous avez quinze [pions](pion.md) blancs, l'ordinateur quinze pions noirs. 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).
|
||||
|
||||
@@ -16,9 +18,9 @@ Le premier qui a sorti tous les siens gagne la [partie](partie.md).
|
||||
- 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.
|
||||
Vos pions suivent la **flèche claire** : ils partent d'en haut à droite, passent à gauche, puis reviennent en bas jusqu'à votre [jan intérieur](jan-interieur.md), les flèches 1 à 6. C'est là que vous les rassemblez avant de les sortir.
|
||||
|
||||
Les pions noyer de l'ordinateur font le même chemin, en sens inverse.
|
||||
Les pions de l'ordinateur suivent la **flèche sombre**, en sens inverse, jusqu'à son jan intérieur, les flèches 19 à 24.
|
||||
|
||||
## 3. Les dés et les doubles
|
||||
|
||||
@@ -60,10 +62,10 @@ Une flèche où l'adversaire a deux pions ou plus est fermée : vous ne pouvez p
|
||||
- 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.
|
||||
- juste: Touché : le pion noir 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.
|
||||
Vous avez 6 et 1. Le pion noir de la flèche 7 est seul : frappez-le.
|
||||
|
||||
## 5. Rentrer depuis la barre
|
||||
|
||||
@@ -71,7 +73,7 @@ Vous avez 6 et 1. Le pion noyer de la flèche 7 est seul : frappez-le.
|
||||
- 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.
|
||||
Votre pion frappé attend sur la barre, au milieu. Avant de jouer quoi que ce soit d'autre, il doit rentrer dans le jan intérieur de l'ordinateur, 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.
|
||||
|
||||
@@ -81,7 +83,7 @@ Chaque dé indique une flèche d'entrée : avec 3 et 4, la 22 ou la 21. Si les d
|
||||
- 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.
|
||||
Quand vos quinze pions sont dans votre jan intérieur (flèches 1 à 6), 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.
|
||||
|
||||
@@ -101,7 +103,7 @@ Vous avez 6 et 3 : sortez deux pions.
|
||||
- jet: 2-1
|
||||
- coup: 2/off 1/off
|
||||
|
||||
Une partie gagnée vaut 1 point. Si l'adversaire n'a sorti aucun pion, 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. En partie libre, gammon et backgammon ne comptent que si le videau a été doublé : c'est la [règle de Jacoby](regle-de-jacoby.md).
|
||||
Une partie gagnée vaut 1 point. Si l'adversaire n'a sorti aucun pion, c'est un [gammon](gammon.md) : 2 points ; s'il lui reste en plus un pion sur la barre ou dans votre jan intérieur, c'est un [backgammon](backgammon.md) : 3 points. En partie libre, gammon et backgammon ne comptent que si le videau a été doublé : c'est la [règle de Jacoby](regle-de-jacoby.md).
|
||||
|
||||
## 8. Le videau
|
||||
|
||||
|
||||
+10
-2
@@ -43,7 +43,8 @@
|
||||
* analyse du coup joué (POSITION_JOUÉE :
|
||||
* position après le coup, vue du joueur
|
||||
* qui l'a joué, comme position_id dans
|
||||
* moves et legal)
|
||||
* moves et legal), avec le classement
|
||||
* de l'analyse (dix coups au plus)
|
||||
* analysecube POSITION_ID MATCH_ID DÉCISION
|
||||
* analyse d'une décision de videau :
|
||||
* no_double ou double (joueur au trait),
|
||||
@@ -684,7 +685,7 @@ run_analyse_move_command(const options *opt)
|
||||
const char *played_id = opt->args[3];
|
||||
engine_move_analysis a;
|
||||
engine_status status;
|
||||
int die1, die2;
|
||||
int die1, die2, i;
|
||||
|
||||
if (opt->arg_count != 4)
|
||||
return usage_error("arguments attendus : POSITION_ID MATCH_ID DÉS POSITION_JOUÉE");
|
||||
@@ -705,6 +706,13 @@ run_analyse_move_command(const options *opt)
|
||||
printf(", \"best\": ");
|
||||
print_move(&a.best, 1);
|
||||
}
|
||||
printf(", \"ranking\": [");
|
||||
for (i = 0; i < a.ranking_count; i++) {
|
||||
if (i > 0)
|
||||
printf(", ");
|
||||
print_move(&a.ranking[i], 1);
|
||||
}
|
||||
printf("]");
|
||||
printf(", \"equity_loss\": " NUMBER ", \"skill\": \"%s\"}\n", a.equity_loss,
|
||||
skill_names[a.skill]);
|
||||
return EXIT_SUCCESS;
|
||||
|
||||
+31
-9
@@ -512,6 +512,8 @@ engine_level_best_moves(const char *position_id, const char *match_id, int die1,
|
||||
|
||||
/* ------------------------------------------------- Analyse d'un coup joué */
|
||||
|
||||
static float luck_analysis(const char *position_id, position *pos, int die1, int die2);
|
||||
|
||||
engine_status
|
||||
engine_analyse_move(const char *position_id, const char *match_id, int die1, int die2,
|
||||
const char *played_position_id, int ply, engine_move_analysis *analysis)
|
||||
@@ -546,6 +548,17 @@ engine_analyse_move(const char *position_id, const char *match_id, int die1, int
|
||||
* premier.
|
||||
*/
|
||||
memset(analysis, 0, sizeof *analysis);
|
||||
/*
|
||||
* gnubg analyse la chance du jet juste avant le coup (AnalyzeMove,
|
||||
* l. 679-682), à 0 pli sans élagage, et garde ces évaluations dans son
|
||||
* cache, qui ne distingue pas une évaluation à 0 pli avec ou sans
|
||||
* réseaux d'élagage : le tri à 0 pli de FindnSaveBestMoves les reprend
|
||||
* ensuite, ce qui change l'équité des coups restés à 0 pli, et parfois
|
||||
* ceux que les filtres retiennent. Faire de même, sans garder le
|
||||
* résultat, donne le classement de gnubg de bureau.
|
||||
*/
|
||||
if (luck_analysis(position_id, &pos, die1, die2) == ERR_VAL)
|
||||
return ENGINE_ERROR_INTERNAL;
|
||||
if (FindnSaveBestMoves(&list, die1, die2, (ConstTanBoard) pos.board, &played_key, TRUE,
|
||||
DOUBTFUL_THRESHOLD, &pos.cube, &context,
|
||||
aaamfMoveFilterSettings[0]) < 0)
|
||||
@@ -565,6 +578,10 @@ engine_analyse_move(const char *position_id, const char *match_id, int die1, int
|
||||
status = ENGINE_OK;
|
||||
break;
|
||||
}
|
||||
/* Le classement : le début de la même liste, dans l'ordre de gnubg. */
|
||||
for (i = 0; i < list.cMoves && i < ENGINE_ANALYSIS_RANKING; i++)
|
||||
fill_move(&analysis->ranking[i], &list.amMoves[i], &pos, 1);
|
||||
analysis->ranking_count = (int) i;
|
||||
g_free(list.amMoves);
|
||||
return status;
|
||||
}
|
||||
@@ -1005,13 +1022,25 @@ luck_first(ConstTanBoard board, int i0, int j0, cubeinfo *cube)
|
||||
return equities[i0][j0] - mean / 30.0f;
|
||||
}
|
||||
|
||||
/* LuckAnalysis (analysis.c l. 252-268) : dés rangés du plus fort au plus
|
||||
* faible ; LuckFirst à la position de départ hors doubles. */
|
||||
static float
|
||||
luck_analysis(const char *position_id, position *pos, int die1, int die2)
|
||||
{
|
||||
int high = (die1 > die2 ? die1 : die2) - 1;
|
||||
int low = (die1 > die2 ? die2 : die1) - 1;
|
||||
|
||||
if (strcmp(position_id, START_POSITION_ID) == 0 && high != low)
|
||||
return luck_first((ConstTanBoard) pos->board, high, low, &pos->cube);
|
||||
return luck_normal((ConstTanBoard) pos->board, high, low, &pos->cube);
|
||||
}
|
||||
|
||||
engine_status
|
||||
engine_luck_of(const char *position_id, const char *match_id, int die1, int die2,
|
||||
engine_luck *luck)
|
||||
{
|
||||
position pos;
|
||||
engine_status status;
|
||||
int high, low;
|
||||
float value;
|
||||
|
||||
status = decode_position(position_id, match_id, &pos);
|
||||
@@ -1021,14 +1050,7 @@ engine_luck_of(const char *position_id, const char *match_id, int die1, int die2
|
||||
return status;
|
||||
start_evaluation();
|
||||
|
||||
/* LuckAnalysis (analysis.c l. 252-268) : dés rangés du plus fort au plus
|
||||
* faible ; LuckFirst à la position de départ hors doubles. */
|
||||
high = (die1 > die2 ? die1 : die2) - 1;
|
||||
low = (die1 > die2 ? die2 : die1) - 1;
|
||||
if (strcmp(position_id, START_POSITION_ID) == 0 && high != low)
|
||||
value = luck_first((ConstTanBoard) pos.board, high, low, &pos.cube);
|
||||
else
|
||||
value = luck_normal((ConstTanBoard) pos.board, high, low, &pos.cube);
|
||||
value = luck_analysis(position_id, &pos, die1, die2);
|
||||
if (value == ERR_VAL)
|
||||
return ENGINE_ERROR_INTERNAL;
|
||||
luck->luck = value;
|
||||
|
||||
+21
-1
@@ -207,6 +207,10 @@ typedef struct {
|
||||
* (double_take <= double_pass) */
|
||||
} engine_cube;
|
||||
|
||||
/* Coups gardés dans le classement d'une analyse : les dix premiers, comme
|
||||
* les positions de référence (tests/reference/README.md). */
|
||||
#define ENGINE_ANALYSIS_RANKING 10
|
||||
|
||||
/* Analyse d'un coup joué, comme l'analyse de partie de gnubg. */
|
||||
typedef struct {
|
||||
int move_count; /* nombre de coups légaux (0 : aucun coup
|
||||
@@ -218,6 +222,14 @@ typedef struct {
|
||||
float equity_loss; /* équité du coup joué moins celle du meilleur :
|
||||
* 0 ou négative */
|
||||
engine_skill skill; /* appréciation de gnubg pour cette perte */
|
||||
/* Le classement de l'analyse, du meilleur au moins bon : les premiers
|
||||
* coups de la liste que gnubg garde pour l'analyse de partie (ce que
|
||||
* montre son panneau d'analyse), chacun au niveau où gnubg l'a
|
||||
* évalué. Le meilleur coup et le coup joué y ont les mêmes évaluations
|
||||
* que best et played ; le coup joué n'y figure que s'il est parmi les
|
||||
* premiers. */
|
||||
engine_move ranking[ENGINE_ANALYSIS_RANKING];
|
||||
int ranking_count;
|
||||
} engine_move_analysis;
|
||||
|
||||
/* Analyse d'une décision de videau, comme l'analyse de partie de gnubg. */
|
||||
@@ -452,7 +464,15 @@ engine_luck_rating engine_luck_rating_of(float luck);
|
||||
* Analyse le coup joué par le joueur au trait avec le jet die1, die2, au
|
||||
* niveau ply, comme l'analyse de partie de gnubg (AnalyzeMove dans
|
||||
* analysis.c : FindnSaveBestMoves avec le coup joué, seuil « douteux »,
|
||||
* filtre « Normal », sans RefreshMoveList). played_position_id est la
|
||||
* filtre « Normal », sans RefreshMoveList), classement compris.
|
||||
*
|
||||
* Niveaux : gnubg n'évalue au niveau ply que les coups retenus par ses
|
||||
* filtres ; les autres gardent l'évaluation d'un niveau inférieur (champ
|
||||
* ply de chaque coup, même convention que gnubg de bureau : 0 = statique).
|
||||
* Si un seul coup passe le filtre du niveau ply - 1, gnubg s'arrête là, et
|
||||
* tout le classement reste à ce niveau, sauf quand le coup joué est une
|
||||
* erreur (écart au meilleur supérieur au seuil « douteux ») : le meilleur
|
||||
* coup et le coup joué sont alors réévalués au niveau ply. played_position_id est la
|
||||
* position après le coup, vue du joueur qui vient de le jouer (champ
|
||||
* position_id d'engine_move). ENGINE_ERROR_MOVE si ce n'est pas un coup
|
||||
* légal ; si aucun coup n'est possible, move_count vaut 0.
|
||||
|
||||
+46
-4
@@ -12,10 +12,21 @@
|
||||
# build/browser-shots/maitre/. Le bilan de fin de partie et l'écran
|
||||
# d'analyse (tests/browser/bilan.mjs), captures dans
|
||||
# build/browser-shots/bilan/. « Demander à une IA » (tests/browser/prompts.mjs),
|
||||
# captures dans build/browser-shots/prompts/.
|
||||
# captures dans build/browser-shots/prompts/. « Nouvelle partie » après une
|
||||
# partie finie et son bilan, par chaque entrée
|
||||
# (tests/browser/nouvelle-partie.mjs), captures dans
|
||||
# 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/. La
|
||||
# progression (tests/browser/progression.mjs : cas vide, trois parties, courbe
|
||||
# égale aux taux des bilans, ligne de l'accueil, « Créer des exercices »),
|
||||
# captures dans build/browser-shots/progression/.
|
||||
#
|
||||
# BROWSERS=chromium et SCRIPTS=accueil (ou play, match, maitre, bilan,
|
||||
# prompts) restreignent le test.
|
||||
# prompts, nouvelle-partie, retours-joueurs, lecons, progression) 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
|
||||
@@ -28,7 +39,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}"
|
||||
scripts="${SCRIPTS:-play accueil match maitre bilan prompts nouvelle-partie retours-joueurs lecons progression}"
|
||||
|
||||
unset MAKEFLAGS MFLAGS MAKELEVEL
|
||||
make -s package >/dev/null
|
||||
@@ -66,7 +77,8 @@ update_port="$(docker port "$update_container" 80/tcp | head -n1 | sed 's/.*://'
|
||||
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 "$tools/"
|
||||
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 tests/browser/progression.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
|
||||
@@ -113,5 +125,35 @@ for browser in $browsers; do
|
||||
-v "$PWD/$tools:/tools" -v "$PWD/build/browser-shots:/shots" -w /tools "$image" \
|
||||
node prompts.mjs "http://localhost:$port/" "$browser" /shots || status=1
|
||||
fi
|
||||
if [[ " $scripts " == *" nouvelle-partie "* ]]; then
|
||||
# « Nouvelle partie » après une partie finie et son bilan : la
|
||||
# nouvelle partie tout de suite ; captures dans
|
||||
# build/browser-shots/nouvelle-partie/.
|
||||
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 nouvelle-partie.mjs "http://localhost:$port/" "$browser" /shots || status=1
|
||||
fi
|
||||
if [[ " $scripts " == *" retours-joueurs "* ]]; then
|
||||
# Retours des premiers joueurs : pions sur la barre, numéros sur le
|
||||
# cadre, flèches du tutoriel ; captures dans
|
||||
# build/browser-shots/retours-joueurs/.
|
||||
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 retours-joueurs.mjs "http://localhost:$port/" "$browser" /shots || status=1
|
||||
fi
|
||||
if [[ " $scripts " == *" lecons "* ]]; then
|
||||
# Les leçons : carte de l'accueil, liste, exercices, progression ;
|
||||
# captures dans build/browser-shots/lecons/.
|
||||
docker run --rm --init --ipc=host --network host --user "$(id -u):$(id -g)" -e HOME=/tmp \
|
||||
-v "$PWD/$tools:/tools" -v "$PWD/build/browser-shots:/shots" -w /tools "$image" \
|
||||
node lecons.mjs "http://localhost:$port/" "$browser" /shots || status=1
|
||||
fi
|
||||
if [[ " $scripts " == *" progression "* ]]; then
|
||||
# La progression : courbe du taux d'erreur, parties, exercices ;
|
||||
# captures dans build/browser-shots/progression/.
|
||||
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 progression.mjs "http://localhost:$port/" "$browser" /shots || status=1
|
||||
fi
|
||||
done
|
||||
exit "$status"
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user