152 lines
5.6 KiB
TypeScript
152 lines
5.6 KiB
TypeScript
// 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"));
|
|
});
|