Bilan et analyse : scénario, fiches du glossaire, test dans le navigateur

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Pierre MARTIN
2026-11-10 10:30:17 +01:00
co-authored by Claude Opus 5.5
parent 006fd62704
commit 2c5ae08f46
8 changed files with 748 additions and 5 deletions
+226
View File
@@ -0,0 +1,226 @@
// Étapes du scénario docs/scenarios/revoir-ma-partie.feature (ADR 0008) :
// la partie se joue par le domaine sur le vrai moteur, avec des dés
// imposés ; son analyse passe par l'analyse en arrière-plan de l'interface
// (review-runner.ts, sans DOM), le bilan par le domaine
// (game-analysis.ts) et les phrases du maître, et l'ancienne partie par le
// stockage des parties sur un localStorage simulé.
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
import assert from "node:assert/strict";
import { BeforeAll, Given, setDefaultTimeout, Then, When } from "@cucumber/cucumber";
import {
chanceCurve,
gameTurns,
missingAnalyses,
readyForSummary,
replayPoint,
runAnalysis,
summarizeAnalysis,
} from "../../src/domain/analysis/game-analysis.js";
import { diceWords, summaryReading } from "../../src/domain/analysis/phrases.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 { parseHalfMoves } from "../../src/domain/game/position.js";
import { fixedDice } from "../../src/domain/game/roll.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";
setDefaultTimeout(60_000);
/** Le joueur au trait : un pion sur son point 6, un sur son point 1 ;
* l'ordinateur : un sur son point 1, un sur son point 2 (Position ID de
* gnubg, chacun vu de lui-même). */
const BEAROFF = "BQAACAIAAAAAAA";
const phrases = loadPhrases();
let engine: Engine;
let game: Game;
let dice: number[];
/** Le journal analysé de la partie. */
let analysed: GameJournal;
/** La partie d'origine, avant « Rejouer d'ici ». */
let original: { head: string; events: number };
/** Les analyses rangées par l'analyse en arrière-plan. */
let recorded: number;
BeforeAll(async () => {
engine = await loadNodeEngine();
});
/** Analyse ce qui manque au journal, comme l'application en
* arrière-plan. */
async function analyse(journal: GameJournal): Promise<GameJournal> {
const runner = createReviewRunner((j, task) => runAnalysis(engine, j, task));
recorded = 0;
let latest = journal;
await new Promise<void>((resolve) => {
runner.start(
"partie",
journal,
(j) => {
latest = j;
recorded++;
},
resolve,
);
});
return latest;
}
async function playMove(move: string): Promise<void> {
for (const halfMove of parseHalfMoves(move)) {
assert.ok(await game.playHalfMove(halfMove), `demi-coup refusé : ${move}`);
}
await game.confirmMove();
}
Given(
"il vous reste deux pions, sur vos points 6 et 1, et à l'ordinateur deux pions sur ses points 1 et 2",
() => {
dice = [];
// Les dés sont tirés au moment voulu : ceux qu'impose le scénario.
game = Game.start(engine, {
setup: { position: BEAROFF, firstTurn: "player" },
level: "2ply",
dice: () => {
const die = dice.shift();
if (die === undefined) {
throw new Error("plus de dés imposés");
}
return die;
},
});
},
);
Given("vous avez {int}-{int} à jouer", async (a: number, b: number) => {
dice.push(a, b);
await game.roll();
assert.deepEqual(game.state.roll, [a, b]);
});
When(/^vous choisissez (?:cette fois )?le coup (.+)$/, async (move: string) => {
await playMove(move);
});
When("l'ordinateur sort ses deux pions avec {int}-{int}", async (a: number, b: number) => {
dice.push(a, b);
await game.playComputerTurn();
assert.equal(game.state.phase, "over", "l'ordinateur a doublé au lieu de jouer");
analysed = await analyse(game.journal);
});
Then("la partie est finie, gagnée par l'ordinateur", () => {
assert.equal(game.state.result?.winner, "computer");
});
Then("le bilan compte une grosse erreur", () => {
assert.ok(readyForSummary(analysed));
assert.equal(summarizeAnalysis(analysed).counts.veryBad, 1);
});
Then("sa phrase dit ce qui a fait la différence, et comment étaient vos dés", () => {
const summary = summarizeAnalysis(analysed);
const reading = summaryReading(phrases, summary);
assert.ok(reading.startsWith("D'après mon analyse, "), reading);
assert.ok(!reading.includes("!"), reading);
// Perdue : le jeu de l'ordinateur a fait la différence, si le résultat
// corrigé de la chance reste négatif ; sinon les dés.
if (summary.luckAdjustedResult < 0) {
assert.ok(reading.includes(diceWords(phrases, summary.player.luck.rating)), reading);
} else {
assert.ok(reading.includes("les dés ont fait la différence"), reading);
}
});
Then("le coup à revoir est votre coup {}, le premier de la partie", (move: string) => {
const toReview = summarizeAnalysis(analysed).toReview;
assert.equal(toReview?.turn, 1);
assert.equal(toReview?.advice.kind, "move");
assert.equal(toReview?.advice.kind === "move" && toReview.advice.played.notation, move);
});
Then("le maître y aurait joué {}", (move: string) => {
const advice = summarizeAnalysis(analysed).toReview?.advice;
assert.equal(advice?.kind === "move" && advice.best.notation, move);
assert.equal(advice?.skill, "veryBad");
});
Then("la courbe de vos chances de gagner finit à {int} %", (percent: number) => {
const curve = chanceCurve(analysed);
assert.equal(curve.length, gameTurns(analysed).length);
assert.equal(curve.at(-1)?.chance, percent / 100);
assert.ok(curve.every((p) => p.chance !== null && p.chance >= 0 && p.chance <= 1));
});
When("vous rejouez d'ici le coup à revoir", () => {
game.recordAnalyses(analysed);
original = { head: game.journal.head ?? "", events: game.events.length };
const toReview = summarizeAnalysis(game.journal).toReview;
const turn = gameTurns(game.journal)[(toReview?.turn ?? 0) - 1];
assert.ok(turn !== undefined);
const point = replayPoint(game.journal, turn);
assert.ok(point !== null);
game.goBackTo(point);
});
Then("vous avez de nouveau {int}-{int} à jouer", (a: number, b: number) => {
assert.equal(game.state.phase, "moving");
assert.equal(game.state.turn, "player");
assert.deepEqual(game.state.roll, [a, b]);
});
Then("la partie est finie, gagnée par vous", () => {
assert.equal(game.state.result?.winner, "player");
});
Then("la partie a deux branches, dont la partie d'origine, intacte", () => {
const branches = game.branches;
assert.equal(branches.length, 2);
const origin = branches.find((b) => b.forkedFrom === null);
assert.equal(origin?.head, original.head);
assert.ok(origin?.over);
// L'analyse de la partie d'origine reste attachée à ses événements.
assert.ok(game.journal.review !== undefined);
});
Given("une partie finie enregistrée sans aucune analyse", async () => {
dice = [6, 1, 2, 1];
game = Game.start(engine, {
setup: { position: BEAROFF, firstTurn: "player" },
level: "2ply",
dice: fixedDice(dice),
});
await game.roll();
await playMove("6/5 5/off");
await game.playComputerTurn();
assert.equal(game.state.phase, "over");
const { library } = await openGameLibrary({ localStorage: memoryStorage() });
await library.save("ancienne", game.journal, { level: "2ply" });
const saved = await library.load("ancienne");
assert.ok(saved !== null);
analysed = saved.journal;
assert.equal(analysed.advice, undefined);
assert.equal(analysed.review, undefined);
});
When("vous ouvrez son bilan", () => {
assert.ok(!readyForSummary(analysed));
});
Then("le maître l'analyse d'abord en arrière-plan", async () => {
const before = missingAnalyses(analysed).length;
analysed = await analyse(analysed);
assert.equal(recorded, before);
assert.ok(readyForSummary(analysed));
});
Then("le bilan nomme ensuite le coup à revoir", () => {
const advice = summarizeAnalysis(analysed).toReview?.advice;
assert.equal(advice?.kind === "move" && advice.played.notation, "6/off");
});