// 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 => { await new Promise((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")); });