274 lines
11 KiB
TypeScript
274 lines
11 KiB
TypeScript
// 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<Advice>; 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 2e choix. Mon coup fait votre point 5.",
|
|
);
|
|
});
|
|
|
|
test("grosse erreur : le rang n'est cité que pour un 2e ou 3e choix", async () => {
|
|
const engine = await loadNodeEngine();
|
|
const worst = (await adviceOn(
|
|
engine,
|
|
await playOpening(engine, [3, 1], "24/23 13/10"),
|
|
)) as MoveAdvice;
|
|
for (const rank of [2, 3, 4, 7, 15]) {
|
|
const { phrase } = masterWords(phrases, { ...worst, rank });
|
|
if (rank <= 3) {
|
|
assert.ok(phrase.includes(`était le ${rank}e choix`), phrase);
|
|
} else {
|
|
assert.ok(!phrase.includes("choix"), phrase);
|
|
assert.equal(phrase, "J'aurais joué 8/5 6/5. 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/);
|
|
});
|