diff --git a/app/src/domain/analysis/analyse.ts b/app/src/domain/analysis/analyse.ts new file mode 100644 index 0000000..dd7b180 --- /dev/null +++ b/app/src/domain/analysis/analyse.ts @@ -0,0 +1,171 @@ +// L'analyse des décisions du joueur par le moteur, pour le conseil du +// maître (étape 1d, ADR 0011) et le bilan de fin de partie (1e). +// +// Copyright (C) 2026 les auteurs d'Ustagammon. +// Licence : GPLv3 ou ultérieure (voir LICENSE). +// +// Tout vient du moteur : l'analyse du coup joué (rang, meilleur coup, équité +// perdue, appréciation), le classement des coups, l'analyse de la décision +// de videau, et les plateaux décodés, où se lisent les pions frappés et les +// points faits. L'application lance ces analyses sur un second moteur, en +// arrière-plan : elles ne retardent jamais la partie. + +import { + ADVICE_VERSION, + type Advice, + type AdvisedMove, + type MoveFacts, +} from "../game/advice.js"; +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 { replay } from "../game/state.js"; + +/** Niveau de l'analyse : 2 plis, comme l'analyse de partie de gnubg par + * défaut, et le niveau « standard » (ADR 0009). */ +export const ANALYSIS_PLY: Ply = STANDARD_PLY; + +/** Nombre de coups gardés du classement du moteur, pour « Voir les + * chiffres du moteur ». */ +export const RANKING_SIZE = 5; + +/** + * Les décisions du joueur à analyser parmi des événements : ses coups (pas + * un tour passé faute de coup possible), ses jets (il aurait pu doubler + * avant), ses doubles et ses réponses aux doubles de l'ordinateur. + */ +export function playerDecisions(events: readonly GameEvent[]): GameEvent[] { + return events.filter((event) => { + switch (event.type) { + case "MovePlayed": + return event.side === "player" && event.halfMoves.length > 0; + case "DiceRolled": + case "CubeOffered": + case "CubeTaken": + case "CubeDropped": + return event.side === "player"; + default: + return false; + } + }); +} + +/** + * 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). + */ +export async function analyseDecision( + engine: Engine, + journal: GameJournal, + eventId: string, + ply: Ply = ANALYSIS_PLY, +): Promise { + const line = currentLine({ ...journal, head: eventId }); + const event = line.at(-1); + if (event === undefined || playerDecisions([event]).length === 0) { + return null; + } + const earlier = line.slice(0, -1); + const before = replay(earlier); + if (event.type === "MovePlayed") { + 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 analysis = await engine.analyseMove( + before.position, + cube, + before.roll, + after.swappedPosition, + ply, + ); + if (analysis === null) { + return null; + } + const ranking = 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)); + return { + version: ADVICE_VERSION, + kind: "move", + eventId, + ply, + position: before.position, + roll: before.roll, + opening: !earlier.some((e) => e.type === "MovePlayed"), + moveCount: analysis.moveCount, + rank: analysis.rank, + played: advised(analysis.played), + best: advised(analysis.best), + playedFacts: await facts(analysis.played), + bestFacts: await facts(analysis.best), + ranking: ranking.slice(0, RANKING_SIZE).map(advised), + equityLoss: analysis.equityLoss, + skill: analysis.skill, + }; + } + let action: CubeAction; + let decider: "player" | "computer" = "player"; + if (event.type === "DiceRolled") { + if (before.phase !== "rolling") { + return null; + } + action = "noDouble"; + } else if (event.type === "CubeOffered") { + action = "double"; + } else { + action = event.type === "CubeTaken" ? "take" : "pass"; + decider = "computer"; + } + const analysis = await engine.analyseCube( + before.position, + engineCube(before, decider), + action, + ply, + ); + if (!analysis.analysed || analysis.equities === null) { + return null; + } + return { + version: ADVICE_VERSION, + kind: "cube", + eventId, + ply, + action, + position: before.position, + equities: analysis.equities, + equityLoss: analysis.equityLoss, + skill: analysis.skill, + }; +} + +function advised(move: EvaluatedMove): AdvisedMove { + return { + notation: move.notation, + halfMoves: move.halfMoves.map((h) => ({ from: h.from, to: h.to })), + equity: move.equity, + ply: move.ply, + }; +} + +/** Ce que le coup a changé sur le plateau, lu sur les plateaux du moteur + * (vus du joueur) : pions adverses envoyés sur la barre, cases où le + * joueur a désormais deux pions ou plus. */ +function factsOf( + mine: BoardSide, + theirs: BoardSide, + after: { readonly onRoll: BoardSide; readonly opponent: BoardSide }, +): MoveFacts { + const madePoints: number[] = []; + for (let i = 0; i < 24; i++) { + if ((mine.points[i] ?? 0) < 2 && (after.onRoll.points[i] ?? 0) >= 2) { + madePoints.push(i + 1); + } + } + return { hits: Math.max(0, after.opponent.bar - theirs.bar), madePoints }; +} diff --git a/app/src/domain/analysis/phrases.ts b/app/src/domain/analysis/phrases.ts new file mode 100644 index 0000000..517f325 --- /dev/null +++ b/app/src/domain/analysis/phrases.ts @@ -0,0 +1,263 @@ +// Les mots et les phrases du maître : lecture de leur source +// (docs/maitre/phrases.md, ADR 0008 : une seule source) et remplissage des +// gabarits avec un conseil du moteur. Fonctions pures, testées sous Node +// sur de vraies analyses (app/tests/advice.test.ts). +// +// Copyright (C) 2026 les auteurs d'Ustagammon. +// Licence : GPLv3 ou ultérieure (voir LICENSE). + +import { + type Advice, + type CubeAdvice, + masterCubeAction, + type MoveAdvice, + type MoveFacts, + type Verdict, + verdictOf, +} from "../game/advice.js"; +import type { CubeAction } from "../game/engine.js"; + +/** Les gabarits, par clé de section (« coup.meilleur »…). */ +export type MasterPhrases = ReadonlyMap; + +/** Longueur maximale de la ligne du maître (mot et phrase), en caractères + * (charte, « Les phrases du maître ») : elle doit rester en dessous. */ +export const LINE_LIMIT = 140; + +/** Ce que le maître dit d'une décision. */ +export interface MasterWords { + readonly verdict: Verdict; + /** Le mot d'appréciation, sans point : « Bon coup », « Erreur »… */ + readonly word: string; + /** La phrase du maître. */ + readonly phrase: string; + /** Le titre de la feuille d'explication. */ + readonly title: string; + /** Le contexte de la décision : « votre 3-1 d'ouverture »… */ + readonly context: string; +} + +/** Lit la source des phrases : les sections « ## clé » à partir de la + * première « ## mot.… », un gabarit par ligne « - … ». */ +export function parsePhrases(text: string): MasterPhrases { + const phrases = new Map(); + let key: string | null = null; + let started = false; + for (const raw of text.split("\n")) { + const line = raw.trim(); + const heading = /^## (\S+)$/.exec(line); + if (heading !== null) { + const name = heading[1] as string; + started ||= name.startsWith("mot."); + key = started ? name : null; + if (key !== null) { + phrases.set(key, []); + } + } else if (key !== null && line.startsWith("- ")) { + phrases.get(key)?.push(line.slice(2).trim()); + } + } + return phrases; +} + +/** Les clés que la source doit contenir (vérifié par les tests). */ +export const PHRASE_KEYS: readonly string[] = [ + "mot.coup.bon", + "mot.videau.bon", + "mot.douteux", + "mot.mauvais", + "mot.tres-mauvais", + "coup.meilleur", + "coup.seul", + "coup.proche", + "coup.douteux", + "coup.mauvais", + "coup.tres-mauvais", + "fait.frappe", + "fait.frappe-plusieurs", + "fait.point", + "fait.frappe-et-point", + "fait.maitre-frappe", + "fait.maitre-point", + "fait.joueur-frappe", + ...["bon", "proche", "autre"].flatMap((group) => + ["double", "pas-de-double", "prendre", "passer"].map((a) => `videau.${group}.${a}`), + ), + "titre.coup.meme", + "titre.coup.autre", + "titre.videau.meme", + ...["double", "pas-de-double", "prendre", "passer"].map((a) => `titre.videau.${a}`), + "contexte.coup", + "contexte.coup.ouverture", + "contexte.videau.double", + "contexte.videau.pas-de-double", + "contexte.videau.reponse", +]; + +/** Ce que le maître dit d'un conseil du moteur. */ +export function masterWords(phrases: MasterPhrases, advice: Advice): MasterWords { + return advice.kind === "move" ? moveWords(phrases, advice) : cubeWords(phrases, advice); +} + +/** La ligne du maître telle qu'elle s'affiche : « Erreur. J'aurais… ». */ +export function masterLine(words: MasterWords): string { + return `${words.word}. ${words.phrase}`; +} + +const ACTION_KEYS: Readonly> = { + double: "double", + noDouble: "pas-de-double", + take: "prendre", + pass: "passer", +}; + +function wordKey(verdict: Verdict, kind: Advice["kind"]): string { + switch (verdict) { + case "good": + return kind === "move" ? "mot.coup.bon" : "mot.videau.bon"; + case "doubtful": + return "mot.douteux"; + case "bad": + return "mot.mauvais"; + case "veryBad": + return "mot.tres-mauvais"; + } +} + +function moveWords(phrases: MasterPhrases, advice: MoveAdvice): MasterWords { + const verdict = verdictOf(advice.skill); + const word = pick(phrases, wordKey(verdict, "move"), advice.eventId); + const values = { + best: advice.best.notation, + played: advice.played.notation, + rank: String(advice.rank), + count: String(advice.moveCount), + roll: `${advice.roll[0]}-${advice.roll[1]}`, + }; + const fill = (key: string, extra: Readonly> = {}) => + fillIn(pick(phrases, key, advice.eventId), { ...values, ...extra }); + let base: string; + let fact: string | null; + if (advice.moveCount <= 1) { + base = fill("coup.seul"); + fact = null; + } else if (advice.rank === 1) { + base = fill("coup.meilleur"); + fact = selfFact(phrases, advice.eventId, advice.played.notation, advice.playedFacts); + } else { + const key = { + good: "coup.proche", + doubtful: "coup.douteux", + bad: "coup.mauvais", + veryBad: "coup.tres-mauvais", + }[verdict]; + base = fill(key); + fact = differenceFact(phrases, advice); + } + const withFact = fact === null ? base : `${base} ${fact}`; + const phrase = `${word}. ${withFact}`.length < LINE_LIMIT ? withFact : base; + return { + verdict, + word, + phrase, + title: fill(advice.rank === 1 ? "titre.coup.meme" : "titre.coup.autre"), + context: fill(advice.opening ? "contexte.coup.ouverture" : "contexte.coup"), + }; +} + +/** Ce que fait le coup lui-même : pions frappés, points faits. */ +function selfFact( + phrases: MasterPhrases, + id: string, + move: string, + facts: MoveFacts, +): string | null { + const values = { move, points: pointsText(facts.madePoints), hits: String(facts.hits) }; + const made = facts.madePoints.length > 0; + if (facts.hits === 1 && made) { + return fillIn(pick(phrases, "fait.frappe-et-point", id), values); + } + if (facts.hits > 1) { + return fillIn(pick(phrases, "fait.frappe-plusieurs", id), values); + } + if (facts.hits === 1) { + return fillIn(pick(phrases, "fait.frappe", id), values); + } + return made ? fillIn(pick(phrases, "fait.point", id), values) : null; +} + +/** Un fait qui distingue le coup du maître du coup joué, s'il y en a un. */ +function differenceFact(phrases: MasterPhrases, advice: MoveAdvice): string | null { + const best = advice.bestFacts; + const played = advice.playedFacts; + if (best.hits > 0 && played.hits === 0) { + return pick(phrases, "fait.maitre-frappe", advice.eventId); + } + const onlyBest = best.madePoints.filter((p) => !played.madePoints.includes(p)); + if (onlyBest.length > 0) { + return fillIn(pick(phrases, "fait.maitre-point", advice.eventId), { + points: pointsText(onlyBest), + }); + } + if (played.hits > 0 && best.hits === 0) { + return pick(phrases, "fait.joueur-frappe", advice.eventId); + } + return null; +} + +function cubeWords(phrases: MasterPhrases, advice: CubeAdvice): MasterWords { + const verdict = verdictOf(advice.skill); + const master = masterCubeAction(advice); + const same = master === advice.action; + const group = verdict !== "good" ? "autre" : same ? "bon" : "proche"; + const shown = group === "bon" ? advice.action : master; + const context = + advice.action === "take" || advice.action === "pass" + ? "contexte.videau.reponse" + : `contexte.videau.${ACTION_KEYS[advice.action]}`; + return { + verdict, + word: pick(phrases, wordKey(verdict, "cube"), advice.eventId), + phrase: pick(phrases, `videau.${group}.${ACTION_KEYS[shown]}`, advice.eventId), + title: pick( + phrases, + same ? "titre.videau.meme" : `titre.videau.${ACTION_KEYS[master]}`, + advice.eventId, + ), + context: pick(phrases, context, advice.eventId), + }; +} + +/** « votre point 5 », « vos points 5 et 7 », « vos points 4, 5 et 7 ». */ +function pointsText(points: readonly number[]): string { + const sorted = [...points].sort((a, b) => a - b).map(String); + if (sorted.length <= 1) { + return `votre point ${sorted[0] ?? ""}`; + } + return `vos points ${sorted.slice(0, -1).join(", ")} et ${sorted.at(-1)}`; +} + +/** Un gabarit de la section key ; plusieurs tournent selon l'identifiant + * de l'événement. Erreur si la section manque. */ +function pick(phrases: MasterPhrases, key: string, id: string): string { + const choices = phrases.get(key) ?? []; + if (choices.length === 0) { + throw new Error(`phrases du maître : section « ${key} » absente`); + } + const n = Number.parseInt(id, 10); + // Les identifiants des coups du joueur vont souvent de quatre en quatre : + // un brassage (hachage de Knuth, bits de poids fort) les fait tourner. + const index = (Math.imul(Number.isNaN(n) ? 0 : n, 2654435761) >>> 16) % choices.length; + return choices[index] as string; +} + +/** Remplace les {repères} ; erreur sur un repère inconnu. */ +function fillIn(template: string, values: Readonly>): string { + return template.replace(/\{([a-z]+)\}/g, (_, name: string) => { + const value = values[name]; + if (value === undefined) { + throw new Error(`phrases du maître : repère {${name}} inconnu`); + } + return value; + }); +} diff --git a/app/src/domain/game/advice.ts b/app/src/domain/game/advice.ts new file mode 100644 index 0000000..772c55b --- /dev/null +++ b/app/src/domain/game/advice.ts @@ -0,0 +1,124 @@ +// Le conseil du maître sur une décision du joueur : ce que le moteur en dit, +// rattaché à l'événement de la décision dans le journal (étape 1d). +// +// 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 (analyse du coup, classement des coups, analyse de la décision de +// videau, plateaux décodés). Le calcul est dans domain/analysis/ ; le +// stockage, dans le journal (events.ts, champ advice). Le format est +// durable (ADR 0007) : chaque conseil porte sa version (ADVICE_VERSION). + +import type { CubeAction, CubeEquities, Ply, Skill } from "./engine.js"; +import type { HalfMove, PositionId } from "./position.js"; +import type { Roll } from "./roll.js"; + +/** Version du format d'un conseil enregistré. Un conseil d'une autre + * version est ignoré à la reprise (il peut être recalculé). */ +export const ADVICE_VERSION = 1; + +/** + * Le conseil du maître sur une décision du joueur : un coup ou une décision + * de videau, avec l'appréciation du moteur. + * + * Glossaire : docs/glossaire/conseil-du-maitre.md + */ +export type Advice = MoveAdvice | CubeAdvice; + +/** Un coup, tel que le conseil le retient. */ +export interface AdvisedMove { + /** Notation de gnubg, par exemple « 8/5 6/5 ». */ + readonly notation: string; + readonly halfMoves: readonly HalfMove[]; + /** Équité (avec videau) du point de vue du joueur, et le niveau auquel + * le moteur l'a évaluée. */ + readonly equity: number; + readonly ply: Ply; +} + +/** Ce que l'on lit sur les plateaux décodés par le moteur, avant et après + * un coup : de quoi écrire une phrase sans rien inventer. */ +export interface MoveFacts { + /** Pions adverses envoyés sur la barre. */ + readonly hits: number; + /** Cases (numérotation du joueur) où il n'avait pas deux pions avant + * le coup et en a deux ou plus après : les points qu'il fait. */ + readonly madePoints: readonly number[]; +} + +export interface MoveAdvice { + readonly version: number; + readonly kind: "move"; + /** L'événement MovePlayed du coup. */ + readonly eventId: string; + /** Niveau de l'analyse, en plis. */ + readonly ply: Ply; + /** La position avant le coup, vue du joueur, et son jet. */ + readonly position: PositionId; + readonly roll: Roll; + /** Le premier coup de la partie (après le jet d'ouverture). */ + readonly opening: boolean; + /** Nombre de coups légaux et rang du coup joué (à partir de 1). */ + readonly moveCount: number; + readonly rank: number; + readonly played: AdvisedMove; + readonly best: AdvisedMove; + readonly playedFacts: MoveFacts; + readonly bestFacts: MoveFacts; + /** Les meilleurs coups du classement du moteur, du meilleur au moins + * bon (cinq au plus). */ + readonly ranking: readonly AdvisedMove[]; + /** Équité du coup joué moins celle du meilleur : 0 ou négative. */ + readonly equityLoss: number; + readonly skill: Skill; +} + +export interface CubeAdvice { + readonly version: number; + readonly kind: "cube"; + /** L'événement de la décision : DiceRolled (le joueur lance sans + * doubler), CubeOffered (il double), CubeTaken ou CubeDropped (il + * répond au double de l'ordinateur). */ + readonly eventId: string; + readonly ply: Ply; + /** La décision du joueur. */ + readonly action: CubeAction; + /** La position, vue de celui qui double ou non (l'ordinateur quand le + * joueur prend ou passe). */ + readonly position: PositionId; + /** Équités de celui qui double ou non, pour un videau de 1. */ + readonly equities: CubeEquities; + /** Équité perdue par le joueur : 0 ou négative. */ + readonly equityLoss: number; + readonly skill: Skill; +} + +/** L'appréciation dite au joueur : celle du moteur, « rien à signaler » + * devenant « bon ». */ +export type Verdict = "good" | "doubtful" | "bad" | "veryBad"; + +export function verdictOf(skill: Skill): Verdict { + return skill === "none" ? "good" : skill; +} + +/** L'autre choix d'une décision de videau. */ +export function otherCubeAction(action: CubeAction): CubeAction { + switch (action) { + case "double": + return "noDouble"; + case "noDouble": + return "double"; + case "take": + return "pass"; + case "pass": + return "take"; + } +} + +/** La décision que le maître aurait prise : celle du joueur s'il n'a perdu + * aucune équité, l'autre sinon (une décision de videau n'a que deux + * choix). */ +export function masterCubeAction(advice: CubeAdvice): CubeAction { + return advice.equityLoss < 0 ? otherCubeAction(advice.action) : advice.action; +} diff --git a/app/src/domain/game/engine.ts b/app/src/domain/game/engine.ts index 1a19a7e..9df4fe0 100644 --- a/app/src/domain/game/engine.ts +++ b/app/src/domain/game/engine.ts @@ -80,6 +80,12 @@ export interface Engine { ply: Ply, ): Promise; + /** Les coups légaux d'un jet classés du meilleur au moins bon, au + * niveau ply, comme la commande « hint » de gnubg (les coups écartés + * par ses filtres gardent l'évaluation d'un niveau inférieur, voir leur + * ply). */ + rankMoves(position: PositionId, cube: EngineCube, roll: Roll, 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). */ diff --git a/app/src/domain/game/events.ts b/app/src/domain/game/events.ts index f4f44f4..9f66ac4 100644 --- a/app/src/domain/game/events.ts +++ b/app/src/domain/game/events.ts @@ -12,6 +12,7 @@ // Chaque événement contient tout ce qu'il faut pour rejouer la partie sans // le moteur : l'état se recalcule par une simple boucle (state.ts). +import { ADVICE_VERSION, type Advice } from "./advice.js"; import type { Cube } from "./cube.js"; import type { Level, ResultKind } from "./engine.js"; import type { Match, MatchAfterGame } from "./match.js"; @@ -38,7 +39,8 @@ const VERSION_1_LEVEL: Level = "2ply"; * une partie de la version 1 étant une partie libre. Un événement d'une * version inconnue est laissé tel quel (Game.resume le refuse). */ -export function migrateJournal(journal: GameJournal): GameJournal { +export function migrateJournal(saved: GameJournal): GameJournal { + const journal = keepKnownAdvice(saved); if (journal.events.every((event) => event.version === EVENT_VERSION)) { return journal; } @@ -55,7 +57,34 @@ export function migrateJournal(journal: GameJournal): GameJournal { } return Object.freeze({ ...event, version: EVENT_VERSION }); }); - return { events, head: journal.head }; + return { ...journal, events }; +} + +/** + * Les conseils du maître d'un journal enregistré : seuls restent ceux de la + * version courante (ADVICE_VERSION), rattachés à un événement du journal. + * Les autres sont oubliés : ce ne sont que des analyses du moteur, qui + * peuvent être refaites. Un journal sans conseils (enregistré avant l'étape + * 1d) reste tel quel. + */ +function keepKnownAdvice(journal: GameJournal): GameJournal { + const advice = journal.advice; + if (advice === undefined) { + return journal; + } + const ids = new Set(journal.events.map((event) => event.id)); + const kept = Object.entries(advice).filter( + ([id, a]) => + typeof a === "object" && + a !== null && + a.version === ADVICE_VERSION && + a.eventId === id && + ids.has(id), + ); + if (kept.length === Object.keys(advice).length) { + return journal; + } + return { ...journal, advice: Object.fromEntries(kept) }; } interface EventBase { @@ -150,10 +179,14 @@ export type NewGameEvent = WithoutBase; type WithoutBase = E extends unknown ? Omit : never; /** Tous les événements d'une partie, branches comprises, et l'événement - * courant (head) : la partie en est là. */ + * 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. */ export interface GameJournal { readonly events: readonly GameEvent[]; readonly head: string | null; + readonly advice?: Readonly>; } export const EMPTY_JOURNAL: GameJournal = { events: [], head: null }; @@ -166,7 +199,16 @@ export function appendEvent(journal: GameJournal, event: NewGameEvent): GameJour parent: journal.head, version: EVENT_VERSION, }) as GameEvent; - return { events: [...journal.events, added], head: added.id }; + return { ...journal, events: [...journal.events, added], head: added.id }; +} + +/** Rattache un conseil du maître à son événement (il remplace un conseil + * déjà rattaché). Erreur si l'événement n'est pas dans le journal. */ +export function withAdvice(journal: GameJournal, advice: Advice): GameJournal { + if (!journal.events.some((event) => event.id === advice.eventId)) { + throw new Error(`événement introuvable : ${advice.eventId}`); + } + return { ...journal, advice: { ...journal.advice, [advice.eventId]: advice } }; } /** Les événements du début jusqu'à l'événement courant, dans l'ordre. */ @@ -190,5 +232,5 @@ export function goBackTo(journal: GameJournal, id: string): GameJournal { if (!journal.events.some((event) => event.id === id)) { throw new Error(`événement introuvable : ${id}`); } - return { events: journal.events, head: id }; + return { ...journal, head: id }; } diff --git a/app/src/domain/game/game.ts b/app/src/domain/game/game.ts index 165749e..b5102ce 100644 --- a/app/src/domain/game/game.ts +++ b/app/src/domain/game/game.ts @@ -9,6 +9,7 @@ // l'ordinateur, videau (Crawford compris), fin de partie, points et score du // match. +import type { Advice } from "./advice.js"; import { type Branch, branches, switchToBranch } from "./branches.js"; import { CENTERED_CUBE, type Cube } from "./cube.js"; import type { BoardSide, Engine, EngineCube, Level, Ply, ResultKind } from "./engine.js"; @@ -22,6 +23,7 @@ import { goBackTo, migrateJournal, type NewGameEvent, + withAdvice, } from "./events.js"; import { type Match, nextGameOf } from "./match.js"; import { BAR, type HalfMove, OFF, type PositionId, START_POSITION } from "./position.js"; @@ -216,36 +218,61 @@ export class Game { * (checkHalfMoves, puis board) : aucune règle n'est rejouée ici. */ async lastMoveSteps(): Promise { - const events = this.events; - const index = events.findLastIndex((e) => e.type === "MovePlayed"); - const played = events[index]; - if (played?.type !== "MovePlayed") { - return null; - } - // L'état juste avant le coup : la position vue de celui qui joue, et - // son jet. - const before = replay(events.slice(0, index)); - const roll = rollToPlay(before); + const played = this.events.findLast((e) => e.type === "MovePlayed"); + return played?.type === "MovePlayed" + ? this.stepsBefore(played.id, played.side, played.halfMoves) + : null; + } + + /** + * Un coup de side joué demi-coup par demi-coup à partir de la position + * d'avant l'événement eventId (le coup réellement joué, ou un autre coup + * du même jet : celui du maître). Les plateaux viennent du moteur ; + * erreur s'il refuse un demi-coup. Sans demi-coup, boards ne contient + * que la position d'avant l'événement. + */ + async stepsBefore( + eventId: string, + side: Side, + halfMoves: readonly HalfMove[], + ): Promise { + const line = currentLine({ ...this.#journal, head: eventId }); + // L'état juste avant l'événement : la position vue de celui qui a la + // main, et son jet. + const before = replay(line.slice(0, -1)); const positions = [before.position]; - for (let i = 1; i <= played.halfMoves.length; i++) { + for (let i = 1; i <= halfMoves.length; i++) { const check = await this.#engine.checkHalfMoves( before.position, - roll, - played.halfMoves.slice(0, i), + rollToPlay(before), + halfMoves.slice(0, i), ); if (check.position === null) { - throw new Error("coup enregistré refusé par le moteur"); + throw new Error("coup refusé par le moteur"); } positions.push(check.position); } + const onRoll = before.turn ?? side; const boards = await Promise.all( - positions.map((p) => this.#sideBoards(p, played.side, before)), + positions.map((p) => this.#sideBoards(p, onRoll, before)), ); - const opponent = otherSide(played.side); - const hits = played.halfMoves.map( + const opponent = otherSide(side); + const hits = halfMoves.map( (_, i) => (boards[i + 1]?.[opponent].bar ?? 0) > (boards[i]?.[opponent].bar ?? 0), ); - return { side: played.side, halfMoves: played.halfMoves, boards, hits }; + return { side, halfMoves, boards, hits }; + } + + /** Les conseils du maître déjà rattachés aux décisions du joueur, par + * identifiant d'événement. */ + get advice(): Readonly> { + return this.#journal.advice ?? {}; + } + + /** Rattache le conseil du maître à sa décision dans le journal (il sera + * enregistré avec la partie). Erreur si l'événement n'existe pas. */ + recordAdvice(advice: Advice): void { + this.#journal = withAdvice(this.#journal, advice); } /** diff --git a/app/src/engine/wasm-engine.ts b/app/src/engine/wasm-engine.ts index c7e26fe..b6fbdc2 100644 --- a/app/src/engine/wasm-engine.ts +++ b/app/src/engine/wasm-engine.ts @@ -195,6 +195,14 @@ export function createWasmEngine(run: RunCommand): Engine { }; }, + async rankMoves(position: PositionId, cube: EngineCube, roll: Roll, ply: Ply) { + const args = ["--ply", String(ply), "moves", position, await matchId(cube)]; + const answer = (await run([...args, diceText(roll)])) as { + moves: CliEvaluatedMove[]; + }; + return answer.moves.map(toEvaluatedMove); + }, + async analyseCube( position: PositionId, cube: EngineCube, diff --git a/app/tests/advice.test.ts b/app/tests/advice.test.ts new file mode 100644 index 0000000..e065f37 --- /dev/null +++ b/app/tests/advice.test.ts @@ -0,0 +1,224 @@ +// Le conseil du maître (étape 1d) : analyses du moteur sur des décisions +// connues, phrases rendues sur ces analyses, conseil rattaché au journal et +// gardé à l'enregistrement. +// +// 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 { + LINE_LIMIT, + masterLine, + masterWords, + PHRASE_KEYS, +} from "../src/domain/analysis/phrases.js"; +import { ADVICE_VERSION, type Advice, type MoveAdvice } from "../src/domain/game/advice.js"; +import { Game } from "../src/domain/game/game.js"; +import { createGameLibrary } from "../src/storage/game-library.js"; +import { createKeyValueBackend } from "../src/storage/key-value-backend.js"; +import { memoryStorage } from "../src/storage/open-library.js"; +import { + adviceOn, + loadPhrases, + playerAnswers, + playerDoubles, + playerRollsWithoutDoubling, + playOpening, +} from "./support/advice.js"; +import { loadNodeEngine } from "./support/node-engine.js"; + +const phrases = loadPhrases(); + +test("la source des phrases a toutes ses sections, sans point d'exclamation", () => { + for (const key of PHRASE_KEYS) { + assert.ok((phrases.get(key)?.length ?? 0) > 0, `section ${key} absente ou vide`); + } + for (const [key, templates] of phrases) { + assert.ok(PHRASE_KEYS.includes(key), `section ${key} inconnue`); + for (const template of templates) { + assert.ok(!template.includes("!"), `« ! » dans ${key} : ${template}`); + } + } +}); + +// Des décisions réelles, analysées par le moteur à 2 plis. Les coups +// d'ouverture et leurs écarts viennent du classement de gnubg. +const cases: { name: string; play: () => Promise; word: string }[] = [ + { + name: "3-1 joué 8/5 6/5 : le meilleur coup", + play: async () => adviceOn(await loadNodeEngine(), await playOpening(await loadNodeEngine(), [3, 1], "8/5 6/5")), + word: "Bon coup", + }, + { + name: "2-1 joué 13/11 6/5 : tout près du meilleur", + play: async () => adviceOn(await loadNodeEngine(), await playOpening(await loadNodeEngine(), [2, 1], "13/11 6/5")), + word: "Bon coup", + }, + { + name: "2-1 joué 13/10 : imprécision", + play: async () => adviceOn(await loadNodeEngine(), await playOpening(await loadNodeEngine(), [2, 1], "13/11 11/10")), + word: "Imprécision", + }, + { + name: "2-1 joué 24/22 6/5 : erreur", + play: async () => adviceOn(await loadNodeEngine(), await playOpening(await loadNodeEngine(), [2, 1], "24/22 6/5")), + word: "Erreur", + }, + { + name: "3-1 joué 24/23 13/10 : grosse erreur", + play: async () => adviceOn(await loadNodeEngine(), await playOpening(await loadNodeEngine(), [3, 1], "24/23 13/10")), + word: "Grosse erreur", + }, + { + name: "double dans une position de double et prise", + play: async () => adviceOn(await loadNodeEngine(), await playerDoubles(await loadNodeEngine())), + word: "Bonne décision", + }, + { + name: "lancer sans doubler dans une position de double", + play: async () => adviceOn(await loadNodeEngine(), await playerRollsWithoutDoubling(await loadNodeEngine())), + word: "Erreur", + }, + { + name: "prendre un double juste", + play: async () => adviceOn(await loadNodeEngine(), await playerAnswers(await loadNodeEngine(), true)), + word: "Bonne décision", + }, + { + name: "passer un double qu'il fallait prendre", + play: async () => adviceOn(await loadNodeEngine(), await playerAnswers(await loadNodeEngine(), false)), + word: "Erreur", + }, +]; + +const analysed: Advice[] = []; + +for (const c of cases) { + test(`conseil : ${c.name}`, async () => { + const advice = await c.play(); + analysed.push(advice); + const words = masterWords(phrases, advice); + assert.equal(words.word, c.word, JSON.stringify(advice)); + // Chaque gabarit tourne avec l'identifiant de l'événement : la ligne + // est rendue pour plusieurs identifiants. + for (let id = 1; id <= 40; id++) { + const line = masterLine(masterWords(phrases, { ...advice, eventId: String(id) })); + assert.ok(line.length < LINE_LIMIT, `trop long (${line.length}) : ${line}`); + assert.ok(!line.includes("!"), line); + assert.ok(!/[{}]/.test(line), `repère non rempli : ${line}`); + } + }); +} + +test("le coup du maître est nommé, et le fait qui distingue les coups vient du plateau", async () => { + const engine = await loadNodeEngine(); + const best = (await adviceOn(engine, await playOpening(engine, [3, 1], "8/5 6/5"))) as MoveAdvice; + assert.equal(best.kind, "move"); + assert.equal(best.version, ADVICE_VERSION); + assert.equal(best.rank, 1); + assert.equal(best.opening, true); + assert.deepEqual(best.playedFacts, { hits: 0, madePoints: [5] }); + assert.equal(best.ranking[0]?.notation, "8/5 6/5"); + const words = masterWords(phrases, best); + assert.equal(words.title, "C'est aussi le coup que j'aurais joué"); + assert.equal(words.context, "votre 3-1 d'ouverture"); + + const worst = (await adviceOn( + engine, + await playOpening(engine, [3, 1], "24/23 13/10"), + )) as MoveAdvice; + assert.equal(worst.skill, "veryBad"); + assert.equal(worst.best.notation, "8/5 6/5"); + assert.deepEqual(worst.bestFacts.madePoints, [5]); + const said = masterWords(phrases, worst); + assert.equal(said.title, "J'aurais joué 8/5 6/5"); + assert.equal( + said.phrase, + `J'aurais joué 8/5 6/5. Votre 24/23 13/10 était le ${worst.rank}e choix sur 16. Mon coup fait votre point 5.`, + ); +}); + +test("décisions de videau : la décision du maître et le contexte", async () => { + const engine = await loadNodeEngine(); + const missed = await adviceOn(engine, await playerRollsWithoutDoubling(engine)); + const words = masterWords(phrases, missed); + assert.equal(words.phrase, "Ici, j'aurais doublé."); + assert.equal(words.title, "J'aurais doublé"); + assert.equal(words.context, "avant votre jet"); + const passed = masterWords(phrases, await adviceOn(engine, await playerAnswers(engine, false))); + assert.equal(passed.phrase, "Ici, j'aurais pris."); + assert.equal(passed.context, "le double de l'ordinateur"); +}); + +test("tous les gabarits, remplis avec les plus longues notations, restent sous la limite", () => { + const long = "bar/22* 13/9*(2) 6/off"; + const move = (equity: number) => ({ notation: long, halfMoves: [], equity, ply: 2 }); + for (const skill of ["none", "doubtful", "bad", "veryBad"] as const) { + for (const rank of [1, 2, 23]) { + for (const facts of [ + { hits: 0, madePoints: [] }, + { hits: 1, madePoints: [4, 5, 7] }, + { hits: 3, madePoints: [] }, + ]) { + const advice: MoveAdvice = { + version: ADVICE_VERSION, + kind: "move", + eventId: "7", + ply: 2, + position: "4HPwATDgc/ABMA", + roll: [6, 6], + opening: false, + moveCount: 87, + rank, + played: move(-0.2), + best: move(0), + playedFacts: rank === 1 ? facts : { hits: 0, madePoints: [] }, + bestFacts: facts, + ranking: [], + equityLoss: rank === 1 ? 0 : -0.2, + skill: rank === 1 ? "none" : skill, + }; + for (let id = 0; id < 20; id++) { + const line = masterLine(masterWords(phrases, { ...advice, eventId: String(id) })); + assert.ok(line.length < LINE_LIMIT, `trop long (${line.length}) : ${line}`); + } + } + } + } +}); + +test("le conseil est rattaché au journal et gardé à l'enregistrement", async () => { + const engine = await loadNodeEngine(); + const played = await playOpening(engine, [3, 1], "8/5 6/5"); + const advice = await adviceOn(engine, played); + played.game.recordAdvice(advice); + // Le tour de l'ordinateur, puis l'enregistrement : le conseil reste. + await played.game.playComputerTurn(); + assert.deepEqual(played.game.advice[played.eventId], advice); + const library = createGameLibrary(createKeyValueBackend(memoryStorage())); + await library.save("g", played.game.journal, { level: "2ply" }); + const loaded = await library.load("g"); + assert.ok(loaded !== null); + const resumed = Game.resume(engine, loaded.journal); + assert.deepEqual(resumed.advice[played.eventId], advice); + // Revenir en arrière garde le conseil, rattaché à son événement. + resumed.goBackTo(played.eventId); + assert.deepEqual(resumed.advice[played.eventId], advice); +}); + +test("migration : un journal sans conseil, ou avec un conseil d'une autre version", async () => { + const engine = await loadNodeEngine(); + const played = await playOpening(engine, [3, 1], "8/5 6/5"); + const { advice: _, ...withoutAdvice } = played.game.journal; + assert.deepEqual(Game.resume(engine, withoutAdvice).advice, {}); + const advice = await adviceOn(engine, played); + const future = { ...advice, version: ADVICE_VERSION + 1 }; + const orphan = { ...advice, eventId: "999" }; + const resumed = Game.resume(engine, { + ...played.game.journal, + advice: { [played.eventId]: future, "999": orphan }, + }); + assert.deepEqual(resumed.advice, {}); + assert.throws(() => resumed.recordAdvice(orphan), /introuvable/); +}); diff --git a/app/tests/game.test.ts b/app/tests/game.test.ts index a81f676..5a3dab9 100644 --- a/app/tests/game.test.ts +++ b/app/tests/game.test.ts @@ -73,6 +73,7 @@ function fakeEngine(overrides: Partial = {}): Engine { }; }, analyseMove: async () => null, + rankMoves: async () => [], analyseCube: async () => ({ analysed: false, equities: null, diff --git a/app/tests/support/advice.ts b/app/tests/support/advice.ts new file mode 100644 index 0000000..168a340 --- /dev/null +++ b/app/tests/support/advice.ts @@ -0,0 +1,108 @@ +// Des décisions du joueur dans des positions connues, jouées par le domaine +// sur le vrai moteur, puis analysées : de quoi tester le conseil du maître +// (tests unitaires et scénarios). +// +// Copyright (C) 2026 les auteurs d'Ustagammon. +// Licence : GPLv3 ou ultérieure (voir LICENSE). + +import { readFileSync } from "node:fs"; +import { analyseDecision } from "../../src/domain/analysis/analyse.js"; +import { type MasterPhrases, parsePhrases } from "../../src/domain/analysis/phrases.js"; +import type { Advice } from "../../src/domain/game/advice.js"; +import type { Engine } from "../../src/domain/game/engine.js"; +import { Game } from "../../src/domain/game/game.js"; +import { parseHalfMoves, START_POSITION } from "../../src/domain/game/position.js"; +import { fixedDice } from "../../src/domain/game/roll.js"; + +const root = new URL("../../../../", import.meta.url); + +/** Les phrases du maître, lues dans leur source. */ +export function loadPhrases(): MasterPhrases { + return parsePhrases(readFileSync(new URL("docs/maitre/phrases.md", root), "utf8")); +} + +/** Une position de double et prise (videau au centre), vue de celui qui a + * la main : doubler y est juste, et prendre aussi (tests du moteur, 1a). */ +export const DOUBLE_TAKE_POSITION = "6O5uAAC4uzsAAA"; + +/** Le joueur, au trait à la position de départ, lance dice et joue move ; + * la partie et l'événement du coup. */ +export async function playOpening( + engine: Engine, + dice: readonly [number, number], + move: string, +): Promise<{ game: Game; eventId: string }> { + const game = Game.start(engine, { + setup: { position: START_POSITION, firstTurn: "player" }, + // Le jet du joueur, puis celui de l'ordinateur s'il joue ensuite. + dice: fixedDice([...dice, 6, 5]), + }); + await game.roll(); + for (const halfMove of parseHalfMoves(move)) { + if (!(await game.playHalfMove(halfMove))) { + throw new Error(`demi-coup refusé : ${move}`); + } + } + await game.confirmMove(); + const played = game.events.findLast((e) => e.type === "MovePlayed"); + return { game, eventId: played?.id ?? "" }; +} + +/** Le joueur, au trait dans la position de double et prise, double ; + * l'ordinateur répond. */ +export async function playerDoubles(engine: Engine): Promise<{ game: Game; eventId: string }> { + const game = Game.start(engine, { + setup: { position: DOUBLE_TAKE_POSITION, firstTurn: "player" }, + dice: fixedDice([]), + }); + await game.double(); + const offered = game.events.find((e) => e.type === "CubeOffered"); + return { game, eventId: offered?.id ?? "" }; +} + +/** Le joueur, au trait dans la position de double et prise, lance sans + * doubler. */ +export async function playerRollsWithoutDoubling( + engine: Engine, +): Promise<{ game: Game; eventId: string }> { + const game = Game.start(engine, { + setup: { position: DOUBLE_TAKE_POSITION, firstTurn: "player" }, + dice: fixedDice([6, 5]), + }); + await game.roll(); + const rolled = game.events.findLast((e) => e.type === "DiceRolled"); + return { game, eventId: rolled?.id ?? "" }; +} + +/** L'ordinateur, au trait dans la position de double et prise, double ; le + * joueur prend (take) ou passe. */ +export async function playerAnswers( + engine: Engine, + take: boolean, +): Promise<{ game: Game; eventId: string }> { + const game = Game.start(engine, { + setup: { position: DOUBLE_TAKE_POSITION, firstTurn: "computer" }, + level: "2ply", + dice: fixedDice([]), + }); + await game.playComputerTurn(); + if (take) { + game.take(); + } else { + await game.drop(); + } + const answer = game.events.find((e) => e.type === (take ? "CubeTaken" : "CubeDropped")); + return { game, eventId: answer?.id ?? "" }; +} + +/** Le conseil du moteur sur une décision ; erreur s'il n'y en a pas. */ +export async function adviceOn( + engine: Engine, + played: { game: Game; eventId: string }, +): Promise { + const advice = await analyseDecision(engine, played.game.journal, played.eventId); + if (advice === null) { + throw new Error(`aucun conseil pour l'événement ${played.eventId}`); + } + return advice; +} diff --git a/docs/glossaire/README.md b/docs/glossaire/README.md index 7d46bc5..a4e3e45 100644 --- a/docs/glossaire/README.md +++ b/docs/glossaire/README.md @@ -43,6 +43,7 @@ exportée. `make test` vérifie le lien dans les deux sens : chaque fiche avec | [Camp](camp.md) | Home board | | [Chance](chance.md) | Luck | | [Compte de points](compte-de-points.md) | Pip count | +| [Conseil du maître](conseil-du-maitre.md) | Advice | | [Coup](coup.md) | Move | | [Demi-coup](demi-coup.md) | Half-move | | [Course](course.md) | Race | diff --git a/docs/glossaire/conseil-du-maitre.md b/docs/glossaire/conseil-du-maitre.md new file mode 100644 index 0000000..5db8d32 --- /dev/null +++ b/docs/glossaire/conseil-du-maitre.md @@ -0,0 +1,18 @@ +--- +term: Conseil du maître +english: Advice +code: Advice +--- +# Conseil du maître + +**En bref** : après chacun de vos coups et chacune de vos décisions de videau, une ligne qui donne l'appréciation du moteur et ce que le maître aurait joué. + +Le maître ne parle jamais avant votre coup : il commente ce que vous venez de +jouer, avec un mot (bon coup, imprécision, erreur, grosse erreur) choisi +d'après l'[équité](equite.md) perdue par rapport au meilleur choix (voir +[Appréciation](appreciation.md)). Toucher la ligne ouvre l'explication : votre +coup et celui du maître, joués sur le plateau, et les chiffres du +[moteur](moteur.md) si vous les demandez. Le conseil se coupe dans le menu ou +sur l'écran « Nouvelle partie ». + +Voir aussi : [Appréciation](appreciation.md), [Coup](coup.md), [Videau](videau.md) diff --git a/docs/maitre/phrases.md b/docs/maitre/phrases.md new file mode 100644 index 0000000..2c84abe --- /dev/null +++ b/docs/maitre/phrases.md @@ -0,0 +1,216 @@ +# Les phrases du maître + +Les mots et les phrases du conseil du maître (étape 1d), en une seule source +(ADR 0008) : `make site` copie ce dossier dans le site publié +(`docs/maitre/`), l'application le lit à l'exécution +(`app/src/domain/analysis/phrases.ts`), et `make test` rend chaque gabarit +sur de vraies analyses du moteur (`app/tests/advice.test.ts`). + +Texte sous licence **CC BY-SA 4.0**. Version 1, rédigée par Claude Code +d'après la charte (`docs/identite/charte/ecrans-etape-1.md`, « Les phrases du +maître »), **à relire** par la session « identité » puis par Pierre. + +## Règles + +- Le maître parle à la première personne et vouvoie : « J'aurais joué… », + jamais « Vous auriez dû… ». +- Il ne cite que des faits donnés par le moteur : le coup recommandé, le rang + du coup joué, le nombre de coups possibles, les pions frappés et les points + faits (lus sur les plateaux décodés par le moteur), la décision de videau + qu'il préfère. Aucune explication stratégique. +- La ligne (le mot et la phrase) fait moins de 140 caractères ; pas de point + d'exclamation, pas d'émoji, pas de superlatif. +- Quand une section a plusieurs gabarits, ils tournent d'un coup à l'autre + (l'identifiant de l'événement choisit), pour ne pas lasser. + +## Format + +Une section `## clé` par emploi, suivie d'un gabarit par ligne (`- …`). Les +mots entre accolades sont remplis avec les données du moteur : + +| Repère | Ce qu'il devient | +| --- | --- | +| `{best}` | le coup du maître, en notation de gnubg (« 8/5 6/5 ») | +| `{played}` | votre coup, même notation | +| `{move}` | le coup dont parle le fait (le vôtre ou celui du maître) | +| `{rank}` | le rang de votre coup dans le classement du moteur (2, 3…) | +| `{count}` | le nombre de coups possibles avec ce jet | +| `{points}` | « votre point 5 » ou « vos points 5 et 7 » | +| `{roll}` | le jet, « 3-1 » | +| `{hits}` | le nombre de pions frappés, en chiffres | + +Un fait (sections `fait.…`) s'ajoute à la phrase seulement si la ligne reste +sous 140 caractères. + +Tout ce qui précède la première section `## mot.…` n'est pas lu. + +## mot.coup.bon + +- Bon coup + +## mot.videau.bon + +- Bonne décision + +## mot.douteux + +- Imprécision + +## mot.mauvais + +- Erreur + +## mot.tres-mauvais + +- Grosse erreur + +## coup.meilleur + +- C'est le coup du maître. +- Exactement ce que j'aurais joué. +- Bien vu. +- C'est aussi mon choix. + +## coup.seul + +- C'était le seul coup possible. + +## coup.proche + +- Tout près : j'aurais joué {best}, votre coup était le {rank}e choix. + +## coup.douteux + +- J'aurais joué {best}. Votre coup était le {rank}e choix, tout près. + +## coup.mauvais + +- J'aurais joué {best}. Votre {played} était le {rank}e choix. + +## coup.tres-mauvais + +- J'aurais joué {best}. Votre {played} était le {rank}e choix sur {count}. + +## fait.frappe + +- {move} frappe un pion. + +## fait.frappe-plusieurs + +- {move} frappe {hits} pions. + +## fait.point + +- {move} fait {points}. + +## fait.frappe-et-point + +- {move} frappe un pion et fait {points}. + +## fait.maitre-frappe + +- Mon coup frappe un pion, le vôtre non. + +## fait.maitre-point + +- Mon coup fait {points}. + +## fait.joueur-frappe + +- Votre coup frappe un pion, le mien non. + +## videau.bon.double + +- Doubler était le bon choix. + +## videau.bon.pas-de-double + +- Ne pas doubler était le bon choix. + +## videau.bon.prendre + +- Prendre était le bon choix. + +## videau.bon.passer + +- Passer était le bon choix. + +## videau.proche.double + +- Les deux choix étaient très proches. J'aurais doublé. + +## videau.proche.pas-de-double + +- Les deux choix étaient très proches. Je n'aurais pas doublé. + +## videau.proche.prendre + +- Les deux choix étaient très proches. J'aurais pris. + +## videau.proche.passer + +- Les deux choix étaient très proches. J'aurais passé. + +## videau.autre.double + +- Ici, j'aurais doublé. + +## videau.autre.pas-de-double + +- Ici, je n'aurais pas doublé. + +## videau.autre.prendre + +- Ici, j'aurais pris. + +## videau.autre.passer + +- Ici, j'aurais passé. + +## titre.coup.meme + +- C'est aussi le coup que j'aurais joué + +## titre.coup.autre + +- J'aurais joué {best} + +## titre.videau.meme + +- C'est aussi ce que j'aurais fait + +## titre.videau.double + +- J'aurais doublé + +## titre.videau.pas-de-double + +- Je n'aurais pas doublé + +## titre.videau.prendre + +- J'aurais pris + +## titre.videau.passer + +- J'aurais passé + +## contexte.coup + +- votre {roll} + +## contexte.coup.ouverture + +- votre {roll} d'ouverture + +## contexte.videau.double + +- votre double + +## contexte.videau.pas-de-double + +- avant votre jet + +## contexte.videau.reponse + +- le double de l'ordinateur