diff --git a/app/src/domain/analysis/analyse.ts b/app/src/domain/analysis/analyse.ts index 22c38a3..665249b 100644 --- a/app/src/domain/analysis/analyse.ts +++ b/app/src/domain/analysis/analyse.ts @@ -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 { + 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, diff --git a/app/src/domain/analysis/game-analysis.ts b/app/src/domain/analysis/game-analysis.ts new file mode 100644 index 0000000..cecb4cc --- /dev/null +++ b/app/src/domain/analysis/game-analysis.ts @@ -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//analyse/). + */ +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 { + 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 { + 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>; + /** 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(); + 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 = { + 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 = { 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; +} diff --git a/app/src/domain/game/engine.ts b/app/src/domain/game/engine.ts index 935f6da..10228dd 100644 --- a/app/src/domain/game/engine.ts +++ b/app/src/domain/game/engine.ts @@ -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; + /** 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; + /** 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; @@ -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; diff --git a/app/src/domain/game/events.ts b/app/src/domain/game/events.ts index 9f66ac4..8ac60eb 100644 --- a/app/src/domain/game/events.ts +++ b/app/src/domain/game/events.ts @@ -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 = (table: Readonly>) => + 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 extends unknown ? Omit : 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>; + 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])); diff --git a/app/src/domain/game/game.ts b/app/src/domain/game/game.ts index c19b1de..0c56d4c 100644 --- a/app/src/domain/game/game.ts +++ b/app/src/domain/game/game.ts @@ -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 diff --git a/app/src/domain/game/review.ts b/app/src/domain/game/review.ts new file mode 100644 index 0000000..1bed493 --- /dev/null +++ b/app/src/domain/game/review.ts @@ -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>; + /** 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>; + /** 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>; + /** 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: [], + }; +} diff --git a/app/src/engine/wasm-engine.ts b/app/src/engine/wasm-engine.ts index b6fbdc2..58e73f1 100644 --- a/app/src/engine/wasm-engine.ts +++ b/app/src/engine/wasm-engine.ts @@ -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 { + 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 { diff --git a/app/tests/game-analysis.test.ts b/app/tests/game-analysis.test.ts new file mode 100644 index 0000000..9904306 --- /dev/null +++ b/app/tests/game-analysis.test.ts @@ -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 { + 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); +}); diff --git a/app/tests/game.test.ts b/app/tests/game.test.ts index a511981..77ede5f 100644 --- a/app/tests/game.test.ts +++ b/app/tests/game.test.ts @@ -81,6 +81,7 @@ function fakeEngine(overrides: Partial = {}): Engine { skill: "none", }), luck: async () => ({ value: 0, rating: "none" }), + evaluate: async () => ({ win: 0.5, equity: 0 }), encodeMatch: async () => "MATCH", decodeMatch: async () => ({ matchLength: 0,