É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>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
0493858c1a
commit
fdbbff50ea
@@ -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);
|
||||
}
|
||||
},
|
||||
);
|
||||
Reference in New Issue
Block a user