Conseil du maître : analyse des décisions, phrases du maître, conseil rattaché au journal
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
16fc836cb9
commit
1330ee6df4
@@ -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<Advice | null> {
|
||||
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 };
|
||||
}
|
||||
@@ -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<string, readonly string[]>;
|
||||
|
||||
/** 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<string, string[]>();
|
||||
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<Record<CubeAction, string>> = {
|
||||
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<Record<string, string>> = {}) =>
|
||||
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<Record<string, string>>): 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;
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -80,6 +80,12 @@ export interface Engine {
|
||||
ply: Ply,
|
||||
): Promise<MoveAnalysis | null>;
|
||||
|
||||
/** 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<EvaluatedMove[]>;
|
||||
|
||||
/** 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). */
|
||||
|
||||
@@ -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<GameEvent>;
|
||||
type WithoutBase<E> = E extends unknown ? Omit<E, keyof EventBase> : 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<Record<string, Advice>>;
|
||||
}
|
||||
|
||||
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 };
|
||||
}
|
||||
|
||||
+45
-18
@@ -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<MoveSteps | null> {
|
||||
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<MoveSteps> {
|
||||
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<Record<string, Advice>> {
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user