Compare commits

..
Author SHA1 Message Date
Pierre MARTINandClaude Opus 5.5 0d9622eff9 Test de la progression : captures après le départ du bandeau
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 19:02:10 +01:00
Pierre MARTINandClaude Opus 5.5 d31ab0e30a Fusion de origin/main (MR !61, 1g) dans progression
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 18:36:07 +01:00
Pierre MARTINandClaude Opus 5.5 cff8be8b35 Fusion de origin/lecons (recette 1g) dans progression
Conflit sur la ligne de progression de l'accueil : le texte de 1g
(« Aucune leçon terminée sur 5 », majuscule en tête) dans le lien vers
#/progression.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 14:19:08 +01:00
Pierre MARTIN a6c6c8a68c Merge remote-tracking branch 'origin/main' into progression 2026-11-12 13:57:29 +01:00
Pierre MARTINandClaude Opus 5.5 a5157dd6bd Test navigateur de la progression ; 1h faite, plan clos
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 13:57:25 +01:00
Pierre MARTINandClaude Opus 5.5 fdbbff50ea Écran de progression (#/progression) : courbe du taux d'erreur, parties, exercices
La ligne de progression de l'accueil y mène. Les taux viennent des
analyses rangées ; les parties non analysées le sont en arrière-plan.
« Créer des exercices » ouvre le prompt du gabarit exercices, avec les
thèmes des erreurs comptés sur les faits du moteur.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 13:50:02 +01:00
31 changed files with 1900 additions and 296 deletions
+25
View File
@@ -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);
+122
View File
@@ -0,0 +1,122 @@
// 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 type { GameJournal } from "../game/events.js";
import type { GameResult } 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;
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,
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 };
}
+37
View File
@@ -446,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. */
@@ -484,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`,
});
}
+2 -1
View File
@@ -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";
@@ -180,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),
+174 -16
View File
@@ -34,6 +34,11 @@ 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";
@@ -99,6 +104,7 @@ 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 {
@@ -282,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") },
@@ -1467,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);
}
}
}
@@ -1504,20 +1523,142 @@ 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,
back: (link) => leavingLink(link, (route) => route.screen === "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 : ce qui est déjà lu, puis les parties relues. */
function showProgression(): void {
const request = ++progressionRequest;
progressionTried.clear();
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
@@ -1763,6 +1904,8 @@ export function startApp(options: AppOptions): void {
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");
}
},
};
@@ -1924,8 +2067,17 @@ export function startApp(options: AppOptions): void {
const lesson = below.screen === "lesson" || below.screen === "lessonEnd";
const choosing = below.screen === "newGame";
const analysing = below.screen === "analysis";
const progression = below.screen === "progression";
const page =
help || position || home || tutorial || lessonList || lesson || choosing || analysing;
help ||
position ||
home ||
tutorial ||
lessonList ||
lesson ||
choosing ||
analysing ||
progression;
const wasPage = ui.game.hidden;
const wasScreen = ui.root.dataset.screen;
ui.game.hidden = page;
@@ -1936,6 +2088,7 @@ export function startApp(options: AppOptions): void {
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);
@@ -1978,6 +2131,11 @@ export function startApp(options: AppOptions): void {
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) {
@@ -2038,7 +2196,7 @@ export function startApp(options: AppOptions): void {
() => openReview(id),
() => undefined,
);
} else {
} else if (route.screen !== "progression") {
stopForeignReview();
}
if (route.screen === "games") {
+5
View File
@@ -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;
+1
View File
@@ -36,6 +36,7 @@ export const FEATURES = [
"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",
+2
View File
@@ -44,6 +44,8 @@ export const HOME_TEXTS = {
played === 0
? "aucune partie jouée"
: `${plural(played, "partie jouée", "parties jouées")} · ${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
+11 -4
View File
@@ -6,8 +6,8 @@
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
//
// La progression détaillée (1h) n'existe pas encore : l'accueil n'affiche
// que ce qui existe.
// 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";
@@ -219,10 +219,14 @@ export function createHomeView(options: HomeViewOptions): HomeView {
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";
@@ -294,7 +298,10 @@ export function createHomeView(options: HomeViewOptions): HomeView {
parts.push(HOME_TEXTS.progress(state.progress.played, state.progress.won));
}
const line = parts.join(" · ");
progress.textContent = line.charAt(0).toUpperCase() + line.slice(1);
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));
},
};
}
+51
View File
@@ -0,0 +1,51 @@
// 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: "partie simple",
gammon: "gammon",
backgammon: "backgammon",
} as const;
export const PROGRESSION_TEXTS = {
title: "Votre progression",
back: "Retour",
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: "Créer des exercices",
listTitle: "Vos parties",
/** « Gagnée, gammon (2 points) ». */
result: (result: GameResult | null) => {
if (result === null) {
return "Partie terminée";
}
const who = result.winner === "player" ? "Gagnée" : "Perdue";
return `${who}, ${RESULT_KIND[result.kind]} (${plural(result.points, "point", "points")})`;
},
level: (name: string) => `contre ${name}`,
rateLabel: "taux d'erreur",
rateLoading: "en analyse",
openSummary: (result: string, date: string) => `Bilan de la partie du ${date} : ${result}`,
analysis: "Analyse",
analysisLabel: (date: string) => `Analyse de la partie du ${date}`,
/** La date d'une partie : « 3 nov. 2026 ». */
date: (ms: number) => new Date(ms).toLocaleDateString("fr-FR", { dateStyle: "medium" }),
} as const;
+319
View File
@@ -0,0 +1,319 @@
// 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;
/** Branche le lien « Retour » (il revient dans l'historique). */
readonly back: (link: HTMLAnchorElement) => void;
}
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 (étiquettes de l'axe à
* gauche, valeurs au-dessus des points). */
const CURVE_HEIGHT = 168;
const PAD = { left: 34, right: 16, top: 24, bottom: 12 };
/** Au-delà, les valeurs ne s'écrivent plus au-dessus des points. */
const LABELLED_POINTS = 10;
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();
const head = element(section, "div", { className: "analysis-head" });
const back = element(head, "a", {
id: "progression-back",
className: "button secondary",
text: PROGRESSION_TEXTS.back,
});
back.href = "#/accueil";
options.back(back);
element(head, "h2", {
id: "progression-title",
className: "analysis-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.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" });
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);
summary.textContent =
data.phrases === null
? ""
: progressionSentence(data.phrases, progressionTrend(points));
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" });
drawnCurve = () => drawCurve(svg, points);
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");
}
// « Créer des exercices » : la feuille du prompt, étape 1f ; bouton secondaire.
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);
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: "muted",
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 });
}
const analysis = element(li, "a", {
className: "button secondary progression-analysis",
text: PROGRESSION_TEXTS.analysis,
});
analysis.href = routeHash({ screen: "analysis", game: entry.id, turn: null });
analysis.setAttribute("aria-label", PROGRESSION_TEXTS.analysisLabel(date));
return li;
}
return { render };
}
/** La courbe : un point par partie analysée, de gauche (la plus ancienne)
* à droite ; l'axe va de 0 au plus haut taux, en millièmes ; le dernier
* point en verdict-good (charte). */
function drawCurve(
svg: SVGSVGElement,
points: readonly { readonly id: string; readonly perDecision: number }[],
): 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));
// Le haut de l'axe : le plus haut taux, arrondi à la dizaine au-dessus.
const top = Math.max(10, Math.ceil(Math.max(...millis) / 10) * 10);
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);
for (const level of [top, 0]) {
svgElement(svg, "line", {
x1: PAD.left,
x2: width - PAD.right,
y1: y(level),
y2: y(level),
class: "curve-axis",
});
const label = svgElement(svg, "text", {
x: PAD.left - 6,
y: y(level) + 4,
class: "curve-label",
"text-anchor": "end",
});
label.textContent = String(level);
}
const line = points.map((p, i) => `${x(i)},${y(p.perDecision * 1000)}`).join(" ");
svgElement(svg, "polyline", { points: line, class: "curve-line" });
points.forEach((point, i) => {
const last = i === points.length - 1;
const dot = svgElement(svg, "circle", {
cx: x(i),
cy: y(point.perDecision * 1000),
r: last ? 6 : 4,
class: last ? "progression-dot last" : "progression-dot",
"data-id": point.id,
"data-rate": String(point.perDecision),
});
dot.setAttribute("aria-hidden", "true");
if (points.length <= LABELLED_POINTS) {
const value = svgElement(svg, "text", {
x: x(i),
y: y(point.perDecision * 1000) - 10,
class: last ? "progression-value last" : "progression-value",
"text-anchor": "middle",
});
value.textContent = String(millis[i]);
}
});
}
+18
View File
@@ -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(),
+14 -1
View File
@@ -54,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). */
@@ -130,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":
@@ -146,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). */
@@ -155,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 };
@@ -303,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":
+3
View File
@@ -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", {
+245
View File
@@ -0,0 +1,245 @@
// 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 { 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 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"]);
});
+5
View File
@@ -46,6 +46,9 @@ const TABLE: [string, Route][] = [
["#/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" }],
@@ -127,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);
}
},
);
+25
View File
@@ -218,3 +218,28 @@ sur le modèle du tutoriel :
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 « Créer des exercices », depuis la progression | `#/progression/ia` |
- La ligne de progression de l'accueil est un lien vers `#/progression`.
L'écran est une page, comme l'analyse : « Retour » (et Échap, et le
retour du téléphone) 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`) ; « Analyse » vers son analyse
(`#/parties/<id>/analyse`). Fermer le bilan ramène à la progression.
- « Créer des exercices » 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.
+1 -1
View File
@@ -24,7 +24,7 @@ Repris du cadrage (modules, écrans clés, IA générative en V1) et des ADR 001
| 1e. Fin de partie et analyse (fait) | Bilan (ADR 0011) : résultat, coups par appréciation, taux d'erreur comparé à la chance, « le coup à revoir » ; écran d'analyse : graphe d'équité (toucher un point ouvre la position), liste des erreurs filtrable, visualiseur pas à pas coup joué contre meilleur coup, « Rejouer d'ici » | 1a, 1b, 1d |
| 1f. Prompts à copier (fait) | Gabarits versionnés et testés (`docs/prompts/` ou équivalent, source unique), bouton « Demander à une IA » sur le plateau, le conseil du maître, l'analyse et la fin de partie ; le moteur décide, l'IA explique | 1d, 1e |
| 1g. Accueil, tutoriel et leçons (fait) | Accueil (reprendre, nouvelle partie, prochaine leçon, progression résumée), tutoriel des règles interactif et illustré (0e.5), cinq premières leçons numérotées (ADR 0011) avec positions et « à vous de jouer », progression locale ; contenus CC BY-SA 4.0 livrés sans relecture préalable, sous les garde-fous du moteur (décision du 2026-11-09) | 0e.5, 1a |
| 1h. Progression | Historique des parties et courbe du taux d'erreur | 1b, 1e |
| 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 |
| 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 |
+4
View File
@@ -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
+7 -232
View File
@@ -255,28 +255,14 @@ 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, 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. »).
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. »).
- La courbe du taux d'erreur par partie terminée, avec le rappel « plus bas,
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.
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.
- Moins de trois parties analysées : pas de courbe, une phrase « Votre
courbe apparaîtra après trois parties. ».
@@ -295,214 +281,3 @@ Maquette « Progression ».
qu'une case 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.
+32 -2
View File
@@ -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,30 @@ 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.
- Sur vos {parties} dernières parties, votre taux d'erreur remonte un peu, 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.
-30
View File
@@ -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.
+1 -1
View File
@@ -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) | Créer des exercices | Écran de progression (étape 1h) |
Chaque prompt contient :
+7 -3
View File
@@ -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 « Créer des exercices » 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}
+1
View File
@@ -66,4 +66,5 @@ Règles d'écriture :
| [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 |
@@ -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, ou 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 ?
« Créer des exercices » prépare une question pour une IA : 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
+14 -4
View File
@@ -20,10 +20,13 @@
# 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/.
# 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, nouvelle-partie, retours-joueurs, lecons) 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
@@ -36,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 nouvelle-partie retours-joueurs lecons}"
scripts="${SCRIPTS:-play accueil match maitre bilan prompts nouvelle-partie retours-joueurs lecons progression}"
unset MAKEFLAGS MFLAGS MAKELEVEL
make -s package >/dev/null
@@ -75,7 +78,7 @@ rm -rf build/browser-shots
mkdir -p build/browser-shots
cp tests/browser/play.mjs tests/browser/accueil.mjs tests/browser/match.mjs tests/browser/recette.mjs \
tests/browser/maitre.mjs tests/browser/bilan.mjs tests/browser/prompts.mjs tests/browser/nouvelle-partie.mjs \
tests/browser/retours-joueurs.mjs tests/browser/lecons.mjs "$tools/"
tests/browser/retours-joueurs.mjs tests/browser/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
@@ -145,5 +148,12 @@ for browser in $browsers; do
-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"
+346
View File
@@ -0,0 +1,346 @@
// progression.mjs -- l'écran de progression (étape 1h) dans un vrai
// navigateur sans écran (Playwright), sur le site servi par l'image de
// production (charte, ecrans-etape-1.md, « 1h. Progression »).
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
//
// Usage : node progression.mjs <adresse du site> <chromium|firefox> <dossier des captures>
// Lancé par tests/browser.sh. L'écran sans partie terminée, puis trois
// fins de course courtes jouées par l'interface, depuis un lien de
// position, avec des dés imposés (comme bilan.mjs) : le joueur a 6-1 et
// deux pions, sur 6 et 1 ; il joue 6/5/off (grosse erreur, l'ordinateur
// double et le joueur passe), puis 6/off 1/off (gagne), puis de nouveau
// 6/5/off. Vérifie que la ligne de l'accueil mène à #/progression, que la
// courbe a trois points égaux, dans l'ordre, aux taux d'erreur des trois
// bilans, la phrase du maître, la liste reliée aux bilans et aux analyses,
// et « Créer des exercices » (feuille du prompt). Rien d'interactif sous
// une barre d'action, aucune erreur ni violation de la politique de
// sécurité. Captures (Chromium) dans <dossier des captures>/progression/,
// en 412×915 et 915×412.
import { mkdirSync } from "node:fs";
import { chromium, firefox } from "playwright";
import { hiddenUnderActionBar, tallButtons } from "./recette.mjs";
const [base, browserName, shots] = process.argv.slice(2);
const browserType = { chromium, firefox }[browserName];
if (!base || !browserType || !shots) {
console.error("usage : node progression.mjs <adresse> <chromium|firefox> <dossier des captures>");
process.exit(2);
}
const full = browserName === "chromium";
const dir = `${shots}/progression`;
mkdirSync(dir, { recursive: true });
const PORTRAIT = { width: 412, height: 915 };
const LANDSCAPE = { width: 915, height: 412 };
const SIZES = [PORTRAIT, LANDSCAPE];
// Le joueur au trait : un pion sur 6, un sur 1 ; l'ordinateur : un sur 1,
// un sur 2 (positions de gnubg, chacun vu de lui-même).
const BEAROFF = "BQAACAIAAAAAAA";
const problems = [];
const log = (text) => console.log(`[${browserName}] ${text}`);
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
let captures = 0;
function check(condition, message) {
if (!condition) {
throw new Error(message);
}
}
const browser = await browserType.launch();
const context = await browser.newContext({
viewport: PORTRAIT,
deviceScaleFactor: 1,
locale: "fr-FR",
reducedMotion: "reduce",
});
await context.addInitScript(() => {
// Les dés imposés d'abord (un dé par valeur), puis une graine fixe.
window.__dice = [];
let state = 5;
Math.random = () => {
const die = window.__dice.shift();
if (die !== undefined) {
return (die - 0.5) / 6;
}
state = (state * 1103515245 + 12345) % 2147483648;
return state / 2147483648;
};
window.__csp = [];
document.addEventListener("securitypolicyviolation", (e) =>
window.__csp.push(`${e.effectiveDirective} ${e.blockedURI}`),
);
});
const page = await context.newPage();
page.on("pageerror", (error) => problems.push(`erreur de la page : ${error.message}`));
page.on("console", (message) => {
if (message.type() === "error") {
problems.push(`console : ${message.text()}`);
}
});
/** Attend une condition dans la page (sans page.waitForFunction, qui passe
* par eval, interdit par la politique de sécurité du site). */
async function until(what, fn, arg, ms = 120_000) {
for (let waited = 0; waited < ms; waited += 100) {
if (await page.evaluate(fn, arg)) {
return;
}
await sleep(100);
}
await page.screenshot({ path: `${dir}/${browserName}-bloque.png` }).catch(() => {});
throw new Error(`attente trop longue : ${what}`);
}
const idle = () => until("écran libre", () => !document.body.classList.contains("busy"));
/** Une capture de la recette dans les deux tailles (Chromium seulement),
* après la règle commune ; settle replace l'écran après le changement de
* taille. */
async function capture(name, settle = async () => {}) {
if (!full) {
return;
}
// Le bandeau « Reprendre l'ancienne » (10 s) d'une partie commencée.
await until("bandeau parti", () => document.getElementById("banner").hidden, null, 15_000);
for (const size of SIZES) {
await page.setViewportSize(size);
await sleep(300);
await settle();
const hidden = await hiddenUnderActionBar(page);
check(
hidden.length === 0,
`${name} ${size.width}x${size.height} : caché sous la barre d'action : ${hidden.join(", ")}`,
);
const tall = await tallButtons(page);
check(tall.length === 0, `${name} ${size.width}x${size.height} : boutons étirés : ${tall.join(", ")}`);
await page.screenshot({ path: `${dir}/${name}-${size.width}x${size.height}.png`, fullPage: true });
captures++;
}
await page.setViewportSize(PORTRAIT);
await sleep(300);
}
/** Une nouvelle partie à partir d'une position, joueur au trait. */
async function startFrom(position) {
await page.goto(`${base}#/position/${encodeURIComponent(position)}`);
await until("position lue", () => {
const play = document.getElementById("position-play");
return !play.hidden && !play.disabled;
});
await page.click("#position-play");
await until(
"partie commencée",
() =>
location.hash === "#/" &&
!document.body.classList.contains("busy") &&
!document.getElementById("roll").hidden,
);
}
const place = (point) => (point === 0 ? "off-player" : `p${point}`);
/** Lance les dés imposés et joue un coup, demi-coup par demi-coup. */
async function play(dice, halfMoves) {
await page.evaluate((d) => {
window.__dice.push(...d);
}, dice);
await page.click("#roll");
await idle();
for (const [from, to] of halfMoves) {
await page.click(`#board .hit[data-place="${place(from)}"]`);
await idle();
const land = page.locator(`#board .hit.can-land[data-place="${place(to)}"]`);
if ((await land.count()) > 0) {
await land.click();
await idle();
}
}
check(!(await page.locator("#confirm").isHidden()), `coup incomplet : ${JSON.stringify(halfMoves)}`);
await page.click("#confirm");
}
/** Le bilan de la partie qui vient de finir : son identifiant et son taux
* d'erreur (celui de la jauge « Votre jeu »). */
async function summaryRate() {
await until(
"bilan prêt",
() =>
/^#\/parties\/[a-z0-9-]+\/bilan$/.test(location.hash) &&
document.getElementById("summary")?.dataset.ready === "true",
);
return page.evaluate(() => ({
id: location.hash.split("/")[2],
rate: document.getElementById("gauge-play").dataset.errorRate,
}));
}
/** Une partie : le joueur joue 6/5/off (puis passe le double) ou 6/off
* 1/off. */
async function oneGame(mistake) {
await startFrom(BEAROFF);
if (mistake) {
await play([6, 1], [[6, 5], [5, 0]]);
await until("double de l'ordinateur", () => !document.getElementById("drop").hidden);
await page.click("#drop");
} else {
await play([6, 1], [[6, 0], [1, 0]]);
}
return summaryRate();
}
/** L'écran de progression lu, prêt (toutes ses parties analysées). */
async function progressionReady() {
await until(
"progression prête",
() =>
location.hash === "#/progression" &&
!document.getElementById("progression").hidden &&
document.getElementById("progression").dataset.ready === "true",
null,
300_000,
);
}
// --------------------------------------------------- Aucune partie terminée
await page.goto(`${base}#/progression`);
await progressionReady();
const empty = await page.evaluate(() => ({
summary: document.getElementById("progression-summary").textContent,
start: document.getElementById("progression-new-game")?.getAttribute("href") ?? null,
curve: document.getElementById("progression-curve") !== null,
list: document.getElementById("progression-list") !== null,
}));
check(
empty.summary.startsWith("Aucune partie terminée pour l'instant") &&
empty.start === "#/nouvelle-partie" &&
!empty.curve &&
!empty.list,
`cas vide : ${JSON.stringify(empty)}`,
);
await capture("1-vide");
log("progression sans partie terminée : phrase et « Nouvelle partie »");
// ----------------------------------------------------- Trois parties jouées
const games = [];
for (const mistake of [true, false, true]) {
games.push(await oneGame(mistake));
}
check(
games.every((g) => g.id && g.rate !== undefined && !Number.isNaN(Number(g.rate))),
`bilans : ${JSON.stringify(games)}`,
);
check(Number(games[0].rate) > 0 && Number(games[1].rate) === 0, `taux des bilans : ${JSON.stringify(games)}`);
log(`trois parties jouées, taux des bilans : ${games.map((g) => g.rate).join(", ")}`);
// La ligne de l'accueil mène à la progression.
await page.goto(`${base}#/accueil`);
await until("accueil", () => {
const line = document.getElementById("home-progress");
return (
!document.getElementById("home").hidden &&
line !== null &&
!line.hidden &&
line.textContent.includes("3 parties jouées")
);
});
const line = await page.evaluate(() => {
const a = document.getElementById("home-progress");
return { tag: a.tagName, href: a.getAttribute("href"), text: a.textContent };
});
check(line.tag === "A" && line.href === "#/progression", `ligne de l'accueil : ${JSON.stringify(line)}`);
await capture("3-accueil-ligne", async () => {
await page.locator("#home-progress").scrollIntoViewIfNeeded();
});
log(`accueil : « ${line.text} » mène à #/progression`);
await page.click("#home-progress");
await progressionReady();
// La courbe : trois points, dans l'ordre des parties, égaux aux taux des
// bilans.
const shown = await page.evaluate(() => ({
summary: document.getElementById("progression-summary").textContent,
dots: [...document.querySelectorAll("#progression-curve .progression-dot")].map((c) => ({
id: c.getAttribute("data-id"),
rate: c.getAttribute("data-rate"),
last: c.classList.contains("last"),
})),
items: [...document.querySelectorAll("#progression-list .progression-item")].map((li) => ({
id: li.dataset.id,
summary: li.querySelector(".progression-game").getAttribute("href"),
analysis: li.querySelector(".progression-analysis").getAttribute("href"),
rate: li.querySelector(".progression-rate")?.dataset.rate,
})),
glossary: document.getElementById("progression-glossary")?.getAttribute("href"),
exercises: document.getElementById("progression-exercises")?.getAttribute("href"),
}));
check(shown.dots.length === 3, `courbe : ${shown.dots.length} points`);
for (const [i, game] of games.entries()) {
const dot = shown.dots[i];
check(dot.id === game.id && dot.rate === game.rate, `point ${i + 1} : ${JSON.stringify(dot)} contre ${JSON.stringify(game)}`);
}
check(shown.dots[2].last && !shown.dots[0].last, "le dernier point n'est pas mis en avant");
check(shown.summary.startsWith("Sur vos 3 dernières parties"), `phrase : ${shown.summary}`);
check(!shown.summary.includes("!"), `phrase : ${shown.summary}`);
check(shown.items.length === 3 && shown.items[0].id === games[2].id, `liste : ${JSON.stringify(shown.items)}`);
for (const item of shown.items) {
const game = games.find((g) => g.id === item.id);
check(
item.summary === `#/parties/${item.id}/bilan` &&
item.analysis === `#/parties/${item.id}/analyse` &&
item.rate === game.rate,
`ligne : ${JSON.stringify(item)}`,
);
}
check(shown.glossary === "#/glossaire/taux-d-erreur", `lien du glossaire : ${shown.glossary}`);
check(shown.exercises === "#/progression/ia", `exercices : ${shown.exercises}`);
await capture("2-trois-parties");
log(`courbe : ${shown.dots.map((d) => d.rate).join(", ")} ; « ${shown.summary} »`);
// Une ligne ouvre le bilan ; le fermer ramène à la progression.
await page.click(`#progression-list .progression-item[data-id="${games[0].id}"] .progression-game`);
await until(
"bilan depuis la progression",
([id]) =>
location.hash === `#/parties/${id}/bilan` &&
document.getElementById("summary")?.dataset.ready === "true",
[games[0].id],
);
await page.click("#summary-close");
await progressionReady();
log("une ligne ouvre le bilan, la croix ramène à la progression");
// « Créer des exercices » : la feuille du prompt.
await page.click("#progression-exercises");
await until(
"prompt des exercices",
() =>
location.hash === "#/progression/ia" &&
document.getElementById("prompt-page")?.dataset.ready === "true",
);
const prompt = await page.textContent("#prompt-text");
check(prompt.includes("Voici mes 3 dernières parties"), `prompt : ${prompt}`);
check(prompt.includes("Ce qui revient dans ces erreurs"), `prompt : ${prompt}`);
check(prompt.includes("Je débute au backgammon."), `prompt : ${prompt}`);
check(!/[{}]/.test(prompt), `repère non rempli : ${prompt}`);
await page.keyboard.press("Escape");
await until("retour à la progression", () => location.hash === "#/progression");
log("« Créer des exercices » : prompt avec les erreurs et leurs thèmes");
const csp = await page.evaluate(() => window.__csp);
for (const violation of csp) {
problems.push(`politique de sécurité : ${violation}`);
}
await browser.close();
if (problems.length > 0) {
for (const problem of problems) {
console.error(`[${browserName}] ${problem}`);
}
process.exit(1);
}
log(`progression : tout est conforme (${captures} captures)`);
+124 -1
View File
@@ -1836,10 +1836,31 @@ html.bare .topbar {
line-height: 20px;
}
.home-progress:empty {
/* La ligne de progression : un lien vers #/progression (1h), haut de
* 44 px pour le doigt, en caption. */
.home-progress {
display: flex;
gap: var(--space-1);
align-items: center;
min-height: 44px;
text-decoration: none;
}
.home-progress[hidden] {
display: none;
}
.home-progress-text {
text-decoration: underline;
text-underline-offset: 3px;
}
.home-progress .icon {
flex: none;
width: 16px;
height: 16px;
}
.home-footer {
display: flex;
gap: 10px;
@@ -3482,6 +3503,108 @@ html.landscape .summary-panel > .summary-end {
.curve-band { fill: transparent; cursor: pointer; }
.curve-band.selected { fill: var(--accent); fill-opacity: 0.08; }
/* La progression (#/progression, 1h) : la phrase du maître, la courbe du
* taux d'erreur (le dernier point en verdict-good), puis les parties. La
* page reprend l'en-tête et les cartes de l'analyse. */
.progression-summary {
margin: 0 0 var(--space-4);
font-size: 17px;
line-height: 24px;
}
.progression-summary:empty {
display: none;
}
.progression-curve {
display: block;
width: 100%;
height: 168px;
font-family: var(--font-sans);
}
.progression-dot { fill: var(--text); }
.progression-dot.last { fill: var(--verdict-good); }
.progression-value { fill: var(--muted); font-size: 12px; }
.progression-value.last { fill: var(--verdict-good); font-weight: 700; }
.progression .link-button {
display: inline-flex;
align-items: center;
}
.progression-actions {
display: flex;
justify-content: flex-end;
margin: 0 0 var(--space-4);
}
.progression-list {
display: flex;
flex-direction: column;
gap: var(--space-2);
margin: var(--space-2) 0 0;
padding: 0;
list-style: none;
}
.progression-item {
display: flex;
gap: var(--space-2);
align-items: center;
padding: var(--space-2) var(--space-3);
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-md);
}
/* Toute la ligne ouvre le bilan de la partie. */
.progression-game {
display: flex;
flex: 1;
gap: var(--space-3);
align-items: center;
min-width: 0;
min-height: 44px;
color: inherit;
text-decoration: none;
}
.progression-text {
display: flex;
flex: 1;
flex-direction: column;
min-width: 0;
}
.progression-text .muted {
font-size: 15px;
line-height: 20px;
}
.progression-rate {
display: flex;
flex: none;
flex-direction: column;
align-items: flex-end;
text-align: right;
}
.progression-rate strong {
font-size: 22px;
line-height: 26px;
font-variant-numeric: tabular-nums;
}
.progression-rate.pending {
font-size: 13px;
color: var(--muted);
}
.progression-analysis {
flex: none;
}
/* Le visualiseur : « ‹ Coup 7 sur 24 · 6-1 › », le plateau d'avant le
* coup, la phrase du maître, votre coup contre celui du maître. */
.viewer-nav {
+4
View File
@@ -110,6 +110,10 @@
app/src/ui/analysis-view.ts. -->
<section id="analysis" class="analysis page" hidden></section>
<!-- La progression (#/progression) : la courbe du taux d'erreur et les
parties terminées, construite par app/src/ui/progression-view.ts. -->
<section id="progression" class="analysis progression page" hidden></section>
<!-- L'aide : un onglet par adresse (#/aide, #/glossaire, #/a-propos). -->
<section id="help" class="help page" hidden>
<!-- La barre haute de l'aide reste en vue quand la page défile. -->