From 23f4d47fcf71cd6307043a5c03954c1482bc0fb7 Mon Sep 17 00:00:00 2001 From: Pierre MARTIN Date: Tue, 3 Nov 2026 14:59:07 +0100 Subject: [PATCH] =?UTF-8?q?Domaine=20et=20adaptateur=20:=20niveaux,=20anal?= =?UTF-8?q?yses=20et=20chance=20;=20cache=20vid=C3=A9=20=C3=A0=20chaque=20?= =?UTF-8?q?calcul?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- app/src/domain/game/engine.ts | 135 ++++++++++++++++++++++++++++++ app/src/engine/wasm-engine.ts | 134 ++++++++++++++++++++++++++++- app/tests/engine-analysis.test.ts | 126 ++++++++++++++++++++++++++++ app/tests/game.test.ts | 10 +++ docs/glossaire/README.md | 3 + docs/glossaire/appreciation.md | 14 ++++ docs/glossaire/chance.md | 15 ++++ docs/glossaire/niveau-de-jeu.md | 18 ++++ engine/engine.c | 25 +++++- 9 files changed, 476 insertions(+), 4 deletions(-) create mode 100644 app/tests/engine-analysis.test.ts create mode 100644 docs/glossaire/appreciation.md create mode 100644 docs/glossaire/chance.md create mode 100644 docs/glossaire/niveau-de-jeu.md diff --git a/app/src/domain/game/engine.ts b/app/src/domain/game/engine.ts index 12d4663..cee9049 100644 --- a/app/src/domain/game/engine.ts +++ b/app/src/domain/game/engine.ts @@ -38,6 +38,141 @@ export interface Engine { /** Le plateau d'une position, et le résultat si la partie y est finie. */ board(position: PositionId, cube: EngineCube): Promise; + + /** Le coup que joue l'ordinateur de gnubg à ce niveau (bruit compris, + * reproductible), ou null si aucun coup n'est possible. */ + levelMove( + position: PositionId, + cube: EngineCube, + roll: Roll, + level: Level, + ): Promise; + + /** La décision de videau de l'ordinateur à ce niveau, bruit compris. */ + levelCubeDecision(position: PositionId, cube: EngineCube, level: Level): Promise; + + /** L'analyse du coup joué (played : position après le coup, vue du joueur + * qui l'a joué, comme Move.position), au niveau d'analyse ply ; null si + * aucun coup n'était possible. */ + analyseMove( + position: PositionId, + cube: EngineCube, + roll: Roll, + played: PositionId, + ply: Ply, + ): Promise; + + /** L'analyse d'une décision de videau, position vue du joueur au trait + * (celui qui double ou non ; take et pass sont la réponse de son + * adversaire). */ + analyseCube( + position: PositionId, + cube: EngineCube, + action: CubeAction, + ply: Ply, + ): Promise; + + /** La chance d'un jet pour le joueur au trait. */ + luck(position: PositionId, cube: EngineCube, roll: Roll): Promise; +} + +/** + * Niveau de jeu de l'ordinateur : un réglage prédéfini de GNU Backgammon + * (aecSettings dans eval.c ; voir engine/include/engine.h). Les quatre + * premiers jouent à 0 pli avec un bruit qui leur fait commettre des erreurs ; + * les suivants sont sans bruit, de 0 à 4 plis. + * + * Glossaire : docs/glossaire/niveau-de-jeu.md + */ +export type Level = + | "beginner" + | "casual" + | "intermediate" + | "advanced" + | "0ply" + | "1ply" + | "2ply" + | "3ply" + | "4ply"; + +/** + * Appréciation d'un coup ou d'une décision de videau par le moteur, selon + * l'équité perdue : rien à signaler, douteux, mauvais, très mauvais. + * + * Glossaire : docs/glossaire/appreciation.md + */ +export type Skill = "none" | "doubtful" | "bad" | "veryBad"; + +/** + * Seuils de GNU Backgammon (arSkillLevel dans gnubg.c) : une perte + * d'équité strictement plus grande fait passer à l'appréciation. Le moteur + * classe lui-même ; ces valeurs servent à l'expliquer. + */ +export const SKILL_THRESHOLDS = { doubtful: 0.03, bad: 0.06, veryBad: 0.12 } as const; + +/** Catégorie de chance d'un jet, de très malchanceux à très chanceux. */ +export type LuckRating = "veryUnlucky" | "unlucky" | "none" | "lucky" | "veryLucky"; + +/** + * Seuils de GNU Backgammon (arLuckLevel dans gnubg.c) sur la chance d'un + * jet, dans un sens ou dans l'autre. + */ +export const LUCK_THRESHOLDS = { lucky: 0.3, veryLucky: 0.6 } as const; + +/** + * La chance d'un jet : l'équité qu'il fait gagner (positive) ou perdre au + * joueur par rapport à la moyenne des jets. + * + * Glossaire : docs/glossaire/chance.md + */ +export interface Luck { + readonly value: number; + readonly rating: LuckRating; +} + +/** Un coup avec son équité (avec videau), du point de vue de celui qui le joue. */ +export interface EvaluatedMove extends Move { + readonly equity: number; + /** Niveau auquel le coup a réellement été évalué. */ + readonly ply: Ply; +} + +/** Ce que le moteur dit d'un coup joué, comme l'analyse de partie de gnubg. */ +export interface MoveAnalysis { + /** Nombre de coups légaux. */ + readonly moveCount: number; + /** Place du coup joué dans le classement, à partir de 1. */ + readonly rank: number; + readonly played: EvaluatedMove; + readonly best: EvaluatedMove; + /** Équité du coup joué moins celle du meilleur : 0 ou négative. */ + readonly equityLoss: number; + readonly skill: Skill; +} + +/** Décision de videau : doubler ou non (joueur au trait), prendre ou passer + * (son adversaire). */ +export type CubeAction = "noDouble" | "double" | "take" | "pass"; + +/** Ce que le moteur dit d'une décision de videau. */ +export interface CubeAnalysis { + /** gnubg analyse-t-il cette décision ? Pas quand le joueur au trait ne + * peut pas doubler, ni à la position de départ. */ + readonly analysed: boolean; + /** Équités du joueur au trait, normalisées pour un videau de 1 (null si + * non analysée). */ + readonly equities: CubeEquities | null; + /** Équité perdue par celui qui décide : 0 ou négative. */ + readonly equityLoss: number; + readonly skill: Skill; +} + +export interface CubeEquities { + readonly noDouble: number; + readonly doubleTake: number; + readonly doublePass: number; + /** Après les meilleures décisions des deux joueurs. */ + readonly optimal: number; } /** diff --git a/app/src/engine/wasm-engine.ts b/app/src/engine/wasm-engine.ts index 019ccca..ef12813 100644 --- a/app/src/engine/wasm-engine.ts +++ b/app/src/engine/wasm-engine.ts @@ -12,12 +12,20 @@ import type { Board, BoardResult, BoardSide, + CubeAction, + CubeAnalysis, CubeDecision, Engine, EngineCube, + EvaluatedMove, HalfMovesCheck, + Level, + Luck, + LuckRating, Move, + MoveAnalysis, Ply, + Skill, } from "../domain/game/engine.js"; import { formatHalfMoves, type HalfMove, type PositionId } from "../domain/game/position.js"; import type { Roll } from "../domain/game/roll.js"; @@ -32,6 +40,16 @@ interface CliMove { position_id: string; half_moves: [number, number][]; } +interface CliEvaluatedMove extends CliMove { + ply: number; + equity: number; +} +type CliSkill = "none" | "doubtful" | "bad" | "very_bad"; +interface CliCubeDecision { + available: boolean; + double: boolean; + take: boolean; +} interface CliSide { points: number[]; bar: number; @@ -86,8 +104,108 @@ export function createWasmEngine(run: RunCommand): Engine { ply: Ply, ): Promise { const args = ["--ply", String(ply), "cube", position, await matchId(cube)]; - const answer = (await run(args)) as CubeDecision; - return { available: answer.available, double: answer.double, take: answer.take }; + return toCubeDecision((await run(args)) as CliCubeDecision); + }, + + async levelMove(position: PositionId, cube: EngineCube, roll: Roll, level: Level) { + const args = ["--level", level, "bestmove", position, await matchId(cube)]; + const answer = (await run([...args, diceText(roll)])) as { best_move: CliMove | null }; + return answer.best_move === null ? null : toMove(answer.best_move); + }, + + async levelCubeDecision( + position: PositionId, + cube: EngineCube, + level: Level, + ): Promise { + const args = ["--level", level, "cube", position, await matchId(cube)]; + return toCubeDecision((await run(args)) as CliCubeDecision); + }, + + async analyseMove( + position: PositionId, + cube: EngineCube, + roll: Roll, + played: PositionId, + ply: Ply, + ): Promise { + const args = ["--ply", String(ply), "analysemove", position, await matchId(cube)]; + const answer = (await run([...args, diceText(roll), played])) as { + move_count: number; + rank: number | null; + played: CliEvaluatedMove | null; + best: CliEvaluatedMove | null; + equity_loss: number; + skill: CliSkill; + }; + if (answer.rank === null || answer.played === null || answer.best === null) { + return null; + } + return { + moveCount: answer.move_count, + rank: answer.rank, + played: toEvaluatedMove(answer.played), + best: toEvaluatedMove(answer.best), + equityLoss: answer.equity_loss, + skill: toSkill(answer.skill), + }; + }, + + async analyseCube( + position: PositionId, + cube: EngineCube, + action: CubeAction, + ply: Ply, + ): Promise { + const cliAction = { + noDouble: "no_double", + double: "double", + take: "take", + pass: "pass", + }[action]; + const args = ["--ply", String(ply), "analysecube", position, await matchId(cube)]; + const answer = (await run([...args, cliAction])) as { + analysed: boolean; + cube: { + no_double: number; + double_take: number; + double_pass: number; + optimal: number; + } | null; + equity_loss: number; + skill: CliSkill; + }; + return { + analysed: answer.analysed, + equities: + answer.cube === null + ? null + : { + noDouble: answer.cube.no_double, + doubleTake: answer.cube.double_take, + doublePass: answer.cube.double_pass, + optimal: answer.cube.optimal, + }, + equityLoss: answer.equity_loss, + skill: toSkill(answer.skill), + }; + }, + + async luck(position: PositionId, cube: EngineCube, roll: Roll): Promise { + const args = ["luck", position, await matchId(cube), diceText(roll)]; + const answer = (await run(args)) as { luck: number; rating: string }; + const ratings: Record = { + very_unlucky: "veryUnlucky", + unlucky: "unlucky", + none: "none", + lucky: "lucky", + very_lucky: "veryLucky", + }; + const rating = ratings[answer.rating]; + if (rating === undefined) { + throw new Error(`chance inconnue : ${answer.rating}`); + } + return { value: answer.luck, rating }; }, async checkHalfMoves( @@ -139,6 +257,18 @@ function toMove(move: CliMove): Move { }; } +function toEvaluatedMove(move: CliEvaluatedMove): EvaluatedMove { + return { ...toMove(move), equity: move.equity, ply: move.ply }; +} + +function toCubeDecision(answer: CliCubeDecision): CubeDecision { + return { available: answer.available, double: answer.double, take: answer.take }; +} + +function toSkill(skill: CliSkill): Skill { + return skill === "very_bad" ? "veryBad" : skill; +} + function toSide(side: CliSide): BoardSide { return { points: side.points, bar: side.bar, off: side.off, pipCount: side.pip_count }; } diff --git a/app/tests/engine-analysis.test.ts b/app/tests/engine-analysis.test.ts new file mode 100644 index 0000000..ddb1241 --- /dev/null +++ b/app/tests/engine-analysis.test.ts @@ -0,0 +1,126 @@ +// L'adaptateur du moteur WebAssembly pour les niveaux, l'analyse et la +// chance (étape 1a), sur le vrai moteur. La concordance des nombres avec +// gnubg de bureau est vérifiée par tests/test_wasm_reference.sh ; ici, la +// traduction des réponses en types du domaine. +// +// 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 { fileURLToPath } from "node:url"; +import { type EngineCube, LUCK_THRESHOLDS, SKILL_THRESHOLDS } from "../src/domain/game/engine.js"; +import { START_POSITION } from "../src/domain/game/position.js"; +import { loadNodeEngine } from "./support/node-engine.js"; + +const centered: EngineCube = { value: 1, owner: "centered" }; +const middlegame = "mM/gATCw5+ABMA"; + +test("ouverture 3-1 : 24/23 13/10 est une erreur très mauvaise, 8/5 6/5 le meilleur coup", async () => { + const engine = await loadNodeEngine(); + const analysis = await engine.analyseMove( + START_POSITION, + centered, + [3, 1], + "4HPwATDgc+IBKA", + 2, + ); + assert.ok(analysis !== null); + assert.equal(analysis.moveCount, 16); + assert.equal(analysis.rank, 2); + assert.equal(analysis.played.notation, "24/23 13/10"); + assert.equal(analysis.best.notation, "8/5 6/5"); + assert.equal(analysis.skill, "veryBad"); + assert.ok( + Math.abs(analysis.equityLoss - (analysis.played.equity - analysis.best.equity)) < 1e-6, + ); + assert.ok(analysis.equityLoss < -SKILL_THRESHOLDS.veryBad); +}); + +test("sans coup possible, il n'y a rien à analyser", async () => { + const engine = await loadNodeEngine(); + const position = "27YBBwAcz8EDQA"; + assert.equal(await engine.analyseMove(position, centered, [5, 5], position, 0), null); +}); + +test("double/prend : passer est une erreur de l'adversaire, prendre n'en est pas une", async () => { + const engine = await loadNodeEngine(); + const position = "6O5uAAC4uzsAAA"; + const pass = await engine.analyseCube(position, centered, "pass", 2); + assert.equal(pass.analysed, true); + assert.equal(pass.skill, "bad"); + assert.ok(pass.equities !== null && pass.equities.doubleTake < pass.equities.doublePass); + const take = await engine.analyseCube(position, centered, "take", 2); + assert.deepEqual([take.skill, take.equityLoss], ["none", 0]); + const opponentOwns = await engine.analyseCube( + position, + { value: 2, owner: "opponent" }, + "noDouble", + 2, + ); + assert.deepEqual(opponentOwns, { + analysed: false, + equities: null, + equityLoss: 0, + skill: "none", + }); +}); + +test("la chance d'un jet et sa catégorie", async () => { + const engine = await loadNodeEngine(); + const opening = await engine.luck(START_POSITION, centered, [3, 1]); + assert.ok(Math.abs(opening.value - 0.2126) < 2e-3); + assert.equal(opening.rating, "none"); + const doubleSix = await engine.luck(START_POSITION, centered, [6, 6]); + assert.ok(doubleSix.value > LUCK_THRESHOLDS.lucky); + assert.equal(doubleSix.rating, "lucky"); +}); + +// Régression : sans cache vidé à chaque appel (engine.c, start_evaluation), +// une analyse à 2 plis changeait la chance calculée ensuite (évaluations à +// 0 pli des réseaux d'élagage restées dans le cache de gnubg). +test("un résultat ne dépend pas des calculs faits avant", async () => { + const engine = await loadNodeEngine(); + const before = await engine.luck(middlegame, centered, [6, 4]); + await engine.analyseCube(middlegame, centered, "noDouble", 2); + await engine.analyseMove(middlegame, centered, [6, 4], "mM/gATDGzuABMA", 2); + assert.deepEqual(await engine.luck(middlegame, centered, [6, 4]), before); +}); + +test("le niveau débutant joue toujours le même coup, différent du meilleur", async () => { + const engine = await loadNodeEngine(); + for (let i = 0; i < 3; i++) { + const move = await engine.levelMove(middlegame, centered, [6, 4], "beginner"); + assert.equal(move?.notation, "24/20 24/18"); + } + assert.equal((await engine.levelMove(middlegame, centered, [6, 4], "2ply"))?.notation, "24/14"); + const cube = await engine.levelCubeDecision("6O5uAAC4uzsAAA", centered, "beginner"); + assert.equal(cube.double, false); +}); + +test("les seuils du domaine sont ceux du moteur", async () => { + const root = new URL("../../../", import.meta.url); + const module = await import(new URL("build/wasm/engine.mjs", root).href); + const wrapper = await import(new URL("web/engine-wasm.mjs", root).href); + const raw = await wrapper.loadEngine( + module.default, + fileURLToPath(new URL("build/wasm/", root)), + ); + const { result } = raw.run(["levels"]) as { + result: { + levels: { name: string }[]; + skill_thresholds: { doubtful: number; bad: number; very_bad: number }; + luck_thresholds: { lucky: number; very_lucky: number }; + }; + }; + const close = (a: number, b: number) => Math.abs(a - b) < 1e-6; + assert.ok(close(result.skill_thresholds.doubtful, SKILL_THRESHOLDS.doubtful)); + assert.ok(close(result.skill_thresholds.bad, SKILL_THRESHOLDS.bad)); + assert.ok(close(result.skill_thresholds.very_bad, SKILL_THRESHOLDS.veryBad)); + assert.ok(close(result.luck_thresholds.lucky, LUCK_THRESHOLDS.lucky)); + assert.ok(close(result.luck_thresholds.very_lucky, LUCK_THRESHOLDS.veryLucky)); + assert.deepEqual( + result.levels.map((l) => l.name), + ["beginner", "casual", "intermediate", "advanced", "0ply", "1ply", "2ply", "3ply", "4ply"], + ); +}); diff --git a/app/tests/game.test.ts b/app/tests/game.test.ts index c611f38..dfb1442 100644 --- a/app/tests/game.test.ts +++ b/app/tests/game.test.ts @@ -49,6 +49,16 @@ function fakeEngine(overrides: Partial = {}): Engine { return { valid: false, complete: false, position: null }; }, board: async () => notOver, + levelMove: async () => move, + levelCubeDecision: async () => noDouble, + analyseMove: async () => null, + analyseCube: async () => ({ + analysed: false, + equities: null, + equityLoss: 0, + skill: "none", + }), + luck: async () => ({ value: 0, rating: "none" }), ...overrides, }; } diff --git a/docs/glossaire/README.md b/docs/glossaire/README.md index d915c86..f5e6a05 100644 --- a/docs/glossaire/README.md +++ b/docs/glossaire/README.md @@ -35,10 +35,12 @@ exportée. `make test` vérifie le lien dans les deux sens : chaque fiche avec | Terme | Anglais (code) | | --- | --- | +| [Appréciation](appreciation.md) | Skill | | [Backgammon](backgammon.md) | Backgammon | | [Barre](barre.md) | Bar | | [Blot](blot.md) | Blot | | [Branche](branche.md) | Branch | +| [Chance](chance.md) | Luck | | [Compte de points](compte-de-points.md) | Pip count | | [Coup](coup.md) | Move | | [Demi-coup](demi-coup.md) | Half-move | @@ -52,6 +54,7 @@ exportée. `make test` vérifie le lien dans les deux sens : chaque fiche avec | [Jet](jet.md) | Roll | | [Match](match.md) | Match | | [Moteur](moteur.md) | Engine | +| [Niveau de jeu](niveau-de-jeu.md) | Level | | [Partie](partie.md) | Game | | [Passer](passer.md) | Drop | | [Pion](pion.md) | Checker | diff --git a/docs/glossaire/appreciation.md b/docs/glossaire/appreciation.md new file mode 100644 index 0000000..c362ae5 --- /dev/null +++ b/docs/glossaire/appreciation.md @@ -0,0 +1,14 @@ +--- +term: Appréciation +english: Skill +code: Skill +--- +# Appréciation + +**En bref** : le jugement du moteur sur un coup ou une décision de videau, d'après l'équité perdue par rapport au meilleur choix. + +GNU Backgammon distingue trois degrés, selon l'équité perdue : douteux (plus +de 0,03), mauvais (plus de 0,06) et très mauvais (plus de 0,12). En dessous, +il n'y a rien à signaler. + +Voir aussi : [Équité](equite.md), [Coup](coup.md), [Videau](videau.md) diff --git a/docs/glossaire/chance.md b/docs/glossaire/chance.md new file mode 100644 index 0000000..07c0378 --- /dev/null +++ b/docs/glossaire/chance.md @@ -0,0 +1,15 @@ +--- +term: Chance +english: Luck +code: Luck +--- +# Chance + +**En bref** : ce qu'un jet de dés fait gagner ou perdre, en équité, par rapport à la moyenne de tous les jets possibles. + +Un jet est dit chanceux s'il fait gagner plus de 0,3 d'équité par rapport à la +moyenne, très chanceux au-delà de 0,6 ; malchanceux ou très malchanceux dans +l'autre sens. Comparer la chance d'une partie à ses erreurs aide à savoir si +on a perdu à cause des dés ou de son jeu. + +Voir aussi : [Équité](equite.md), [Jet](jet.md) diff --git a/docs/glossaire/niveau-de-jeu.md b/docs/glossaire/niveau-de-jeu.md new file mode 100644 index 0000000..d5d533c --- /dev/null +++ b/docs/glossaire/niveau-de-jeu.md @@ -0,0 +1,18 @@ +--- +term: Niveau de jeu +english: Level +code: Level +--- +# Niveau de jeu + +**En bref** : la force de l'ordinateur, du débutant, qui commet volontairement des erreurs, au plus fort, qui réfléchit plusieurs plis à l'avance. + +Les niveaux sont ceux de GNU Backgammon. Les quatre premiers (débutant, +amateur, intermédiaire, avancé) jugent la position sans regarder les jets +suivants et ajoutent un « bruit » à leur jugement, de plus en plus faible : +c'est ce qui leur fait choisir parfois un moins bon coup. Ce bruit dépend de +la position seule : dans la même position, avec le même jet, l'ordinateur +joue toujours le même coup. Les niveaux suivants, sans bruit, réfléchissent +de 0 à 4 plis. + +Voir aussi : [Pli](pli.md), [Moteur](moteur.md) diff --git a/engine/engine.c b/engine/engine.c index 37445a4..01ed5f5 100644 --- a/engine/engine.c +++ b/engine/engine.c @@ -252,12 +252,31 @@ check_dice(int die1, int die2) return ENGINE_OK; } -/* Réglages prédéfinis de gnubg pour un niveau, sans bruit. */ +/* + * Vide le cache d'évaluation de gnubg avant chaque calcul : un résultat ne + * dépend ainsi que de ses arguments, comme les références (gnubg de bureau, + * « clear cache » avant chaque position). Sans cela, une recherche à 2 plis + * avec élagage laisse dans le cache des évaluations à 0 pli faites avec les + * réseaux d'élagage, rangées sous la même clé que les évaluations à 0 pli + * ordinaires (EvalKey, eval.c l. 2302 : l'élagage n'y compte qu'à partir + * d'1 pli) ; une chance de jet ou un coup à 0 pli calculé ensuite les + * reprendrait (écarts mesurés jusqu'à 2,5e-2, tests/reference/README.md). + * Coût mesuré : environ 3 ms par appel sous Node sur le PC. + */ +static void +start_evaluation(void) +{ + EvalCacheFlush(); +} + +/* Réglages prédéfinis de gnubg pour un niveau, sans bruit ; prépare le + * calcul. */ static engine_status eval_context_for_ply(int ply, evalcontext *context) { if (ply < 0 || ply > ENGINE_MAX_PLY) return ENGINE_ERROR_PLY; + start_evaluation(); *context = aecSettings[SETTINGS_0PLY + ply]; return ENGINE_OK; } @@ -273,12 +292,13 @@ static const char *const level_names[ENGINE_LEVEL_COUNT] = { "beginner", "casual", "intermediate", "advanced", "0ply", "1ply", "2ply", "3ply", "4ply" }; -/* Réglages d'évaluation d'un niveau (bruit compris). */ +/* Réglages d'évaluation d'un niveau (bruit compris) ; prépare le calcul. */ static engine_status eval_context_for_level(int level, evalcontext *context) { if (level < 0 || level >= ENGINE_LEVEL_COUNT) return ENGINE_ERROR_LEVEL; + start_evaluation(); *context = aecSettings[level]; return ENGINE_OK; } @@ -845,6 +865,7 @@ engine_luck_of(const char *position_id, const char *match_id, int die1, int die2 status = check_dice(die1, die2); if (status != ENGINE_OK) return status; + start_evaluation(); /* LuckAnalysis (analysis.c l. 252-268) : dés rangés du plus fort au plus * faible ; LuckFirst à la position de départ hors doubles. */