// 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/); });