Fusion de main (progression, 1h) dans plateau-soigne

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Pierre MARTIN
2026-11-12 23:31:46 +01:00
co-authored by Claude Opus 5.5
35 changed files with 2340 additions and 78 deletions
+15
View File
@@ -8,6 +8,7 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { test } from "node:test";
import { homeModel } from "../src/ui/home-model.js";
import { installPlatform } from "../src/ui/install.js";
import {
DEFAULT_SETTINGS,
@@ -45,6 +46,20 @@ test("réglages d'une version précédente : les nouveaux champs prennent leur v
assert.equal(parseSettings('{"tutorialSeen": -3, "welcomed": "oui"}').tutorialSeen, 0);
});
test("carte de la partie : sans partie en cours, ni miniature ni compte de la partie finie", () => {
const settings = { ...DEFAULT_SETTINGS, welcomed: true, gamesFinished: 1 };
// Partie en cours, ou moteur qui se charge : la miniature et le compte.
assert.deepEqual(
[homeModel(settings, true, false).board, homeModel(settings, true, false).resume],
[true, true],
);
assert.equal(homeModel(settings, null, false).board, true);
// Partie finie : le titre et « Nouvelle partie » seulement (recette 1h).
const over = homeModel(settings, false, false);
assert.equal(over.board, false);
assert.equal(over.resume, false);
});
test("premier lancement : tant que rien n'a été fait, une seule fois", () => {
assert.ok(isFirstLaunch(DEFAULT_SETTINGS));
// La partie commencée d'office au chargement ne compte pas.
+266
View File
@@ -0,0 +1,266 @@
// Tests de la progression (étape 1h) : le taux d'erreur de chaque partie
// terminée est celui du bilan, lu dans les analyses rangées sans rien
// recalculer ; la courbe, sa tendance et la phrase du maître ; le prompt
// « Créer des exercices » sur plusieurs parties (thèmes des erreurs, niveau
// du joueur à partir de 20 décisions cumulées).
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { test } from "node:test";
import {
missingAnalyses,
readyForSummary,
runAnalysis,
summarizeAnalysis,
} from "../src/domain/analysis/game-analysis.js";
import { PHRASE_KEYS, progressionSentence } from "../src/domain/analysis/phrases.js";
import {
CURVE_MIN_GAMES,
errorRateMillis,
type FinishedGame,
progressionCurve,
progressionEntries,
progressionTrend,
} from "../src/domain/analysis/progression.js";
import type { Engine } from "../src/domain/game/engine.js";
import type { GameJournal } from "../src/domain/game/events.js";
import { currentLine } from "../src/domain/game/events.js";
import { Game } from "../src/domain/game/game.js";
import { replay } from "../src/domain/game/state.js";
import {
exercisesPosition,
exercisesPrompt,
LEVEL_MIN_DECISIONS,
LEVEL_NAMES,
mistakeThemes,
} from "../src/domain/prompts/prompts.js";
import { PROMPT_FILES, parsePromptTemplate } from "../src/domain/prompts/templates.js";
import { HOME_TEXTS } from "../src/ui/home-texts.js";
import { PROGRESSION_TEXTS } from "../src/ui/progression-texts.js";
import { parseRoute, routeHash } from "../src/ui/router.js";
import { loadPhrases } from "./support/advice.js";
import { loadNodeEngine } from "./support/node-engine.js";
import { playToTheEnd, seededDice } from "./support/play.js";
const root = new URL("../../../", import.meta.url);
const phrases = loadPhrases();
const point = (id: string, perDecision: number) => ({ id, perDecision });
async function analyseAll(engine: Engine, journal: GameJournal): Promise<GameJournal> {
let current = journal;
for (let i = 0; i < 500; i++) {
const next = missingAnalyses(current)[0];
if (next === undefined) {
return current;
}
current = await runAnalysis(engine, current, next);
}
throw new Error("l'analyse ne finit pas");
}
/** Une course courte jouée jusqu'au bout (le joueur prend le choice-ième
* coup légal), non analysée. */
async function race(engine: Engine, seed: number, choice: number): Promise<GameJournal> {
const game = Game.start(engine, {
setup: { position: "trsXAADs7mwAAA", firstTurn: "player" },
level: "intermediate",
dice: seededDice(seed),
});
await playToTheEnd(game, engine, choice);
return game.journal;
}
function finished(id: string, startedAt: number, journal: GameJournal): FinishedGame {
return {
id,
startedAt,
level: "intermediate",
result: replay(currentLine(journal)).result,
journal,
};
}
test("l'adresse #/progression et celle de « Créer des exercices »", () => {
assert.deepEqual(parseRoute("#/progression"), { screen: "progression" });
assert.equal(routeHash({ screen: "progression", ai: true }), "#/progression/ia");
});
test("le résultat d'une partie dit d'où viennent les points : gammon, videau", () => {
const result = PROGRESSION_TEXTS.result;
const lost = (kind: "single" | "gammon" | "backgammon", points: number) =>
({ winner: "computer", kind, points }) as const;
assert.equal(result({ winner: "player", kind: "single", points: 1 }, 1), "Gagnée, 1 point");
assert.equal(result(lost("single", 1), 1), "Perdue, 1 point");
assert.equal(result(lost("gammon", 2), 1), "Perdue, gammon, 2 points");
assert.equal(result(lost("single", 2), 2), "Perdue, videau à 2, 2 points");
assert.equal(result(lost("gammon", 4), 2), "Perdue, gammon, videau à 2, 4 points");
assert.equal(result(lost("backgammon", 12), 4), "Perdue, backgammon, videau à 4, 12 points");
assert.equal(result(null, 1), "Partie terminée");
});
test("la ligne de l'accueil : « aucune gagnée », comme « aucune leçon terminée »", () => {
assert.equal(HOME_TEXTS.progress(3, 0), "3 parties jouées · aucune gagnée");
assert.equal(HOME_TEXTS.progress(3, 1), "3 parties jouées · 1 gagnée");
assert.equal(HOME_TEXTS.progress(0, 0), "aucune partie jouée");
});
test("le taux d'erreur s'écrit en millièmes, comme les mEMG de GNU Backgammon", () => {
assert.equal(errorRateMillis(0.0142), 14);
assert.equal(errorRateMillis(0.0075), 8);
assert.equal(errorRateMillis(0), 0);
});
test("la tendance : moyennes des deux moitiés, arrondies en millièmes", () => {
assert.deepEqual(progressionTrend([]), { kind: "waiting" });
assert.deepEqual(progressionTrend([point("a", 0.02), point("b", 0.01)]), { kind: "waiting" });
assert.equal(CURVE_MIN_GAMES, 3);
// Trois parties : la première contre la dernière, celle du milieu ne
// compte pas.
assert.deepEqual(progressionTrend([point("a", 0.014), point("b", 0.05), point("c", 0.007)]), {
kind: "down",
games: 3,
from: 14,
to: 7,
});
assert.deepEqual(
progressionTrend([
point("a", 0.01),
point("b", 0.012),
point("c", 0.02),
point("d", 0.016),
]),
{ kind: "up", games: 4, from: 11, to: 18 },
);
// 0,0101 et 0,0104 s'écrivent tous deux 10 : stable.
assert.equal(
progressionTrend([point("a", 0.0101), point("b", 0.03), point("c", 0.0104)]).kind,
"steady",
);
});
test("la phrase du maître : des faits, sans point d'exclamation, sous 140 caractères", () => {
for (const key of ["progression.baisse", "progression.hausse", "progression.stable"]) {
assert.ok(PHRASE_KEYS.includes(key), key);
}
assert.equal(
progressionSentence(phrases, { kind: "down", games: 8, from: 14, to: 7 }),
"Sur vos 8 dernières parties, vous faites moins d'erreurs : votre taux d'erreur est passé de 14 à 7.",
);
assert.equal(
progressionSentence(phrases, { kind: "waiting" }),
"Votre courbe apparaîtra après trois parties.",
);
for (const trend of [
{ kind: "down", games: 20, from: 140, to: 70 },
{ kind: "up", games: 20, from: 70, to: 140 },
{ kind: "steady", games: 20, from: 12, to: 12 },
] as const) {
const sentence = progressionSentence(phrases, trend);
assert.ok(sentence.length < 140, sentence);
assert.ok(!sentence.includes("!"), sentence);
assert.ok(sentence.includes(String(trend.to)), sentence);
}
assert.ok(
progressionSentence(phrases, { kind: "up", games: 5, from: 7, to: 9 }).includes(
"Les leçons sont là",
),
);
});
test("la progression lit le taux du bilan, sans rien recalculer ; une partie non analysée attend", async () => {
const engine = await loadNodeEngine();
const raw = [await race(engine, 3, 3), await race(engine, 7, 2), await race(engine, 11, 1)];
const analysed = await Promise.all(raw.map((j) => analyseAll(engine, j)));
// Données dans le désordre : la progression les range par date.
const games = [
finished("c", 3000, analysed[2] as GameJournal),
finished("a", 1000, analysed[0] as GameJournal),
finished("b", 2000, analysed[1] as GameJournal),
finished("d", 4000, raw[0] as GameJournal),
];
const entries = progressionEntries(games);
assert.deepEqual(
entries.map((e) => e.id),
["a", "b", "c", "d"],
);
for (const [i, entry] of entries.slice(0, 3).entries()) {
assert.deepEqual(
entry.errorRate,
summarizeAnalysis(analysed[i] as GameJournal).player.errorRate,
);
}
assert.equal(readyForSummary(raw[0] as GameJournal), false);
assert.equal(entries[3]?.errorRate, null, "partie non analysée : pas de taux");
const curve = progressionCurve(entries);
assert.deepEqual(
curve.map((p) => p.id),
["a", "b", "c"],
);
assert.equal(progressionTrend(curve).kind === "waiting", false);
});
test("« Créer des exercices » : erreurs, thèmes comptés sur les faits du moteur, niveau à 20 décisions cumulées", async () => {
const engine = await loadNodeEngine();
const template = parsePromptTemplate(
readFileSync(new URL(`docs/prompts/${PROMPT_FILES.exercises}`, root), "utf8"),
);
const templates = { move: "", cube: "", summary: "", concept: "", exercises: template };
const journals: GameJournal[] = [];
for (const [seed, choice] of [
[3, 3],
[7, 2],
[11, 1],
[5, 4],
[9, 3],
] as const) {
journals.push(await analyseAll(engine, await race(engine, seed, choice)));
}
const decisions = journals.map((j) => summarizeAnalysis(j).player.errorRate.decisions);
assert.ok((decisions[0] ?? 0) < LEVEL_MIN_DECISIONS, `décisions : ${decisions.join()}`);
let cumulated = 0;
for (let n = 1; n <= journals.length; n++) {
const some = journals.slice(0, n);
cumulated += decisions[n - 1] ?? 0;
const position = await exercisesPosition(engine, some);
if (position === null) {
continue;
}
const prompt = exercisesPrompt(templates, phrases, { journals: some, position });
assert.ok(!/[{}]/.test(prompt), prompt);
assert.ok(prompt.includes("Ce qui revient dans ces erreurs"), prompt);
assert.match(prompt, /- \d+ coups? et \d+ décisions? de videau/);
if (cumulated >= LEVEL_MIN_DECISIONS) {
assert.match(prompt, /Je suis un joueur de niveau /);
assert.ok(Object.values(LEVEL_NAMES).some((name) => prompt.includes(name)));
} else {
assert.ok(prompt.includes("Je débute au backgammon."), prompt);
}
}
assert.ok(cumulated >= LEVEL_MIN_DECISIONS, `décisions cumulées : ${cumulated}`);
});
test("les thèmes des erreurs : faits du moteur seulement, une ligne par fait présent", () => {
const facts = (hits: number, madePoints: number[]) => ({ hits, madePoints });
const move = (best: ReturnType<typeof facts>, played: ReturnType<typeof facts>) => ({
advice: { kind: "move", bestFacts: best, playedFacts: played } as never,
});
const cube = { advice: { kind: "cube" } as never };
assert.deepEqual(
mistakeThemes([
move(facts(1, []), facts(0, [])),
move(facts(0, [5]), facts(0, [])),
move(facts(0, []), facts(1, [])),
cube,
]),
[
"- 3 coups et 1 décision de videau",
"- 1 fois, le coup de GNU Backgammon frappait un pion et le mien non",
"- 1 fois, le coup de GNU Backgammon faisait un point que le mien ne faisait pas",
"- 1 fois, mon coup frappait un pion et celui de GNU Backgammon non",
],
);
assert.deepEqual(mistakeThemes([cube]), ["- 0 coup et 1 décision de videau"]);
});
+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);
}
},
);