Analyse de partie : tours, chance, taux d'erreur à la manière de gnubg, format durable

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Pierre MARTIN
2026-11-10 09:56:48 +01:00
co-authored by Claude Opus 5.5
parent 3e7b161933
commit a657d7d7a9
9 changed files with 1102 additions and 16 deletions
+318
View File
@@ -0,0 +1,318 @@
// 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<GameJournal> {
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<Advice | null>((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);
});
+1
View File
@@ -81,6 +81,7 @@ function fakeEngine(overrides: Partial<Engine> = {}): Engine {
skill: "none",
}),
luck: async () => ({ value: 0, rating: "none" }),
evaluate: async () => ({ win: 0.5, equity: 0 }),
encodeMatch: async () => "MATCH",
decodeMatch: async () => ({
matchLength: 0,