Bilan de fin de partie et écran d'analyse : courbe des chances, visualiseur, liste filtrable, rejouer d'ici
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
a657d7d7a9
commit
006fd62704
@@ -241,7 +241,10 @@ test("l'analyse de partie enregistrée : migration du format durable", () => {
|
||||
const clean = { ...journal, review: migrated.review };
|
||||
assert.equal(migrateJournal(clean), clean);
|
||||
// Une autre version, ou une analyse mal formée, est oubliée.
|
||||
const other = migrateJournal({ ...journal, review: { ...review, version: REVIEW_VERSION + 1 } });
|
||||
const other = migrateJournal({
|
||||
...journal,
|
||||
review: { ...review, version: REVIEW_VERSION + 1 },
|
||||
});
|
||||
assert.equal(other.review, undefined);
|
||||
const broken = { ...journal, review: { version: REVIEW_VERSION } } as unknown as GameJournal;
|
||||
assert.equal(migrateJournal(broken).review, undefined);
|
||||
@@ -300,10 +303,7 @@ test("une partie complète analysée par le moteur, comme une partie d'avant 1d"
|
||||
assert.equal(summary.toReview?.advice.eventId, worst?.eventId);
|
||||
// La chance de chaque tour d'un camp est comptée.
|
||||
const moves = game.events.filter((e) => e.type === "MovePlayed");
|
||||
assert.equal(
|
||||
summary.player.luck.rolls + summary.computer.luck.rolls,
|
||||
moves.length,
|
||||
);
|
||||
assert.equal(summary.player.luck.rolls + summary.computer.luck.rolls, moves.length);
|
||||
// Les chances de gagner sont des probabilités, et la courbe finit sur
|
||||
// le résultat.
|
||||
const curve = chanceCurve(analysed);
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
// L'analyse d'une partie en arrière-plan (app/src/ui/review-runner.ts) :
|
||||
// une analyse à la fois, rangée au fur et à mesure, arrêtée à la demande
|
||||
// ou par une autre partie ; et les phrases du bilan.
|
||||
//
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
import {
|
||||
type AnalysisTask,
|
||||
type GameSummary,
|
||||
missingAnalyses,
|
||||
} from "../src/domain/analysis/game-analysis.js";
|
||||
import { diceWords, nothingToReview, summaryReading } from "../src/domain/analysis/phrases.js";
|
||||
import {
|
||||
appendEvent,
|
||||
EMPTY_JOURNAL,
|
||||
type GameJournal,
|
||||
type NewGameEvent,
|
||||
} from "../src/domain/game/events.js";
|
||||
import { START_POSITION } from "../src/domain/game/position.js";
|
||||
import { emptyReview, type GameReview } from "../src/domain/game/review.js";
|
||||
import { createReviewRunner } from "../src/ui/review-runner.js";
|
||||
import { loadPhrases } from "./support/advice.js";
|
||||
|
||||
const P = START_POSITION;
|
||||
/** Un tour de l'ordinateur, sans décision du joueur. */
|
||||
const EVENTS: NewGameEvent[] = [
|
||||
{ type: "GameStarted", position: P, cube: { value: 1, owner: null }, firstTurn: "computer" },
|
||||
{ type: "DiceRolled", side: "computer", roll: [6, 5] },
|
||||
{ type: "MovePlayed", side: "computer", halfMoves: [], position: P },
|
||||
];
|
||||
const JOURNAL: GameJournal = EVENTS.reduce(appendEvent, EMPTY_JOURNAL);
|
||||
|
||||
/** Une fausse analyse : elle range la tâche sans moteur, quand on la
|
||||
* libère. */
|
||||
function fakeRun() {
|
||||
const waiting: (() => void)[] = [];
|
||||
const run = async (journal: GameJournal, task: AnalysisTask): Promise<GameJournal> => {
|
||||
await new Promise<void>((resolve) => waiting.push(resolve));
|
||||
const review: GameReview = journal.review ?? emptyReview(2);
|
||||
if (task.kind === "luck") {
|
||||
return {
|
||||
...journal,
|
||||
review: { ...review, luck: { [task.eventId]: { value: 0, rating: "none" } } },
|
||||
};
|
||||
}
|
||||
if (task.kind === "chance") {
|
||||
return {
|
||||
...journal,
|
||||
review: { ...review, winChances: { ...review.winChances, [task.eventId]: 0.5 } },
|
||||
};
|
||||
}
|
||||
return { ...journal, review: { ...review, unanalysed: [task.eventId] } };
|
||||
};
|
||||
const release = async () => {
|
||||
while (waiting.length === 0) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1));
|
||||
}
|
||||
waiting.shift()?.();
|
||||
await new Promise((resolve) => setTimeout(resolve, 1));
|
||||
};
|
||||
return { run, release };
|
||||
}
|
||||
|
||||
test("l'analyse en arrière-plan range chaque analyse, puis s'arrête une fois tout fait", async () => {
|
||||
const { run, release } = fakeRun();
|
||||
const runner = createReviewRunner(run);
|
||||
const recorded: GameJournal[] = [];
|
||||
let done = false;
|
||||
// La chance du jet, le jet sans doubler de l'ordinateur, ses chances
|
||||
// après le coup.
|
||||
const total = missingAnalyses(JOURNAL).length;
|
||||
assert.equal(total, 3);
|
||||
runner.start(
|
||||
"a",
|
||||
JOURNAL,
|
||||
(j) => recorded.push(j),
|
||||
() => {
|
||||
done = true;
|
||||
},
|
||||
);
|
||||
assert.equal(runner.running, "a");
|
||||
// La même partie redemandée ne repart pas.
|
||||
runner.start("a", JOURNAL, () => assert.fail("deuxième analyse"));
|
||||
for (let i = 0; i < total; i++) {
|
||||
await release();
|
||||
}
|
||||
assert.equal(recorded.length, total);
|
||||
assert.deepEqual(missingAnalyses(recorded.at(-1) as GameJournal), []);
|
||||
assert.ok(done);
|
||||
assert.equal(runner.running, null);
|
||||
});
|
||||
|
||||
test("l'analyse s'arrête à la demande, ou pour une autre partie, sans ranger la dernière", async () => {
|
||||
const { run, release } = fakeRun();
|
||||
const runner = createReviewRunner(run);
|
||||
const recorded: string[] = [];
|
||||
runner.start("a", JOURNAL, () => recorded.push("a"));
|
||||
runner.stop();
|
||||
assert.equal(runner.running, null);
|
||||
await release();
|
||||
assert.equal(recorded.length, 0);
|
||||
runner.start("b", JOURNAL, () => recorded.push("b"));
|
||||
runner.start("c", JOURNAL, () => recorded.push("c"));
|
||||
assert.equal(runner.running, "c");
|
||||
await release();
|
||||
await release();
|
||||
assert.deepEqual(recorded, ["c"]);
|
||||
});
|
||||
|
||||
/** Un bilan réduit à ce que lisent les phrases. */
|
||||
function summaryWith(result: number, luckAdjustedResult: number): GameSummary {
|
||||
const side = {
|
||||
errorRate: { total: 0, decisions: 0, perDecision: 0, level: "champion" as const },
|
||||
luck: { total: 0, rolls: 1, perRoll: -0.04, rating: "unlucky" as const, points: 0 },
|
||||
};
|
||||
return {
|
||||
player: side,
|
||||
computer: side,
|
||||
counts: { good: 0, doubtful: 0, bad: 0, veryBad: 0 },
|
||||
toReview: null,
|
||||
result,
|
||||
luckAdjustedResult,
|
||||
};
|
||||
}
|
||||
|
||||
test("les phrases du bilan : ce qui a fait la différence, et les dés en mots", () => {
|
||||
const phrases = loadPhrases();
|
||||
assert.equal(diceWords(phrases, "unlucky"), "un peu contre vous");
|
||||
assert.equal(
|
||||
summaryReading(phrases, summaryWith(1, 1.2)),
|
||||
"D'après mon analyse, votre jeu a fait la différence, avec des dés un peu contre vous.",
|
||||
);
|
||||
assert.equal(
|
||||
summaryReading(phrases, summaryWith(-2, -1.5)),
|
||||
"D'après mon analyse, le jeu de l'ordinateur a fait la différence, avec des dés un peu contre vous.",
|
||||
);
|
||||
// Gagnée grâce aux dés, ou perdue à cause d'eux : les dés.
|
||||
for (const [result, adjusted] of [
|
||||
[1, -0.3],
|
||||
[-1, 0.2],
|
||||
] as const) {
|
||||
assert.equal(
|
||||
summaryReading(phrases, summaryWith(result, adjusted)),
|
||||
"D'après mon analyse, les dés ont fait la différence, plus que le jeu.",
|
||||
);
|
||||
}
|
||||
assert.ok(nothingToReview(phrases).startsWith("Aucun coup à revoir"));
|
||||
});
|
||||
@@ -35,6 +35,9 @@ const TABLE: [string, Route][] = [
|
||||
["#/installer", { screen: "install" }],
|
||||
["#/parties", { screen: "games", game: null }],
|
||||
["#/parties/mh2k3x-a1b2c3", { screen: "games", game: "mh2k3x-a1b2c3" }],
|
||||
["#/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 }],
|
||||
["#/menu", { screen: "menu" }],
|
||||
["#/nouvelle-partie", { screen: "newGame" }],
|
||||
["#/explication/12", { screen: "explanation", event: "12" }],
|
||||
@@ -75,6 +78,12 @@ test("une adresse inconnue est reconnue comme telle", () => {
|
||||
"#/a-propos/x",
|
||||
"#/position",
|
||||
"#/position/%E0%A4%A",
|
||||
"#/parties/ab/autre",
|
||||
"#/parties/ab/bilan/2",
|
||||
"#/parties/ab/analyse/0",
|
||||
"#/parties/ab/analyse/x",
|
||||
"#/parties/ab/analyse/3/plus",
|
||||
"#/parties/AB/bilan",
|
||||
]) {
|
||||
assert.equal(parseRoute(hash).screen, "unknown", hash);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user