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
+38 -14
View File
@@ -14,6 +14,7 @@ import { ADVICE_VERSION, type Advice, type AdvisedMove, type MoveFacts } from ".
import type { BoardSide, CubeAction, Engine, EvaluatedMove, Ply } from "../game/engine.js";
import { currentLine, type GameEvent, type GameJournal } from "../game/events.js";
import { engineCube, STANDARD_PLY } from "../game/game.js";
import { otherSide, type Side } from "../game/side.js";
import { replay } from "../game/state.js";
/** Niveau de l'analyse : 2 plis, comme l'analyse de partie de gnubg par
@@ -30,35 +31,55 @@ export const RANKING_SIZE = 5;
* avant), ses doubles et ses réponses aux doubles de l'ordinateur.
*/
export function playerDecisions(events: readonly GameEvent[]): GameEvent[] {
return decisionsOf(events, "player");
}
/** Les décisions de side parmi des événements, comme playerDecisions. */
export function decisionsOf(events: readonly GameEvent[], side: Side): GameEvent[] {
return events.filter((event) => {
switch (event.type) {
case "MovePlayed":
return event.side === "player" && event.halfMoves.length > 0;
return event.side === side && event.halfMoves.length > 0;
case "DiceRolled":
case "CubeOffered":
case "CubeTaken":
case "CubeDropped":
return event.side === "player";
return event.side === side;
default:
return false;
}
});
}
/** Ce qu'on demande à l'analyse d'une décision. */
export interface DecisionOptions {
/** Niveau de l'analyse ; ANALYSIS_PLY par défaut. */
readonly ply?: Ply;
/** Le camp qui décide ; le joueur par défaut. Le conseil est alors vu
* de ce camp (sa position, ses coups). */
readonly side?: Side;
/** Garder le classement des coups (une seconde analyse) ; oui par
* défaut. L'analyse des coups de l'ordinateur s'en passe. */
readonly ranking?: boolean;
}
/**
* Le conseil du moteur sur la décision du joueur eventId, au niveau ply ;
* null s'il n'y a rien à analyser (pas une décision du joueur, aucun coup
* possible, videau inaccessible ou position de départ pour gnubg).
* Le conseil du moteur sur la décision eventId du joueur (ou du camp
* options.side) ; null s'il n'y a rien à analyser (pas une décision de ce
* camp, aucun coup possible, videau inaccessible ou position de départ pour
* gnubg).
*/
export async function analyseDecision(
engine: Engine,
journal: GameJournal,
eventId: string,
ply: Ply = ANALYSIS_PLY,
options: DecisionOptions = {},
): Promise<Advice | null> {
const ply = options.ply ?? ANALYSIS_PLY;
const side = options.side ?? "player";
const line = currentLine({ ...journal, head: eventId });
const event = line.at(-1);
if (event === undefined || playerDecisions([event]).length === 0) {
if (event === undefined || decisionsOf([event], side).length === 0) {
return null;
}
const earlier = line.slice(0, -1);
@@ -67,10 +88,10 @@ export async function analyseDecision(
if (before.roll === null) {
return null;
}
const cube = engineCube(before, "player");
// Le coup joué est enregistré vu de l'ordinateur : le moteur donne la
// même position vue du joueur.
const after = await engine.board(event.position, engineCube(before, "computer"));
const cube = engineCube(before, side);
// Le coup joué est enregistré vu de l'adversaire : le moteur donne
// la même position vue de celui qui l'a joué.
const after = await engine.board(event.position, engineCube(before, otherSide(side)));
const analysis = await engine.analyseMove(
before.position,
cube,
@@ -81,7 +102,10 @@ export async function analyseDecision(
if (analysis === null) {
return null;
}
const ranking = await engine.rankMoves(before.position, cube, before.roll, ply);
const ranking =
options.ranking === false
? []
: await engine.rankMoves(before.position, cube, before.roll, ply);
const start = await engine.board(before.position, cube);
const facts = async (move: EvaluatedMove) =>
factsOf(start.onRoll, start.opponent, await engine.board(move.position, cube));
@@ -105,7 +129,7 @@ export async function analyseDecision(
};
}
let action: CubeAction;
let decider: "player" | "computer" = "player";
let decider: Side = side;
if (event.type === "DiceRolled") {
if (before.phase !== "rolling") {
return null;
@@ -115,7 +139,7 @@ export async function analyseDecision(
action = "double";
} else {
action = event.type === "CubeTaken" ? "take" : "pass";
decider = "computer";
decider = otherSide(side);
}
const analysis = await engine.analyseCube(
before.position,
+583
View File
@@ -0,0 +1,583 @@
// L'analyse d'une partie (étape 1e) : ce qui manque à analyser, le calcul
// de chaque analyse par le moteur, et les chiffres du bilan à la manière de
// GNU Backgammon (taux d'erreur, chance, résultat corrigé de la chance).
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
//
// Tout ce qui juge le jeu vient du moteur : l'analyse de chaque décision
// (équité perdue, appréciation), la chance de chaque jet, l'évaluation des
// positions. Ce module ne fait que les ranger et les additionner, comme le
// fait gnubg dans ses statistiques de partie. Sources, dans gnubg 1.08.003
// (commit 14b67984, ADR 0005), fichiers analysis.c et formatgs.c :
// - updateStatcontext (analysis.c, l. 304-596) : les erreurs de chaque
// décision et leur nombre ;
// - getMWCFromError (analysis.c, l. 1432-1472) : le taux d'erreur ;
// - GetRating et arThrsRating (analysis.c, l. 83-101) : le classement ;
// - formatgs.c, l. 348-374 et getLuckRating (analysis.c, l. 1757) : la
// chance par coup et son appréciation ;
// - arLuckAdj (analysis.c, l. 304-330 et 405-414) : le résultat corrigé de
// la chance.
// Les tâches d'analyse sont indépendantes : l'application les lance une à
// une sur le second moteur, en arrière-plan, et peut s'arrêter entre deux.
import {
type Advice,
type CubeAdvice,
type MoveAdvice,
type Verdict,
verdictOf,
} from "../game/advice.js";
import type { Engine, LuckRating } from "../game/engine.js";
import { LUCK_THRESHOLDS } from "../game/engine.js";
import {
currentLine,
type GameEnded,
type GameEvent,
type GameJournal,
type MovePlayed,
withAdvice,
withReview,
} from "../game/events.js";
import { engineCube } from "../game/game.js";
import { COMPUTER_LEVELS, type ComputerLevel } from "../game/level.js";
import { emptyReview, type GameReview } from "../game/review.js";
import type { Roll } from "../game/roll.js";
import type { Side } from "../game/side.js";
import { applyEvent, replay } from "../game/state.js";
import { ANALYSIS_PLY, analyseDecision, decisionsOf } from "./analyse.js";
// ------------------------------------------------------------ Les tours
/**
* Un tour de la partie : ce qu'un camp a fait quand il avait la main
* (doubler ou non, lancer, jouer), avec la réponse de l'adversaire à son
* double. Les tours sont numérotés à partir de 1 ; c'est le « coup » de
* l'écran d'analyse (#/parties/<id>/analyse/<n>).
*/
export interface Turn {
readonly number: number;
/** Le camp qui avait la main. */
readonly side: Side;
/** Le jet joué (null : le tour s'est fini sur un double passé). */
readonly roll: Roll | null;
/** Le coup joué (null : double passé). */
readonly move: MovePlayed | null;
/** Les événements du tour, dans l'ordre (sans la fin de partie). */
readonly events: readonly GameEvent[];
/** La fin de partie, si elle suit ce tour. */
readonly end: GameEnded | null;
}
/** Les tours d'une ligne de jeu (événements du début à la fin). */
export function turnsOf(line: readonly GameEvent[]): Turn[] {
const turns: Turn[] = [];
let bucket: GameEvent[] = [];
let state = replay([]);
for (const event of line) {
const before = state;
state = applyEvent(state, event);
if (event.type === "GameStarted") {
continue;
}
if (event.type === "GameEnded") {
const last = turns.at(-1);
if (bucket.length === 0 && last !== undefined) {
turns[turns.length - 1] = { ...last, end: event };
} else if (bucket.length > 0) {
const offer = bucket.find((e) => e.type === "CubeOffered");
const side = offer?.type === "CubeOffered" ? offer.side : (before.turn ?? "player");
turns.push(makeTurn(turns.length + 1, side, bucket, null, event));
}
bucket = [];
continue;
}
bucket.push(event);
if (event.type === "MovePlayed") {
turns.push(makeTurn(turns.length + 1, event.side, bucket, event, null));
bucket = [];
}
}
return turns;
}
function makeTurn(
number: number,
side: Side,
events: readonly GameEvent[],
move: MovePlayed | null,
end: GameEnded | null,
): Turn {
const rolled = move === null ? undefined : events.findLast((e) => e.type === "DiceRolled");
return {
number,
side,
roll: rolled?.type === "DiceRolled" ? rolled.roll : null,
move,
events,
end,
};
}
/** Les tours de la ligne courante d'un journal. */
export function gameTurns(journal: GameJournal): Turn[] {
return turnsOf(currentLine(journal));
}
// --------------------------------------------- Ce qui reste à analyser
/** Une analyse à demander au moteur. */
export type AnalysisTask =
/** Une décision (coup ou videau) d'un camp. */
| { readonly kind: "decision"; readonly side: Side; readonly eventId: string }
/** La chance d'un jet. */
| { readonly kind: "luck"; readonly eventId: string }
/** Les chances de gagner du joueur après un coup. */
| { readonly kind: "chance"; readonly eventId: string };
/** L'analyse de partie du journal, au niveau d'analyse ; vide s'il n'y en
* a pas encore (ou à un autre niveau). */
export function reviewOf(journal: GameJournal): GameReview {
const review = journal.review;
return review !== undefined && review.ply === ANALYSIS_PLY
? review
: emptyReview(ANALYSIS_PLY);
}
/**
* Ce qui reste à analyser sur la ligne courante, dans l'ordre où le bilan
* en a besoin : les décisions du joueur (celles d'avant l'étape 1d, ou
* dont l'analyse est arrivée trop tard), la chance de chaque jet, puis,
* pour l'écran d'analyse, les décisions de l'ordinateur et les chances de
* gagner après chaque coup.
*/
export function missingAnalyses(journal: GameJournal): AnalysisTask[] {
const line = currentLine(journal);
const review = reviewOf(journal);
const skipped = new Set(review.unanalysed);
const advice = journal.advice ?? {};
const tasks: AnalysisTask[] = [];
for (const event of decisionsOf(line, "player")) {
if (advice[event.id] === undefined && !skipped.has(event.id)) {
tasks.push({ kind: "decision", side: "player", eventId: event.id });
}
}
for (const event of luckyRolls(line)) {
if (review.luck[event.id] === undefined) {
tasks.push({ kind: "luck", eventId: event.id });
}
}
for (const event of decisionsOf(line, "computer")) {
if (review.computer[event.id] === undefined && !skipped.has(event.id)) {
tasks.push({ kind: "decision", side: "computer", eventId: event.id });
}
}
for (const event of line) {
if (event.type === "MovePlayed" && review.winChances[event.id] === undefined) {
tasks.push({ kind: "chance", eventId: event.id });
}
}
return tasks;
}
/** Ce qu'il faut au bilan : toutes les décisions du joueur et la chance
* de tous les jets. */
export function readyForSummary(journal: GameJournal): boolean {
return missingAnalyses(journal).every(
(task) => task.kind === "chance" || (task.kind === "decision" && task.side === "computer"),
);
}
/** Les jets qui ont une chance : tous ceux d'un camp, et le jet
* d'ouverture qui désigne le premier joueur (pas une égalité, qui se
* relance). */
function luckyRolls(line: readonly GameEvent[]): GameEvent[] {
const rolls: GameEvent[] = [];
let state = replay([]);
for (const event of line) {
const before = state;
state = applyEvent(state, event);
if (event.type !== "DiceRolled") {
continue;
}
if (event.side !== null || (before.phase === "opening" && state.phase === "moving")) {
rolls.push(event);
}
}
return rolls;
}
/**
* Fait une analyse avec le moteur et la range dans le journal, qui est
* renvoyé (le journal reçu ne change pas). Une décision que le moteur
* n'analyse pas est notée comme telle, pour ne plus la demander.
*/
export async function runAnalysis(
engine: Engine,
journal: GameJournal,
task: AnalysisTask,
): Promise<GameJournal> {
const review = reviewOf(journal);
switch (task.kind) {
case "decision": {
const advice = await analyseDecision(engine, journal, task.eventId, {
side: task.side,
ranking: task.side === "player",
});
if (advice === null) {
return withReview(journal, {
...review,
unanalysed: [...review.unanalysed, task.eventId],
});
}
return task.side === "player"
? withAdvice(journal, advice)
: withReview(journal, {
...review,
computer: { ...review.computer, [task.eventId]: advice },
});
}
case "luck": {
const line = currentLine({ ...journal, head: task.eventId });
const event = line.at(-1);
if (event?.type !== "DiceRolled") {
throw new Error(`pas un jet : ${task.eventId}`);
}
const before = replay(line.slice(0, -1));
// Le camp qui joue le jet ; au jet d'ouverture, celui qu'il
// désigne. La position de départ est la même pour les deux.
const roller = event.side ?? replay(line).turn ?? "player";
const luck = await engine.luck(
before.position,
engineCube(before, roller),
event.roll,
);
return withReview(journal, { ...review, luck: { ...review.luck, [event.id]: luck } });
}
case "chance": {
const chance = await chanceAfter(engine, journal, task.eventId);
return withReview(journal, {
...review,
winChances: { ...review.winChances, [task.eventId]: chance },
});
}
}
}
/** Les chances de gagner du joueur après le coup moveId : le résultat si
* la partie finit là, sinon l'évaluation du moteur, vue de celui qui a
* alors la main. */
async function chanceAfter(engine: Engine, journal: GameJournal, moveId: string): Promise<number> {
const line = currentLine(journal);
const index = line.findIndex((e) => e.id === moveId);
const upTo = index >= 0 ? line.slice(0, index + 1) : currentLine({ ...journal, head: moveId });
const next = index >= 0 ? line[index + 1] : undefined;
if (next?.type === "GameEnded") {
return next.winner === "player" ? 1 : 0;
}
const after = replay(upTo);
const onRoll = after.turn ?? "player";
const evaluation = await engine.evaluate(
after.position,
engineCube(after, onRoll),
ANALYSIS_PLY,
);
return onRoll === "player" ? evaluation.win : 1 - evaluation.win;
}
// ------------------------------------------------ Les chiffres du bilan
/**
* Classement de gnubg selon le taux d'erreur par décision (GetRating et
* arThrsRating, analysis.c l. 83-101) : en dessous de 0,005, « Super
* Grandmaster » ; puis 0,008 « Grandmaster », 0,013 « Master », 0,020
* « Advanced », 0,032 « Intermediate », au-delà « Beginner ». Les six
* classements portent les noms des six niveaux de l'ordinateur, dans le
* même ordre (correspondance dans docs/glossaire/taux-d-erreur.md).
*/
export const RATING_THRESHOLDS: readonly number[] = [0.032, 0.02, 0.013, 0.008, 0.005];
/**
* Le taux d'erreur d'un camp, à la manière de gnubg (getMWCFromError,
* analysis.c) : l'équité perdue sur toutes ses décisions, divisée par le
* nombre de coups non forcés et de décisions de videau « serrées ».
*
* Glossaire : docs/glossaire/taux-d-erreur.md
*/
export interface ErrorRate {
/** Équité perdue au total, pour un videau de 1 (positive ou nulle). */
readonly total: number;
/** Nombre de décisions comptées. */
readonly decisions: number;
/** Équité perdue par décision (0 sans décision). */
readonly perDecision: number;
/** Le niveau correspondant, du nom des niveaux de l'ordinateur. */
readonly level: ComputerLevel;
}
/** La chance d'un camp sur la partie, à la manière de gnubg (formatgs.c,
* « Luck total », « Luck rate », « Luck rating »). */
export interface LuckSummary {
/** Somme de la chance de ses jets, pour un videau de 1. */
readonly total: number;
/** Nombre de ses tours avec un jet (anTotalMoves de gnubg). */
readonly rolls: number;
/** Chance par tour (0 sans tour). */
readonly perRoll: number;
/** getLuckRating : la chance par tour, multipliée par 10, comparée aux
* seuils d'un jet (LUCK_THRESHOLDS). */
readonly rating: LuckRating;
/** La chance comptée en points : chaque jet multiplié par la valeur
* du videau à ce moment (arLuck[…][1] de gnubg en partie libre). */
readonly points: number;
}
/** Ce que le bilan dit d'un camp. */
export interface SideSummary {
readonly errorRate: ErrorRate;
readonly luck: LuckSummary;
}
/** Une décision du joueur et son conseil, dans son tour. */
export interface ReviewedDecision {
readonly turn: number;
readonly advice: Advice;
}
/** Le bilan d'une partie (ligne courante). */
export interface GameSummary {
readonly player: SideSummary;
readonly computer: SideSummary;
/** Les décisions du joueur comptées, par appréciation : ses coups, ses
* doubles et ses réponses aux doubles, et ses jets sans doubler
* seulement quand doubler était mieux. */
readonly counts: Readonly<Record<Verdict, number>>;
/** La plus grosse erreur du joueur (null : aucune imprécision ni
* erreur). */
readonly toReview: ReviewedDecision | null;
/** Le résultat de la partie en points pour le joueur (0 si elle n'est
* pas finie), et ce résultat corrigé de la chance des deux camps
* (arLuckAdj de gnubg) : ce qu'il serait sans les dés. */
readonly result: number;
readonly luckAdjustedResult: number;
}
/** Le niveau de gnubg pour un taux d'erreur par décision. */
export function levelOfErrorRate(perDecision: number): ComputerLevel {
let index = 0;
while (index < RATING_THRESHOLDS.length && perDecision < (RATING_THRESHOLDS[index] ?? 0)) {
index++;
}
return COMPUTER_LEVELS[index] ?? "beginner";
}
/** getLuckRating de gnubg : Luck(chance par tour × 10). */
export function luckRatingOf(perRoll: number): LuckRating {
const r = perRoll * 10;
if (r > LUCK_THRESHOLDS.veryLucky) {
return "veryLucky";
}
if (r > LUCK_THRESHOLDS.lucky) {
return "lucky";
}
if (r < -LUCK_THRESHOLDS.veryLucky) {
return "veryUnlucky";
}
if (r < -LUCK_THRESHOLDS.lucky) {
return "unlucky";
}
return "none";
}
/**
* Une décision de videau « serrée » pour gnubg (isCloseCubedecision,
* eval.c l. 5107) : doubler coûte moins de 0,16 par rapport à l'optimum.
* Un jet sans doubler n'est compté dans le taux d'erreur que dans ce cas ;
* un double, une prise ou un refus le sont toujours.
*/
function closeCube(advice: CubeAdvice): boolean {
const e = advice.equities;
return e.optimal - Math.min(e.doubleTake, 1) < 0.16;
}
/** L'erreur d'une décision (équité perdue, positive) et si elle compte
* dans le nombre de décisions (updateStatcontext). */
function errorOf(advice: Advice): { error: number; counted: boolean } {
const error = -Math.min(0, advice.equityLoss);
if (advice.kind === "move") {
// Un coup forcé ne compte pas (anUnforcedMoves).
return { error: advice.moveCount > 1 ? error : 0, counted: advice.moveCount > 1 };
}
return { error, counted: advice.action !== "noDouble" || closeCube(advice) };
}
function errorRateOf(advices: readonly Advice[]): ErrorRate {
let total = 0;
let decisions = 0;
for (const advice of advices) {
const { error, counted } = errorOf(advice);
total += error;
decisions += counted ? 1 : 0;
}
const perDecision = decisions > 0 ? total / decisions : 0;
return { total, decisions, perDecision, level: levelOfErrorRate(perDecision) };
}
/** Le bilan de la ligne courante, avec les analyses déjà faites (les
* manquantes ne comptent pas : voir readyForSummary). */
export function summarizeAnalysis(journal: GameJournal): GameSummary {
const line = currentLine(journal);
const review = reviewOf(journal);
const advice = journal.advice ?? {};
const turns = turnsOf(line);
const turnOf = new Map<string, number>();
for (const turn of turns) {
for (const event of turn.events) {
turnOf.set(event.id, turn.number);
}
}
const decided = (side: Side): Advice[] =>
decisionsOf(line, side)
.map((e) => (side === "player" ? advice[e.id] : review.computer[e.id]))
.filter((a): a is Advice => a !== undefined);
const mine = decided("player");
// La chance de chaque camp, et la valeur du videau à chaque jet.
const luck: Record<Side, { total: number; points: number; rolls: number }> = {
player: { total: 0, points: 0, rolls: 0 },
computer: { total: 0, points: 0, rolls: 0 },
};
let state = replay([]);
for (const event of line) {
const before = state;
state = applyEvent(state, event);
if (event.type === "MovePlayed") {
luck[event.side].rolls++;
}
const value = event.type === "DiceRolled" ? review.luck[event.id] : undefined;
if (value !== undefined && event.type === "DiceRolled") {
const roller = event.side ?? state.turn ?? "player";
luck[roller].total += value.value;
luck[roller].points += value.value * before.cube.value;
}
}
const luckSummary = (side: Side): LuckSummary => {
const { total, points, rolls } = luck[side];
const perRoll = rolls > 0 ? total / rolls : 0;
return { total, rolls, perRoll, rating: luckRatingOf(perRoll), points };
};
const counts: Record<Verdict, number> = { good: 0, doubtful: 0, bad: 0, veryBad: 0 };
let toReview: ReviewedDecision | null = null;
for (const a of mine) {
const verdict = verdictOf(a.skill);
// Un jet sans doubler n'est une décision visible que s'il était
// une faute.
if (!(a.kind === "cube" && a.action === "noDouble" && verdict === "good")) {
counts[verdict]++;
}
if (verdict !== "good" && (toReview === null || a.equityLoss < toReview.advice.equityLoss)) {
toReview = { turn: turnOf.get(a.eventId) ?? 0, advice: a };
}
}
const ended = line.findLast((e) => e.type === "GameEnded");
const result =
ended?.type === "GameEnded" ? (ended.winner === "player" ? 1 : -1) * ended.points : 0;
const player = luckSummary("player");
const computer = luckSummary("computer");
return {
player: { errorRate: errorRateOf(mine), luck: player },
computer: { errorRate: errorRateOf(decided("computer")), luck: computer },
counts,
toReview,
result,
luckAdjustedResult: result - player.points + computer.points,
};
}
// ------------------------------------------------ Coup par coup
/** Un point de la courbe des chances de gagner : un tour. */
export interface ChancePoint {
readonly turn: number;
readonly side: Side;
/** Chances de gagner du joueur après le tour, de 0 à 1 (null : pas
* encore analysé). */
readonly chance: number | null;
/** L'appréciation de la décision du joueur la plus coûteuse du tour
* (null : aucune décision du joueur analysée). */
readonly verdict: Verdict | null;
}
/**
* La courbe des chances de gagner du joueur, tour après tour : la position
* après chaque coup évaluée par le moteur, et le résultat après le dernier.
*
* Glossaire : docs/glossaire/courbe-des-chances.md
*/
export type WinChanceCurve = readonly ChancePoint[];
/** La décision du joueur à revoir dans un tour : la plus coûteuse, le coup
* à égalité (null : aucune décision du joueur analysée dans ce tour). */
export function mainAdvice(journal: GameJournal, turn: Turn): Advice | null {
const advice = journal.advice ?? {};
let main: Advice | null = null;
for (const event of decisionsOf(turn.events, "player")) {
const a = advice[event.id];
if (a === undefined) {
continue;
}
if (
main === null ||
a.equityLoss < main.equityLoss ||
(a.equityLoss === main.equityLoss && a.kind === "move")
) {
main = a;
}
}
return main;
}
/** Le conseil sur le coup de l'ordinateur d'un tour (null sinon). */
export function computerMoveAdvice(journal: GameJournal, turn: Turn): MoveAdvice | null {
if (turn.side !== "computer" || turn.move === null) {
return null;
}
const advice = reviewOf(journal).computer[turn.move.id];
return advice?.kind === "move" ? advice : null;
}
export function chanceCurve(journal: GameJournal): WinChanceCurve {
const review = reviewOf(journal);
return gameTurns(journal).map((turn) => {
const main = mainAdvice(journal, turn);
let chance: number | null = null;
if (turn.move !== null) {
chance = review.winChances[turn.move.id] ?? null;
}
if (turn.end !== null && (turn.move === null || chance === null)) {
chance = turn.end.winner === "player" ? 1 : 0;
}
return {
turn: turn.number,
side: turn.side,
chance,
verdict: main === null ? null : verdictOf(main.skill),
};
});
}
/**
* « Rejouer d'ici » sur un tour : l'événement où revenir pour rejouer la
* décision à revoir du joueur (juste avant elle : même jet pour un coup,
* même moment pour un videau). null pour un tour sans décision du joueur.
*/
export function replayPoint(journal: GameJournal, turn: Turn): string | null {
const main = mainAdvice(journal, turn);
const decisions = decisionsOf(turn.events, "player");
const event =
main === null ? decisions.at(-1) : decisions.find((e) => e.id === main.eventId);
return event?.parent ?? null;
}
+15
View File
@@ -104,6 +104,11 @@ export interface Engine {
/** La chance d'un jet pour le joueur au trait. */
luck(position: PositionId, cube: EngineCube, roll: Roll): Promise<Luck>;
/** L'évaluation d'une position, joueur au trait avant de lancer les
* dés, au niveau ply : ses chances de gain (sans videau) et son équité
* avec videau. */
evaluate(position: PositionId, cube: EngineCube, ply: Ply): Promise<Evaluation>;
/** Le Match ID, joueur 0 au trait, avec ce videau et ce match vus de lui
* (partie libre sans match), sans dés. */
encodeMatch(cube: EngineCube): Promise<MatchId>;
@@ -189,6 +194,16 @@ export interface Luck {
readonly rating: LuckRating;
}
/** Une position évaluée par le moteur, du point de vue du joueur au
* trait. */
export interface Evaluation {
/** Chances de gagner la partie, de 0 à 1, sans tenir compte du videau
* (première probabilité de gnubg). */
readonly win: number;
/** Équité avec videau, normalisée pour un videau de 1. */
readonly equity: number;
}
/** Un coup avec son équité (avec videau), du point de vue de celui qui le joue. */
export interface EvaluatedMove extends Move {
readonly equity: number;
+75 -2
View File
@@ -17,6 +17,7 @@ import type { Cube } from "./cube.js";
import type { Level, ResultKind } from "./engine.js";
import type { Match, MatchAfterGame } from "./match.js";
import type { HalfMove, PositionId } from "./position.js";
import { REVIEW_VERSION, type GameReview } from "./review.js";
import type { Roll } from "./roll.js";
import type { Side } from "./side.js";
@@ -40,7 +41,7 @@ const VERSION_1_LEVEL: Level = "2ply";
* version inconnue est laissé tel quel (Game.resume le refuse).
*/
export function migrateJournal(saved: GameJournal): GameJournal {
const journal = keepKnownAdvice(saved);
const journal = keepKnownReview(keepKnownAdvice(saved));
if (journal.events.every((event) => event.version === EVENT_VERSION)) {
return journal;
}
@@ -87,6 +88,56 @@ function keepKnownAdvice(journal: GameJournal): GameJournal {
return { ...journal, advice: Object.fromEntries(kept) };
}
/**
* L'analyse de partie d'un journal enregistré (étape 1e) : oubliée si elle
* est d'une autre version (REVIEW_VERSION) ou mal formée ; sinon, seules
* restent ses entrées rattachées à un événement du journal. Ce n'est
* qu'une analyse du moteur, qui peut être refaite. Un journal sans analyse
* (enregistré avant l'étape 1e) reste tel quel.
*/
function keepKnownReview(journal: GameJournal): GameJournal {
const review = journal.review;
if (review === undefined) {
return journal;
}
const tables = [review.luck, review.winChances, review.computer];
const wellFormed =
typeof review === "object" &&
review !== null &&
review.version === REVIEW_VERSION &&
typeof review.ply === "number" &&
tables.every((table) => typeof table === "object" && table !== null) &&
Array.isArray(review.unanalysed);
if (!wellFormed) {
const { review: _, ...rest } = journal;
return rest;
}
const ids = new Set(journal.events.map((event) => event.id));
const known = <T>(table: Readonly<Record<string, T>>) =>
Object.fromEntries(Object.entries(table).filter(([id]) => ids.has(id)));
const computer = Object.fromEntries(
Object.entries(review.computer).filter(
([id, a]) =>
ids.has(id) &&
typeof a === "object" &&
a !== null &&
a.version === ADVICE_VERSION &&
a.eventId === id,
),
);
const kept: GameReview = {
...review,
luck: known(review.luck),
winChances: known(review.winChances),
computer,
unanalysed: review.unanalysed.filter((id) => ids.has(id)),
};
// Le nombre d'entrées de chaque table : rien d'oublié, rien ne change.
const sizes = (r: GameReview) =>
[r.luck, r.winChances, r.computer, r.unanalysed].map((t) => Object.keys(t).length).join();
return sizes(kept) === sizes(review) ? journal : { ...journal, review: kept };
}
interface EventBase {
/** Identifiant, unique dans la partie. */
readonly id: string;
@@ -182,11 +233,14 @@ type WithoutBase<E> = E extends unknown ? Omit<E, keyof EventBase> : never;
* courant (head) : la partie en est là. advice (étape 1d, facultatif) : le
* conseil du maître sur les décisions du joueur, par identifiant de
* l'événement de la décision. Ce n'est pas un événement : il arrive après
* coup, quand l'analyse du moteur est prête, et ne change pas la partie. */
* coup, quand l'analyse du moteur est prête, et ne change pas la partie.
* review (étape 1e, facultatif) : le reste de l'analyse de la partie (chance
* des jets, chances de gagner, décisions de l'ordinateur), pour le bilan. */
export interface GameJournal {
readonly events: readonly GameEvent[];
readonly head: string | null;
readonly advice?: Readonly<Record<string, Advice>>;
readonly review?: GameReview;
}
export const EMPTY_JOURNAL: GameJournal = { events: [], head: null };
@@ -211,6 +265,25 @@ export function withAdvice(journal: GameJournal, advice: Advice): GameJournal {
return { ...journal, advice: { ...journal.advice, [advice.eventId]: advice } };
}
/** Remplace l'analyse de partie du journal (elle ne change pas la
* partie). */
export function withReview(journal: GameJournal, review: GameReview): GameJournal {
return { ...journal, review };
}
/** Le journal avec les analyses d'une copie de lui-même, faite en
* arrière-plan : ses conseils s'ajoutent, son analyse de partie remplace
* celle du journal. Les événements ne changent pas. */
export function withAnalysesOf(journal: GameJournal, analysed: GameJournal): GameJournal {
const ids = new Set(journal.events.map((event) => event.id));
const advice = Object.entries(analysed.advice ?? {}).filter(([id]) => ids.has(id));
return {
...journal,
advice: { ...Object.fromEntries(advice), ...journal.advice },
...(analysed.review === undefined ? {} : { review: analysed.review }),
};
}
/** Les événements du début jusqu'à l'événement courant, dans l'ordre. */
export function currentLine(journal: GameJournal): GameEvent[] {
const byId = new Map(journal.events.map((event) => [event.id, event]));
+7
View File
@@ -24,6 +24,7 @@ import {
migrateJournal,
type NewGameEvent,
withAdvice,
withAnalysesOf,
} from "./events.js";
import { firstGameOf, type Match, nextGameOf } from "./match.js";
import { BAR, type HalfMove, OFF, type PositionId, START_POSITION } from "./position.js";
@@ -284,6 +285,12 @@ export class Game {
this.#journal = withAdvice(this.#journal, advice);
}
/** Range dans le journal les analyses faites en arrière-plan sur une
* copie de celui-ci (étape 1e : conseils, analyse de partie). */
recordAnalyses(analysed: GameJournal): void {
this.#journal = withAnalysesOf(this.#journal, analysed);
}
/**
* Les demi-coups que le joueur peut saisir maintenant, à la suite de ceux
* déjà saisis (liste vide hors de son tour de jeu). Chaque candidat
+55
View File
@@ -0,0 +1,55 @@
// L'analyse de la partie par le moteur, rangée dans le journal à côté du
// conseil du maître (étape 1e) : la chance de chaque jet, les chances de
// gagner après chaque coup, et les décisions de l'ordinateur.
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
//
// Ce fichier ne contient que des données : tout ce qu'elles disent vient du
// moteur. Le calcul est dans domain/analysis/game-analysis.ts. Comme le
// conseil (advice.ts), ce n'est pas un événement : l'analyse arrive après
// coup et ne change pas la partie. Le format est durable (ADR 0007) : il
// porte sa version (REVIEW_VERSION), et une analyse d'une autre version
// est oubliée à la reprise (elle peut être refaite).
import type { Advice } from "./advice.js";
import type { Luck, Ply } from "./engine.js";
/** Version du format de l'analyse de partie enregistrée. */
export const REVIEW_VERSION = 1;
/**
* L'analyse de la partie, en plus des conseils du maître : de quoi
* calculer le bilan (taux d'erreur, chance) et tracer la courbe des chances
* de gagner. Chaque table est indexée par l'identifiant d'un événement du
* journal ; une table incomplète est complétée en arrière-plan.
*/
export interface GameReview {
readonly version: number;
/** Niveau des analyses et des évaluations, en plis. */
readonly ply: Ply;
/** Pour chaque jet (DiceRolled, ouverture comprise), sa chance pour le
* camp qui le joue, selon le moteur. */
readonly luck: Readonly<Record<string, Luck>>;
/** Pour chaque coup (MovePlayed), les chances de gagner du joueur dans
* la position d'après, de 0 à 1, selon le moteur. */
readonly winChances: Readonly<Record<string, number>>;
/** Les décisions de l'ordinateur, analysées comme celles du joueur (le
* conseil d'un coup de l'ordinateur est vu de lui). */
readonly computer: Readonly<Record<string, Advice>>;
/** Les décisions (des deux camps) que le moteur n'analyse pas : videau
* inaccessible, position de départ. Elles ne sont plus demandées. */
readonly unanalysed: readonly string[];
}
/** Une analyse vide, au niveau ply. */
export function emptyReview(ply: Ply): GameReview {
return {
version: REVIEW_VERSION,
ply,
luck: {},
winChances: {},
computer: {},
unanalysed: [],
};
}
+10
View File
@@ -19,6 +19,7 @@ import type {
EngineCube,
EngineMatch,
EvaluatedMove,
Evaluation,
HalfMovesCheck,
Level,
Luck,
@@ -260,6 +261,15 @@ export function createWasmEngine(run: RunCommand): Engine {
return { value: answer.luck, rating };
},
async evaluate(position: PositionId, cube: EngineCube, ply: Ply): Promise<Evaluation> {
const args = ["--ply", String(ply), "eval", position, await matchId(cube)];
const answer = (await run(args)) as {
probabilities: { win: number };
cubeful_equity: number;
};
return { win: answer.probabilities.win, equity: answer.cubeful_equity };
},
encodeMatch: matchId,
async decodeMatch(id: MatchId): Promise<MatchDescription> {