// 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 { 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 { 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, played: ReturnType) => ({ 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"]); });