// L'analyse de partie (étape 1e) : les tours, le format durable de // l'analyse et sa migration, les chiffres du bilan à la manière de gnubg, // et une partie complète analysée par le vrai moteur. // // 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 { chanceCurve, gameTurns, levelOfErrorRate, luckRatingOf, mainAdvice, missingAnalyses, readyForSummary, replayPoint, runAnalysis, summarizeAnalysis, } from "../src/domain/analysis/game-analysis.js"; import { ADVICE_VERSION, type Advice, type CubeAdvice } from "../src/domain/game/advice.js"; import type { Engine } from "../src/domain/game/engine.js"; import { appendEvent, EMPTY_JOURNAL, type GameJournal, migrateJournal, type NewGameEvent, withAnalysesOf, } from "../src/domain/game/events.js"; import { Game } from "../src/domain/game/game.js"; import { START_POSITION } from "../src/domain/game/position.js"; import { emptyReview, REVIEW_VERSION } from "../src/domain/game/review.js"; import { loadNodeEngine } from "./support/node-engine.js"; import { playToTheEnd, seededDice } from "./support/play.js"; /** Un journal fait d'événements donnés, à la suite. */ function journalOf(events: readonly NewGameEvent[]): GameJournal { return events.reduce(appendEvent, EMPTY_JOURNAL); } const START: NewGameEvent = { type: "GameStarted", position: START_POSITION, cube: { value: 1, owner: null }, firstTurn: null, }; const P = START_POSITION; /** Une petite partie : ouverture (égalité puis 3-1 pour le joueur), un * coup de chaque camp, puis le joueur double et l'ordinateur passe. */ const SHORT: NewGameEvent[] = [ START, { type: "DiceRolled", side: null, roll: [2, 2] }, { type: "DiceRolled", side: null, roll: [3, 1] }, { type: "MovePlayed", side: "player", halfMoves: [{ from: 8, to: 5 }], position: P }, { type: "DiceRolled", side: "computer", roll: [6, 5] }, { type: "MovePlayed", side: "computer", halfMoves: [{ from: 24, to: 13 }], position: P }, { type: "CubeOffered", side: "player" }, { type: "CubeDropped", side: "computer" }, { type: "GameEnded", winner: "player", kind: "single", points: 1 }, ]; test("les tours d'une partie : ouverture, coups, double passé", () => { const journal = journalOf(SHORT); const turns = gameTurns(journal); assert.deepEqual( turns.map((t) => [t.number, t.side, t.roll, t.move?.id ?? null, t.end?.id ?? null]), [ [1, "player", [3, 1], "4", null], [2, "computer", [6, 5], "6", null], [3, "player", null, null, "9"], ], ); // L'égalité d'ouverture appartient au premier tour. assert.deepEqual( turns[0]?.events.map((e) => e.id), ["2", "3", "4"], ); // Le jet d'ouverture qui désigne le joueur a une chance, l'égalité non. const luck = missingAnalyses(journal).filter((t) => t.kind === "luck"); assert.deepEqual( luck.map((t) => t.eventId), ["3", "5"], ); }); /** Un conseil fictif sur un coup du joueur. */ function moveAdvice(eventId: string, equityLoss: number, moveCount = 10): Advice { const move = { notation: "8/5", halfMoves: [], equity: 0, ply: 2 }; return { version: ADVICE_VERSION, kind: "move", eventId, ply: 2, position: P, roll: [3, 1], opening: true, moveCount, rank: equityLoss === 0 ? 1 : 2, played: move, best: move, playedFacts: { hits: 0, madePoints: [] }, bestFacts: { hits: 0, madePoints: [] }, ranking: [], equityLoss, skill: equityLoss < -0.12 ? "veryBad" : equityLoss < -0.06 ? "bad" : equityLoss < -0.03 ? "doubtful" : "none", }; } function cubeAdvice(eventId: string, action: CubeAdvice["action"], equityLoss: number): Advice { return { version: ADVICE_VERSION, kind: "cube", eventId, ply: 2, action, position: P, equities: { noDouble: 0.5, doubleTake: 0.6, doublePass: 1, optimal: 0.6 }, equityLoss, skill: equityLoss < -0.06 ? "bad" : "none", }; } test("le taux d'erreur, le niveau et la chance à la manière de gnubg", () => { // arThrsRating de gnubg : 0,032, 0,020, 0,013, 0,008, 0,005 ; les six // classements de gnubg prennent, dans l'ordre, les noms des six niveaux // de l'ordinateur (Advanced de gnubg : Intermédiaire). assert.equal(levelOfErrorRate(0.05), "beginner"); assert.equal(levelOfErrorRate(0.032), "beginner"); assert.equal(levelOfErrorRate(0.031), "amateur"); assert.equal(levelOfErrorRate(0.015), "intermediate"); assert.equal(levelOfErrorRate(0.01), "advanced"); assert.equal(levelOfErrorRate(0.006), "expert"); assert.equal(levelOfErrorRate(0.001), "champion"); // getLuckRating : chance par tour × 10, seuils 0,3 et 0,6. assert.equal(luckRatingOf(0.07), "veryLucky"); assert.equal(luckRatingOf(0.04), "lucky"); assert.equal(luckRatingOf(0.02), "none"); assert.equal(luckRatingOf(-0.04), "unlucky"); assert.equal(luckRatingOf(-0.07), "veryUnlucky"); const base = journalOf(SHORT); const journal: GameJournal = { ...base, advice: { "4": moveAdvice("4", -0.08), "7": cubeAdvice("7", "double", 0), }, review: { ...emptyReview(2), luck: { "3": { value: 0.2, rating: "none" }, "5": { value: -0.1, rating: "none" }, }, computer: { "6": moveAdvice("6", 0) }, }, }; assert.ok(readyForSummary(journal)); const summary = summarizeAnalysis(journal); // Un coup non forcé (0,08) et un double (compté toujours) : 0,04 par // décision. assert.equal(summary.player.errorRate.decisions, 2); assert.ok(Math.abs(summary.player.errorRate.perDecision - 0.04) < 1e-9); assert.equal(summary.player.errorRate.level, "beginner"); assert.deepEqual(summary.counts, { good: 1, doubtful: 0, bad: 1, veryBad: 0 }); assert.equal(summary.toReview?.advice.eventId, "4"); assert.equal(summary.toReview?.turn, 1); // Une seule fois la main pour chacun : chance par tour égale au total. assert.equal(summary.player.luck.rolls, 1); assert.equal(summary.player.luck.rating, "veryLucky"); assert.equal(summary.computer.luck.rating, "veryUnlucky"); // Gagné 1 point, avec 0,2 de chance contre −0,1 pour l'ordinateur : // 1 − 0,2 − 0,1. assert.equal(summary.result, 1); assert.ok(Math.abs(summary.luckAdjustedResult - 0.7) < 1e-9); // Rejouer le premier tour : juste avant le coup, avec le même jet. const first = gameTurns(journal)[0]; assert.ok(first !== undefined); assert.equal(mainAdvice(journal, first)?.eventId, "4"); assert.equal(replayPoint(journal, first), "3"); // La courbe : le dernier tour finit la partie, gagnée. assert.deepEqual( chanceCurve(journal).map((p) => [p.turn, p.chance, p.verdict]), [ [1, null, "bad"], [2, null, null], [3, 1, "good"], ], ); }); test("un jet sans doubler ne compte que si la décision est serrée (isCloseCubedecision)", () => { const base = journalOf([ { ...START, firstTurn: "player" }, { type: "DiceRolled", side: "player", roll: [3, 1] }, ]); const noDouble = (equities: CubeAdvice["equities"]): GameJournal => ({ ...base, advice: { "2": { ...(cubeAdvice("2", "noDouble", 0) as CubeAdvice), equities } }, }); // Doubler coûterait 0,2 par rapport à l'optimum : pas serré. const far = noDouble({ noDouble: 0.5, doubleTake: 0.3, doublePass: 1, optimal: 0.5 }); assert.equal(summarizeAnalysis(far).player.errorRate.decisions, 0); // Doubler est l'optimum : serré, compté. const close = noDouble({ noDouble: 0.5, doubleTake: 0.6, doublePass: 1, optimal: 0.6 }); assert.equal(summarizeAnalysis(close).player.errorRate.decisions, 1); // Un bon jet sans doubler n'est pas une décision visible du bilan. assert.deepEqual(summarizeAnalysis(close).counts, { good: 0, doubtful: 0, bad: 0, veryBad: 0, }); }); test("l'analyse de partie enregistrée : migration du format durable", () => { const journal = journalOf(SHORT); const review = { ...emptyReview(2), luck: { "3": { value: 0.1, rating: "none" as const } }, winChances: { "4": 0.55, "99": 0.5 }, unanalysed: ["5", "98"], }; // Un journal d'avant l'étape 1e se reprend tel quel. assert.equal(migrateJournal(journal), journal); // Les entrées rattachées à un événement absent sont oubliées. const migrated = migrateJournal({ ...journal, review }); assert.deepEqual(migrated.review?.winChances, { "4": 0.55 }); assert.deepEqual(migrated.review?.unanalysed, ["5"]); assert.deepEqual(migrated.review?.luck, review.luck); // Une analyse complète et connue ne change pas. 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 } }); assert.equal(other.review, undefined); const broken = { ...journal, review: { version: REVIEW_VERSION } } as unknown as GameJournal; assert.equal(migrateJournal(broken).review, undefined); // Les analyses d'une copie s'ajoutent au journal sans toucher aux // conseils déjà là. const live: GameJournal = { ...journal, advice: { "4": moveAdvice("4", 0) } }; const copy: GameJournal = { ...journal, advice: { "4": moveAdvice("4", -0.5), "7": cubeAdvice("7", "double", 0) }, review, }; const merged = withAnalysesOf(live, copy); assert.equal(merged.advice?.["4"]?.equityLoss, 0); assert.ok(merged.advice?.["7"] !== undefined); assert.equal(merged.review, review); }); /** Toutes les analyses qui manquent, une à une. */ async function analyseAll(engine: Engine, journal: GameJournal): Promise { let current = journal; for (let i = 0; i < 500; i++) { const next = missingAnalyses(current)[0]; if (next === undefined) { return current; } current = await runAnalysis(engine, current, next); } throw new Error("l'analyse ne finit pas"); } test("une partie complète analysée par le moteur, comme une partie d'avant 1d", async () => { const engine = await loadNodeEngine(); // Une course courte (tests/reference/positions.json, race-1). const game = Game.start(engine, { setup: { position: "trsXAADs7mwAAA", firstTurn: "player" }, level: "2ply", dice: seededDice(7), }); await playToTheEnd(game, engine, 1); // Aucun conseil n'a été rangé pendant la partie (comme avant l'étape // 1d) : tout est à analyser. assert.equal(game.journal.advice, undefined); assert.ok(!readyForSummary(game.journal)); const analysed = await analyseAll(engine, game.journal); assert.ok(readyForSummary(analysed)); assert.deepEqual(missingAnalyses(analysed), []); const summary = summarizeAnalysis(analysed); const advices = Object.values(analysed.advice ?? {}); assert.ok(advices.length > 0); // Le coup à revoir est la plus grosse perte d'équité du joueur, s'il en // a une. const worst = advices .filter((a) => a.skill !== "none") .reduce((w, a) => (w === null || a.equityLoss < w.equityLoss ? a : w), null); 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, ); // Les chances de gagner sont des probabilités, et la courbe finit sur // le résultat. const curve = chanceCurve(analysed); assert.ok(curve.every((p) => p.chance !== null && p.chance >= 0 && p.chance <= 1)); const won = game.state.result?.winner === "player"; assert.equal(curve.at(-1)?.chance, won ? 1 : 0); // Les coups de l'ordinateur sont analysés aussi. assert.ok(Object.keys(analysed.review?.computer ?? {}).length > 0); // Le journal analysé se reprend : l'analyse fait partie du format. const resumed = Game.resume(engine, JSON.parse(JSON.stringify(analysed))); assert.deepEqual(resumed.journal.review, analysed.review); });