319 lines
13 KiB
TypeScript
319 lines
13 KiB
TypeScript
// 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);
|
||
});
|