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:
co-authored by
Claude Opus 5.5
parent
3e7b161933
commit
a657d7d7a9
@@ -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);
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user