// Les prompts pour une IA (étape 1f) : gabarits lus dans leur source // (docs/prompts/), remplis avec de vraies analyses du moteur. Aucun repère // vide ni inconnu, et les chiffres sont ceux que l'application affiche. // // Copyright (C) 2026 les auteurs d'Ustagammon. // Licence : GPLv3 ou ultérieure (voir LICENSE). import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; import { test } from "node:test"; import { missingAnalyses, runAnalysis, summarizeAnalysis, } from "../src/domain/analysis/game-analysis.js"; import { formatEquity, rankingRows } from "../src/domain/analysis/numbers.js"; import { diceWords, masterWords } 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 type { GameJournal } from "../src/domain/game/events.js"; import { Game } from "../src/domain/game/game.js"; import { COMPUTER_LEVELS } from "../src/domain/game/level.js"; import { START_POSITION } from "../src/domain/game/position.js"; import { boardDiagram, PUBLIC_SITE, positionAddress, positionBefore, positionNow, } from "../src/domain/prompts/position.js"; import { CUBE_ACTION_WORDS, CUBE_ROW_WORDS, conceptExample, conceptPrompt, decisionPrompt, exercisesPosition, exercisesPrompt, LEVEL_MIN_DECISIONS, LEVEL_NAMES, playerSentence, summaryPosition, summaryPrompt, } from "../src/domain/prompts/prompts.js"; import { fillPrompt, PROMPT_FILES, PROMPT_KINDS, type PromptTemplates, parsePromptTemplate, placeholders, } from "../src/domain/prompts/templates.js"; import { ADVICE_TEXTS } from "../src/ui/advice-texts.js"; import { MATCH_TEXTS } from "../src/ui/match-texts.js"; import { REVIEW_TEXTS, signed } from "../src/ui/review-texts.js"; import { parseRoute, routeHash } from "../src/ui/router.js"; import { adviceOn, loadPhrases, playerAnswers, playerDoubles, playerRollsWithoutDoubling, playOpening, } from "./support/advice.js"; import { loadNodeEngine } from "./support/node-engine.js"; import { playToTheEnd, seededDice } from "./support/play.js"; const root = new URL("../../../", import.meta.url); const phrases = loadPhrases(); /** Les gabarits, lus dans leur source. */ function loadTemplates(): PromptTemplates { const read = (file: string) => parsePromptTemplate(readFileSync(new URL(`docs/prompts/${file}`, root), "utf8")); return { move: read(PROMPT_FILES.move), cube: read(PROMPT_FILES.cube), summary: read(PROMPT_FILES.summary), concept: read(PROMPT_FILES.concept), exercises: read(PROMPT_FILES.exercises), }; } const templates = loadTemplates(); /** Ce que chaque prompt doit contenir (cadrage, « IA générative en V1 »). */ function assertComplete(prompt: string, position: { positionId: string; matchId: string }): void { for (const hole of ["{", "}", "inconnu", "undefined", "NaN", "null", " :"]) { assert.ok(!prompt.includes(hole), `« ${hole} » dans le prompt :\n${prompt}`); } assert.ok(prompt.startsWith("Tu es un coach de backgammon"), "rôle absent"); assert.match( prompt, /^Tu es un coach de backgammon, bienveillant et précis\. (Je débute au backgammon\.|Je suis un joueur de niveau [A-ZÉ]\S+\.)\n/, ); assert.ok(prompt.includes(`Position ID : ${position.positionId}`), "Position ID absent"); assert.ok(prompt.includes(`Match ID : ${position.matchId}`), "Match ID absent"); assert.ok(prompt.includes(`${PUBLIC_SITE}#/position/`), "lien absent"); assert.ok(prompt.includes("X : mes pions"), "diagramme absent"); assert.match(prompt, /GNU Backgammon \(analyse à \d plis?\)/); assert.ok(prompt.includes("ne les contredis pas"), "consigne absente"); assert.ok(prompt.includes("Réponds en français"), "langue absente"); } test("les cinq gabarits se lisent dans leur source, avec les repères du cadrage", () => { assert.equal(PROMPT_KINDS.length, 5); for (const kind of PROMPT_KINDS) { const names = placeholders(templates[kind]); for (const name of [ "joueur", "position_id", "match_id", "au_trait", "lien", "diagramme", "score", "videau", "niveau_eval", "langue", ]) { assert.ok(names.includes(name), `{${name}} absent du gabarit ${kind}`); } assert.ok(templates[kind].includes("ne les contredis pas"), `consigne absente : ${kind}`); } assert.throws(() => parsePromptTemplate("# Sans gabarit\n"), /Gabarit/); }); test("un repère inconnu ou vide est une erreur", () => { assert.equal(fillPrompt("a {b} c", { b: "x" }), "a x c"); assert.throws(() => fillPrompt("a {b}", {}), /inconnu/); assert.throws(() => fillPrompt("a {b}", { b: " " }), /vide/); }); test("les mots du prompt sont ceux de l'écran", () => { for (const level of COMPUTER_LEVELS) { assert.equal(LEVEL_NAMES[level], MATCH_TEXTS.levels[level].name); } assert.deepEqual(CUBE_ACTION_WORDS, REVIEW_TEXTS.cubeActions); assert.deepEqual(CUBE_ROW_WORDS, ADVICE_TEXTS.cubeRows); }); test("le niveau du joueur ne se dit qu'à partir de 20 décisions, sans son taux", () => { assert.equal(LEVEL_MIN_DECISIONS, 20); assert.equal(playerSentence(null), "Je débute au backgammon."); for (const level of COMPUTER_LEVELS) { assert.equal(playerSentence({ decisions: 19, level }), "Je débute au backgammon."); assert.equal( playerSentence({ decisions: 20, level }), `Je suis un joueur de niveau ${MATCH_TEXTS.levels[level].name}.`, ); } assert.equal( playerSentence({ decisions: 120, level: "advanced" }), "Je suis un joueur de niveau Avancé.", ); }); test("le diagramme recopie le plateau décodé par le moteur", async () => { const engine = await loadNodeEngine(); const start = await positionNow(engine, null); assert.equal(start.positionId, START_POSITION); const diagram = boardDiagram(start.sides); assert.ok(diagram.includes("Mes pions (X), case (nombre) : 24 (2), 13 (5), 8 (3), 6 (5).")); assert.ok( diagram.includes( "Pions de l'ordinateur (O), dans ma numérotation : 19 (5), 17 (3), 12 (5), 1 (2).", ), ); assert.ok(diagram.includes("Compte de points : moi 167, l'ordinateur 167.")); // La rangée du haut : 13 (X, 5 pions) à gauche, 24 (X, 2 pions) à // droite ; 19 (O) juste après la barre. const lines = diagram.split("\n"); assert.equal(lines[1], " 13 14 15 16 17 18 | 19 20 21 22 23 24"); assert.equal(lines[2], " X . . . O . | O . . . . X"); assert.equal(lines[3], " X O | O X"); assert.equal(lines[0], "```"); assert.equal(lines[lines.indexOf("```", 1) - 1], " 12 11 10 9 8 7 | 6 5 4 3 2 1"); }); test("le lien de la position est l'adresse publique de l'écran « position »", async () => { const engine = await loadNodeEngine(); const played = await playOpening(engine, [3, 1], "24/23 13/10"); const position = await positionBefore(engine, played.game.journal, played.eventId); const address = positionAddress(position); const hash = routeHash({ screen: "position", position: position.positionId, match: position.matchId, }); assert.equal(address, `${PUBLIC_SITE}${hash}`); assert.deepEqual(parseRoute(new URL(address).hash), { screen: "position", position: position.positionId, match: position.matchId, }); }); /** Les lignes du classement telles que la feuille du maître les affiche : * rang, coup, écart. */ function shownRanking(advice: Advice): string[] { if (advice.kind !== "move") { return []; } return rankingRows(advice).map((row) => `${row.move.notation}|${formatEquity(row.loss)}`); } test("prompt d'un coup : la position, les identifiants et le top 3 du moteur, mêmes chiffres", async () => { const engine = await loadNodeEngine(); for (const [dice, move] of [ [[3, 1], "24/23 13/10"], [[3, 1], "8/5 6/5"], [[2, 1], "13/11 6/5"], [[6, 5], "24/18 13/8"], ] as const) { const played = await playOpening(engine, dice, move); const advice = await adviceOn(engine, played); assert.equal(advice.kind, "move"); if (advice.kind !== "move") { continue; } // Le conseil, rattaché au journal comme dans l'application. played.game.recordAdvice(advice); const position = await positionBefore(engine, played.game.journal, played.eventId); assert.equal(position.positionId, advice.position); const prompt = decisionPrompt(templates, phrases, { journal: played.game.journal, advice, position, }); assertComplete(prompt, position); assert.ok(prompt.includes(`Dés : ${dice[0]}-${dice[1]}.`), prompt); assert.ok(prompt.includes(`J'ai joué ${advice.played.notation} : `), prompt); assert.ok(prompt.includes(`« ${masterWords(phrases, advice).word} »`), prompt); // Les trois premiers coups du moteur, et le vôtre, avec les écarts // de « Voir les chiffres du moteur ». const shown = shownRanking(advice); for (const row of rankingRows(advice).filter((r) => r.rank <= 3 || r.played)) { const line = `${row.rank}. ${row.move.notation}${row.played ? " (mon coup)" : ""} : équité ${formatEquity(row.move.equity)}, écart ${formatEquity(row.loss)}`; assert.ok(prompt.includes(line), `ligne absente : ${line}\n${prompt}`); assert.ok(shown.includes(`${row.move.notation}|${formatEquity(row.loss)}`)); } assert.ok(prompt.includes(`1. ${advice.best.notation}`), prompt); assert.match(prompt, /\(analyse à [12] plis?\)/); } }); /** Une équité écrite (« −0,212 ») proche d'une équité de gnubg de bureau : * à l'arrondi près, plus la tolérance des positions de référence (0,002 ; * tests/reference/README.md), le moteur WebAssembly ne calculant pas au * bit près comme gnubg de bureau (ADR 0005). */ function assertNear(text: string, value: number, message: string): void { const written = Number(text.replace("−", "-").replace("+", "").replace(",", ".")); assert.ok(Math.abs(written - value) <= 0.0005 + 0.002, `${text} ≠ ${value}\n${message}`); } /** Le niveau du titre du classement, et chaque ligne du classement : coup, * équité, écart, niveau propre (null : celui du titre). */ function rankingOfPrompt(prompt: string) { const title = /^Classement de GNU Backgammon \(analyse à (\d) plis?\)/m.exec(prompt); assert.ok(title, prompt); const lines = [ ...prompt.matchAll( /^(\d+)\. (.+?)( \(mon coup\))? : équité (\S+), écart (\S+?)(?:, évalué à (\d) plis?)?$/gm, ), ].map((m) => ({ rank: Number(m[1]), notation: m[2], mine: m[3] !== undefined, equity: m[4], loss: m[5], ply: m[6] === undefined ? null : Number(m[6]), })); return { ply: Number(title[1]), lines }; } test("prompt d'un coup : le niveau du titre est celui des lignes, les équités celles de l'analyse", async () => { const engine = await loadNodeEngine(); // gnubg de bureau, analyse de partie à 2 plis (tests/reference/analysis/). const reference = (id: string) => JSON.parse(readFileSync(new URL(`tests/reference/analysis/${id}.json`, root), "utf8")) .plies["2"]; for (const [dice, move, id] of [ // Erreur : gnubg réévalue à 2 plis le meilleur coup et le coup joué ; // les autres restent au niveau où ses filtres les ont laissés. [[3, 1], "24/23 13/10", "move-opening-31-error"], // Meilleur coup : un seul coup passe le filtre de 1 pli, gnubg // s'arrête là, tout le classement est à 1 pli. [[3, 1], "8/5 6/5", "move-opening-31-best"], [[6, 5], "13/8 13/7", "move-opening-65-error"], ] as const) { const played = await playOpening(engine, dice, move); const advice = await adviceOn(engine, played); assert.ok(advice.kind === "move"); played.game.recordAdvice(advice); const position = await positionBefore(engine, played.game.journal, played.eventId); const prompt = decisionPrompt(templates, phrases, { journal: played.game.journal, advice, position, }); const { ply, lines } = rankingOfPrompt(prompt); const expected = reference(id); // Le titre dit le niveau du meilleur coup, celui que gnubg de bureau // donne ; une ligne sans niveau propre est à ce niveau, une ligne // avec un niveau propre, à un niveau inférieur. assert.equal(ply, expected.ranking[0].ply, prompt); assert.equal(ply, advice.best.ply); assert.ok(lines.length >= 3, prompt); for (const line of lines) { const reference = expected.ranking.find( (m: { move: string }) => m.move === line.notation, ); assert.ok(reference, `${line.notation} absent du classement de gnubg`); assert.equal(line.ply ?? ply, reference.ply, `${line.notation}\n${prompt}`); assert.ok(line.ply === null || line.ply < ply, prompt); assertNear(line.equity, reference.equity, `${line.notation}\n${prompt}`); } // Les équités et l'écart du meilleur coup et du vôtre sont ceux de // l'analyse, que disent la feuille du maître et l'écran d'analyse. const best = lines[0]; const mine = lines.find((line) => line.mine); assert.ok(best && mine, prompt); assert.equal(best.notation, advice.best.notation); assert.equal(best.equity, formatEquity(advice.best.equity)); assert.equal(best.loss, formatEquity(0)); assert.equal(mine.notation, advice.played.notation); assert.equal(mine.rank, advice.rank); assert.equal(mine.equity, formatEquity(advice.played.equity)); assert.equal(mine.loss, formatEquity(advice.equityLoss)); assertNear(mine.loss, expected.equity_loss, prompt); assert.ok(shownRanking(advice).includes(`${mine.notation}|${mine.loss}`)); } }); test("prompt d'une décision de videau : les équités de la feuille du maître", async () => { const engine = await loadNodeEngine(); const cases = [ { play: playerDoubles, who: "moi", question: "doubler ou lancer" }, { play: playerRollsWithoutDoubling, who: "moi", question: "doubler ou lancer" }, { play: (e: Engine) => playerAnswers(e, true), who: "l'ordinateur", question: "prendre" }, { play: (e: Engine) => playerAnswers(e, false), who: "l'ordinateur", question: "prendre" }, ]; for (const { play, who, question } of cases) { const played = await play(engine); const advice = await adviceOn(engine, played); assert.equal(advice.kind, "cube"); if (advice.kind !== "cube") { continue; } played.game.recordAdvice(advice); const position = await positionBefore(engine, played.game.journal, played.eventId); assert.equal(position.positionId, advice.position); const prompt = decisionPrompt(templates, phrases, { journal: played.game.journal, advice, position, }); assertComplete(prompt, position); assert.ok(prompt.includes(`Il fallait choisir : ${question}`), prompt); assert.ok(prompt.includes(`(${who}), pour un videau de 1`), prompt); assert.ok(prompt.includes(`J'ai choisi de ${CUBE_ACTION_WORDS[advice.action]},`), prompt); for (const key of ["noDouble", "doubleTake", "doublePass"] as const) { const value = formatEquity(advice.equities[key]); assert.match( prompt, new RegExp( `- ${ADVICE_TEXTS.cubeRows[key]}( \\(ma décision\\))? : ${value.replace("+", "\\+")}\\n`, ), ); } assert.ok(prompt.includes(`Écart de ma décision : ${formatEquity(advice.equityLoss)}.`)); } }); /** Toutes les analyses qui manquent, une à une, comme l'application en * arrière-plan. */ 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"); } /** Une course courte jouée jusqu'au bout et analysée (le joueur prend le * choice-ième coup légal). */ async function analysedRace(engine: Engine, seed: number, choice: number): Promise { const game = Game.start(engine, { setup: { position: "trsXAADs7mwAAA", firstTurn: "player" }, level: "intermediate", dice: seededDice(seed), }); await playToTheEnd(game, engine, choice); return analyseAll(engine, game.journal); } test("prompt du bilan : résultat, décomptes, taux et chance de l'écran, erreurs", async () => { const engine = await loadNodeEngine(); const journal = await analysedRace(engine, 3, 3); const summary = summarizeAnalysis(journal); assert.ok(summary.toReview !== null, "la partie devrait avoir une erreur"); const position = await summaryPosition(engine, journal); const prompt = summaryPrompt(templates, phrases, { journal, position }); assertComplete(prompt, position); const { player, computer, counts } = summary; // Les chiffres de « Voir les chiffres du moteur » de l'analyse, et les // trois cases du bilan. assert.ok( prompt.includes( `Mon erreur par décision : ${signed(-player.errorRate.perDecision)} sur ${player.errorRate.decisions} décision`, ), ); assert.ok( prompt.includes(`soit le niveau ${MATCH_TEXTS.levels[player.errorRate.level].name} ;`), ); assert.ok( prompt.includes(`celle de l'ordinateur : ${signed(-computer.errorRate.perDecision)} sur`), ); assert.ok( prompt.includes( `La chance de mes dés par tour : ${signed(player.luck.perRoll)} (${diceWords(phrases, player.luck.rating)})`, ), ); assert.ok(prompt.includes(`celle de l'ordinateur : ${signed(computer.luck.perRoll)}.`)); assert.ok(prompt.includes(`Mes décisions : ${counts.good} bon`), prompt); assert.ok(prompt.includes(`, ${counts.bad + counts.veryBad} erreur`), prompt); assert.ok(prompt.includes("Résultat : "), prompt); assert.ok(prompt.includes("contre l'ordinateur (niveau Intermédiaire)"), prompt); // Le coup à revoir : sa position, et sa ligne avec son écart. const worst = summary.toReview.advice; assert.equal(position.positionId, worst.position); assert.ok( prompt.includes(`Le coup à revoir, ma plus grosse erreur (coup ${summary.toReview.turn})`), ); assert.ok( prompt.includes( `écart ${formatEquity(worst.equityLoss)} (${masterWords(phrases, worst).word})`, ), prompt, ); }); test("prompt du bilan sans erreur : la position finale", async () => { const engine = await loadNodeEngine(); // Le joueur au trait : un pion sur 6, un sur 1 ; il joue 6/off 1/off et // gagne (scénario « Revoir ma partie »). const game = Game.start(engine, { setup: { position: "BQAACAIAAAAAAA", firstTurn: "player" }, dice: () => 6, }); await game.roll(); const moves = await engine.legalMoves(game.state.position, game.state.roll ?? [6, 6]); for (const halfMove of moves[0]?.halfMoves ?? []) { await game.playHalfMove(halfMove); } await game.confirmMove(); assert.equal(game.state.phase, "over"); const journal = await analyseAll(engine, game.journal); const position = await summaryPosition(engine, journal); const prompt = summaryPrompt(templates, phrases, { journal, position }); assertComplete(prompt, position); assert.ok(prompt.includes("- aucune : j'ai joué comme GNU Backgammon."), prompt); assert.ok(prompt.includes("Voici la position finale"), prompt); assert.ok(prompt.includes("j'ai gagné une partie simple, 1 point"), prompt); }); test("prompt d'un concept : la position de départ, ou celle de la partie en cours", async () => { const engine = await loadNodeEngine(); const alone = await conceptExample(engine, null, 2); const prompt = conceptPrompt(templates, { term: "Videau", english: "Cube", brief: "le dé des enchères.", journal: null, example: alone, }); assertComplete(prompt, alone.position); assert.ok(prompt.includes("le concept « Videau » (en anglais : Cube)."), prompt); assert.ok(prompt.includes("voici la position de départ"), prompt); assert.ok(prompt.includes("précis. Je débute au backgammon.\n"), prompt); assert.match(prompt, /chances de gagner \d+ %, équité [+−]?\d,\d{3}/); const played = await playOpening(engine, [3, 1], "24/23 13/10"); played.game.recordAdvice(await adviceOn(engine, played)); const during = await conceptExample(engine, played.game.journal, 2); assert.ok(during.fromGame); const second = conceptPrompt(templates, { term: "Blot", english: "Blot", brief: "un pion seul sur une case.", journal: played.game.journal, example: during, }); assertComplete(second, during.position); assert.ok(second.includes("le concept « Blot »."), second); assert.ok(second.includes("une position de ma partie en cours"), second); // Une seule décision analysée : pas de niveau, pas de taux d'erreur. assert.ok(second.includes("Je débute au backgammon."), second); assert.ok(!second.includes("erreur par décision"), second); }); test("prompt des exercices : les erreurs de plusieurs parties, la plus coûteuse en position", async () => { const engine = await loadNodeEngine(); const journals = [await analysedRace(engine, 3, 3), await analysedRace(engine, 7, 2)]; const position = await exercisesPosition(engine, journals); assert.ok(position !== null); const prompt = exercisesPrompt(templates, phrases, { journals, position }); assertComplete(prompt, position); assert.ok(prompt.includes("Voici mes 2 dernières parties"), prompt); assert.match(prompt, /- Partie [12], coup \d+/); assert.ok(prompt.includes("La plus coûteuse (partie"), prompt); assert.ok(prompt.includes("cinq exercices"), prompt); assert.equal(await exercisesPosition(engine, []), null); });