Fusion de main (1f, prompts à copier) dans nouvelle-partie-directe
Conflits résolus en gardant les deux apports : la feuille du prompt et la correction de reviewFinishedGame dans app.ts ; prompts et nouvelle-partie dans tests/browser.sh. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,58 @@
|
||||
// Les chiffres du moteur tels qu'on les écrit : à l'écran (feuille du
|
||||
// maître, bilan, analyse) et dans les prompts pour une IA (étape 1f). Une
|
||||
// seule écriture, pour que le prompt donne exactement les chiffres que
|
||||
// l'application affiche.
|
||||
//
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
|
||||
import type { AdvisedMove, MoveAdvice } from "../game/advice.js";
|
||||
|
||||
/** Un nombre à la française, signé, à trois décimales : « −0,123 ». */
|
||||
export function formatEquity(value: number): string {
|
||||
const text = Math.abs(value).toFixed(3).replace(".", ",");
|
||||
return value < -0.0005 ? `−${text}` : value > 0.0005 ? `+${text}` : text;
|
||||
}
|
||||
|
||||
/** Des chances de 0 à 1, en pourcentage arrondi, avec une espace
|
||||
* insécable : « 41 % ». */
|
||||
export function formatPercent(chance: number): string {
|
||||
return `${Math.round(chance * 100)} %`;
|
||||
}
|
||||
|
||||
/** Une ligne du classement des coups montré par « Voir les chiffres du
|
||||
* moteur ». */
|
||||
export interface RankingRow {
|
||||
readonly move: AdvisedMove;
|
||||
/** Rang dans le classement du moteur, à partir de 1. */
|
||||
readonly rank: number;
|
||||
/** Écart d'équité avec le premier coup du classement : 0 ou négatif. */
|
||||
readonly loss: number;
|
||||
/** C'est le coup joué. */
|
||||
readonly played: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Le classement d'un conseil tel que l'affiche la feuille du maître : les
|
||||
* coups du classement du moteur, chacun avec son écart par rapport au
|
||||
* premier ; puis le coup joué, s'il n'y figure pas, avec l'écart de
|
||||
* l'analyse du coup joué.
|
||||
*/
|
||||
export function rankingRows(advice: MoveAdvice): RankingRow[] {
|
||||
const first = advice.ranking[0]?.equity ?? advice.best.equity;
|
||||
const rows = advice.ranking.map((move, i) => ({
|
||||
move,
|
||||
rank: i + 1,
|
||||
loss: move.equity - first,
|
||||
played: move.notation === advice.played.notation,
|
||||
}));
|
||||
if (!rows.some((row) => row.played)) {
|
||||
rows.push({
|
||||
move: advice.played,
|
||||
rank: advice.rank,
|
||||
loss: advice.equityLoss,
|
||||
played: true,
|
||||
});
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
// La position que décrit un prompt, étape 1f : ses identifiants de GNU
|
||||
// Backgammon, son plateau décodé par le moteur, le videau et le match ; et
|
||||
// son diagramme texte.
|
||||
//
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
//
|
||||
// Rien n'est décodé ici : le moteur lit la position (board) et écrit le
|
||||
// Match ID (encodeMatch). Le diagramme ne fait que recopier le plateau
|
||||
// qu'il a décodé.
|
||||
|
||||
import type { Cube } from "../game/cube.js";
|
||||
import type { Engine, Evaluation, MatchId } from "../game/engine.js";
|
||||
import { currentLine, type GameJournal } from "../game/events.js";
|
||||
import { engineCube, type SideBoards } from "../game/game.js";
|
||||
import type { Match } from "../game/match.js";
|
||||
import type { PositionId } from "../game/position.js";
|
||||
import type { Roll } from "../game/roll.js";
|
||||
import type { Side } from "../game/side.js";
|
||||
import { type GameState, replay } from "../game/state.js";
|
||||
|
||||
/** L'adresse publique de l'application (ADR 0013 : les liens de position
|
||||
* s'ouvrent sur elle). */
|
||||
export const PUBLIC_SITE = "https://ustagammon.sans.pub/";
|
||||
|
||||
/** Une position telle que la décrit un prompt. */
|
||||
export interface PromptPosition {
|
||||
/** Position ID de gnubg, vue du camp au trait. */
|
||||
readonly positionId: PositionId;
|
||||
/** Match ID écrit par le moteur, du point de vue du camp au trait, sans
|
||||
* dés. */
|
||||
readonly matchId: MatchId;
|
||||
readonly onRoll: Side;
|
||||
/** Le plateau décodé par le moteur, chaque camp dans sa numérotation. */
|
||||
readonly sides: SideBoards;
|
||||
readonly cube: Cube;
|
||||
/** null en partie libre. */
|
||||
readonly match: Match | null;
|
||||
/** Le jet à jouer, s'il est déjà tiré. */
|
||||
readonly roll: Roll | null;
|
||||
}
|
||||
|
||||
/** La position d'avant l'événement eventId du journal (la décision qu'on
|
||||
* commente), lue par le moteur. */
|
||||
export async function positionBefore(
|
||||
engine: Engine,
|
||||
journal: GameJournal,
|
||||
eventId: string,
|
||||
): Promise<PromptPosition> {
|
||||
const line = currentLine({ ...journal, head: eventId });
|
||||
return positionOf(engine, replay(line.slice(0, -1)));
|
||||
}
|
||||
|
||||
/** La position actuelle d'une partie, ou la position de départ sans
|
||||
* partie, lue par le moteur. */
|
||||
export async function positionNow(
|
||||
engine: Engine,
|
||||
journal: GameJournal | null,
|
||||
): Promise<PromptPosition> {
|
||||
return positionOf(engine, replay(journal === null ? [] : currentLine(journal)));
|
||||
}
|
||||
|
||||
async function positionOf(engine: Engine, state: GameState): Promise<PromptPosition> {
|
||||
// Pendant le jet d'ouverture, la position de départ, vue du joueur.
|
||||
const onRoll = state.turn ?? "player";
|
||||
const cube = engineCube(state, onRoll);
|
||||
const [board, matchId] = await Promise.all([
|
||||
engine.board(state.position, cube),
|
||||
engine.encodeMatch(cube),
|
||||
]);
|
||||
const sides =
|
||||
onRoll === "player"
|
||||
? { player: board.onRoll, computer: board.opponent }
|
||||
: { player: board.opponent, computer: board.onRoll };
|
||||
return {
|
||||
positionId: state.position,
|
||||
matchId,
|
||||
onRoll,
|
||||
sides,
|
||||
cube: state.cube,
|
||||
match: state.match,
|
||||
roll: state.phase === "moving" ? state.roll : null,
|
||||
};
|
||||
}
|
||||
|
||||
/** L'évaluation par le moteur d'une position, pour le camp au trait avant
|
||||
* de lancer les dés. */
|
||||
export function evaluatePosition(
|
||||
engine: Engine,
|
||||
position: PromptPosition,
|
||||
ply: number,
|
||||
): Promise<Evaluation> {
|
||||
const cube = engineCube({ cube: position.cube, match: position.match }, position.onRoll);
|
||||
return engine.evaluate(position.positionId, cube, ply);
|
||||
}
|
||||
|
||||
/** L'adresse publique de la position (#/position/<Position ID>/<Match ID>,
|
||||
* ADR 0013, identifiants encodés avec encodeURIComponent). */
|
||||
export function positionAddress(position: PromptPosition): string {
|
||||
const id = encodeURIComponent(position.positionId);
|
||||
return `${PUBLIC_SITE}#/position/${id}/${encodeURIComponent(position.matchId)}`;
|
||||
}
|
||||
|
||||
/** Nombre de lignes de pions dessinées par moitié du plateau ; au-delà, la
|
||||
* dernière ligne porte le nombre de pions. */
|
||||
const ROWS = 5;
|
||||
|
||||
/**
|
||||
* Le diagramme texte d'une position, vu du joueur : ses pions X, ceux de
|
||||
* l'ordinateur O, dans la numérotation du joueur (sa case 1 est la plus
|
||||
* proche de sa sortie, en bas à droite). Sous le dessin, la liste des pions
|
||||
* case par case, la barre, les pions sortis et les comptes de points, tels
|
||||
* que le moteur les donne.
|
||||
*/
|
||||
export function boardDiagram(sides: SideBoards): string {
|
||||
// La case p du joueur est la case 25 - p de l'ordinateur.
|
||||
const at = (point: number): { mark: string; count: number } => {
|
||||
const mine = sides.player.points[point - 1] ?? 0;
|
||||
const theirs = sides.computer.points[24 - point] ?? 0;
|
||||
if (mine > 0) {
|
||||
return { mark: "X", count: mine };
|
||||
}
|
||||
return theirs > 0 ? { mark: "O", count: theirs } : { mark: ".", count: 0 };
|
||||
};
|
||||
const cell = (text: string) => text.padStart(3, " ");
|
||||
const half = (points: readonly number[]) =>
|
||||
`${points.slice(0, 6).map(String).map(cell).join("")} |${points.slice(6).map(String).map(cell).join("")}`;
|
||||
const row = (points: readonly number[], level: number) => {
|
||||
const cells = points.map((p) => {
|
||||
const { mark, count } = at(p);
|
||||
if (count < level) {
|
||||
return level === 1 ? cell(".") : cell("");
|
||||
}
|
||||
return cell(level === ROWS && count > ROWS ? String(count) : mark);
|
||||
});
|
||||
return `${cells.slice(0, 6).join("")} |${cells.slice(6).join("")}`.trimEnd();
|
||||
};
|
||||
const top = [13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24];
|
||||
const bottom = [12, 11, 10, 9, 8, 7, 6, 5, 4, 3, 2, 1];
|
||||
const lines = [half(top)];
|
||||
for (let level = 1; level <= ROWS; level++) {
|
||||
lines.push(row(top, level));
|
||||
}
|
||||
lines.push("");
|
||||
for (let level = ROWS; level >= 1; level--) {
|
||||
lines.push(row(bottom, level));
|
||||
}
|
||||
lines.push(half(bottom));
|
||||
|
||||
const list = (mark: "X" | "O") => {
|
||||
const parts: string[] = [];
|
||||
for (let point = 24; point >= 1; point--) {
|
||||
const here = at(point);
|
||||
if (here.mark === mark) {
|
||||
parts.push(`${point} (${here.count})`);
|
||||
}
|
||||
}
|
||||
return parts.length === 0 ? "aucun" : parts.join(", ");
|
||||
};
|
||||
return [
|
||||
"```",
|
||||
...lines,
|
||||
"```",
|
||||
"X : mes pions ; O : ceux de l'ordinateur. Les cases sont numérotées de mon point de vue : je joue de la case 24 vers la case 1, mon jan intérieur est en bas à droite (cases 1 à 6).",
|
||||
`Mes pions (X), case (nombre) : ${list("X")}.`,
|
||||
`Pions de l'ordinateur (O), dans ma numérotation : ${list("O")}.`,
|
||||
`Sur la barre : moi ${sides.player.bar}, l'ordinateur ${sides.computer.bar}. Sortis : moi ${sides.player.off}, l'ordinateur ${sides.computer.off}.`,
|
||||
`Compte de points : moi ${sides.player.pipCount}, l'ordinateur ${sides.computer.pipCount}.`,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
/** Le camp, à la première personne du joueur : « moi », « l'ordinateur ». */
|
||||
export function sideName(side: Side): string {
|
||||
return side === "player" ? "moi" : "l'ordinateur";
|
||||
}
|
||||
|
||||
/** Le score : « partie libre » ou celui du match. */
|
||||
export function scoreText(position: Pick<PromptPosition, "match">): string {
|
||||
const match = position.match;
|
||||
if (match === null) {
|
||||
return "partie libre, sans match (règle Jacoby)";
|
||||
}
|
||||
const crawford = match.crawford ? ", partie Crawford" : "";
|
||||
return `match en ${match.length} points, moi ${match.score.player}, l'ordinateur ${match.score.computer}${crawford}`;
|
||||
}
|
||||
|
||||
/** Le videau : sa valeur et qui le possède. */
|
||||
export function cubeText(position: Pick<PromptPosition, "cube" | "match">): string {
|
||||
if (position.match?.crawford === true) {
|
||||
return "aucun (partie Crawford : personne ne peut doubler)";
|
||||
}
|
||||
const { value, owner } = position.cube;
|
||||
const where = owner === null ? "au centre" : owner === "player" ? "à moi" : "à l'ordinateur";
|
||||
return `${value}, ${where}`;
|
||||
}
|
||||
@@ -0,0 +1,474 @@
|
||||
// Les prompts pour une IA (étape 1f ; cadrage, « IA générative en V1 ») :
|
||||
// chaque gabarit de docs/prompts/ rempli avec la position, décodée par le
|
||||
// moteur, et les chiffres du moteur, écrits comme l'application les
|
||||
// affiche. Le moteur décide, l'IA explique : le prompt lui demande de ne
|
||||
// pas contredire ces chiffres.
|
||||
//
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
//
|
||||
// Les fonctions qui remplissent (decisionPrompt, summaryPrompt,
|
||||
// conceptPrompt, exercisesPrompt) sont pures : elles reçoivent les
|
||||
// analyses du moteur et la position déjà lue. Les fonctions asynchrones
|
||||
// (summaryPosition, conceptExample, exercisesPosition) ne font que demander
|
||||
// au moteur la position à décrire.
|
||||
|
||||
import {
|
||||
type ErrorRate,
|
||||
gameTurns,
|
||||
levelOfErrorRate,
|
||||
type ReviewedDecision,
|
||||
summarizeAnalysis,
|
||||
} from "../analysis/game-analysis.js";
|
||||
import { formatEquity, formatPercent, rankingRows } from "../analysis/numbers.js";
|
||||
import { diceWords, type MasterPhrases, masterWords } from "../analysis/phrases.js";
|
||||
import {
|
||||
type Advice,
|
||||
type CubeAdvice,
|
||||
type MoveAdvice,
|
||||
masterCubeAction,
|
||||
verdictOf,
|
||||
} from "../game/advice.js";
|
||||
import type { CubeAction, Engine, Evaluation, Ply } from "../game/engine.js";
|
||||
import { currentLine, type GameJournal } from "../game/events.js";
|
||||
import { type ComputerLevel, computerLevelOf } from "../game/level.js";
|
||||
import { replay } from "../game/state.js";
|
||||
import {
|
||||
boardDiagram,
|
||||
cubeText,
|
||||
evaluatePosition,
|
||||
type PromptPosition,
|
||||
positionAddress,
|
||||
positionBefore,
|
||||
positionNow,
|
||||
scoreText,
|
||||
sideName,
|
||||
} from "./position.js";
|
||||
import { fillPrompt, type Prompt, type PromptTemplates } from "./templates.js";
|
||||
|
||||
/** La langue des réponses demandées à l'IA. */
|
||||
export const PROMPT_LANGUAGE = "français";
|
||||
|
||||
/** Les noms des niveaux, les mêmes que l'écran « Nouvelle partie » et le
|
||||
* bilan (vérifié par app/tests/prompts.test.ts). */
|
||||
export const LEVEL_NAMES: Readonly<Record<ComputerLevel, string>> = {
|
||||
beginner: "Débutant",
|
||||
amateur: "Amateur",
|
||||
intermediate: "Intermédiaire",
|
||||
advanced: "Avancé",
|
||||
expert: "Expert",
|
||||
champion: "Champion",
|
||||
};
|
||||
|
||||
/** Les décisions de videau, comme le bilan les nomme. */
|
||||
export const CUBE_ACTION_WORDS: Readonly<Record<CubeAction, string>> = {
|
||||
double: "doubler",
|
||||
noDouble: "lancer sans doubler",
|
||||
take: "prendre",
|
||||
pass: "passer",
|
||||
};
|
||||
|
||||
/** Les lignes des équités de videau, comme « Voir les chiffres du
|
||||
* moteur ». */
|
||||
export const CUBE_ROW_WORDS = {
|
||||
noDouble: "Pas de double",
|
||||
doubleTake: "Double, prend",
|
||||
doublePass: "Double, passe",
|
||||
} as const;
|
||||
|
||||
/** Les erreurs citées au plus dans un prompt : bilan, exercices. */
|
||||
const MAX_ERRORS = 8;
|
||||
|
||||
const plural = (n: number, one: string, many: string) => `${n} ${n > 1 ? many : one}`;
|
||||
|
||||
/** « analyse à 2 plis ». */
|
||||
export function plyText(ply: Ply): string {
|
||||
return `analyse à ${ply} ${ply > 1 ? "plis" : "pli"}`;
|
||||
}
|
||||
|
||||
/** Nombre de décisions analysées à partir duquel le niveau du joueur se
|
||||
* dit dans un prompt : en dessous, l'erreur par décision dit peu de chose. */
|
||||
export const LEVEL_MIN_DECISIONS = 20;
|
||||
|
||||
/** Ce que le joueur dit de lui-même dans un prompt : son niveau, d'après
|
||||
* son erreur par décision mesurée par GNU Backgammon, s'il est mesuré sur
|
||||
* au moins LEVEL_MIN_DECISIONS décisions ; sinon, qu'il débute (pas encore
|
||||
* d'historique des parties : étape 1h). Jamais le taux lui-même, qui n'est
|
||||
* que dans le bilan. */
|
||||
export function playerSentence(rate: Pick<ErrorRate, "decisions" | "level"> | null): string {
|
||||
if (rate === null || rate.decisions < LEVEL_MIN_DECISIONS) {
|
||||
return "Je débute au backgammon.";
|
||||
}
|
||||
return `Je suis un joueur de niveau ${LEVEL_NAMES[rate.level]}.`;
|
||||
}
|
||||
|
||||
/** playerSentence, sur les décisions d'une partie. */
|
||||
function playerOf(journal: GameJournal | null): string {
|
||||
return playerSentence(journal === null ? null : summarizeAnalysis(journal).player.errorRate);
|
||||
}
|
||||
|
||||
/** Les repères de la position, communs à tous les gabarits. */
|
||||
function positionValues(position: PromptPosition): Record<string, string> {
|
||||
return {
|
||||
position_id: position.positionId,
|
||||
match_id: position.matchId,
|
||||
au_trait: sideName(position.onRoll),
|
||||
lien: positionAddress(position),
|
||||
diagramme: boardDiagram(position.sides),
|
||||
score: scoreText(position),
|
||||
videau: cubeText(position),
|
||||
langue: PROMPT_LANGUAGE,
|
||||
};
|
||||
}
|
||||
|
||||
// ------------------------------------------------------ Une décision
|
||||
|
||||
/** Ce que demande le prompt d'une décision du joueur. */
|
||||
export interface DecisionPromptData {
|
||||
/** La partie : le niveau du joueur se lit sur ses décisions. */
|
||||
readonly journal: GameJournal;
|
||||
/** Le conseil du moteur sur la décision. */
|
||||
readonly advice: Advice;
|
||||
/** La position d'avant la décision (positionBefore). */
|
||||
readonly position: PromptPosition;
|
||||
}
|
||||
|
||||
/** Le prompt « Pourquoi ce coup est meilleur » ou « Expliquer cette
|
||||
* décision de videau ». */
|
||||
export function decisionPrompt(
|
||||
templates: PromptTemplates,
|
||||
phrases: MasterPhrases,
|
||||
data: DecisionPromptData,
|
||||
): Prompt {
|
||||
const common = {
|
||||
...positionValues(data.position),
|
||||
joueur: playerOf(data.journal),
|
||||
niveau_eval: plyText(data.advice.ply),
|
||||
appreciation: masterWords(phrases, data.advice).word,
|
||||
};
|
||||
return data.advice.kind === "move"
|
||||
? fillPrompt(templates.move, { ...common, ...moveValues(data.advice) })
|
||||
: fillPrompt(templates.cube, { ...common, ...cubeValues(data.advice) });
|
||||
}
|
||||
|
||||
function moveValues(advice: MoveAdvice): Record<string, string> {
|
||||
const count = plural(advice.moveCount, "coup possible", "coups possibles");
|
||||
const rank =
|
||||
advice.moveCount <= 1
|
||||
? "le seul coup possible"
|
||||
: advice.rank === 1
|
||||
? `le meilleur des ${count}`
|
||||
: `le ${advice.rank}e choix sur ${count}`;
|
||||
// Les trois meilleurs coups, et le vôtre s'il n'en est pas : les lignes
|
||||
// de « Voir les chiffres du moteur », avec les mêmes chiffres.
|
||||
const rows = rankingRows(advice).filter((row) => row.rank <= 3 || row.played);
|
||||
const ranking = rows.map((row) => {
|
||||
const ply = row.move.ply === advice.ply ? "" : `, évalué à ${row.move.ply} pli`;
|
||||
const mine = row.played ? " (mon coup)" : "";
|
||||
return `${row.rank}. ${row.move.notation}${mine} : équité ${formatEquity(row.move.equity)}, écart ${formatEquity(row.loss)}${ply}`;
|
||||
});
|
||||
return {
|
||||
des: `${advice.roll[0]}-${advice.roll[1]}`,
|
||||
coup_joue: advice.played.notation,
|
||||
rang: rank,
|
||||
classement: ranking.join("\n"),
|
||||
};
|
||||
}
|
||||
|
||||
function cubeValues(advice: CubeAdvice): Record<string, string> {
|
||||
const answering = advice.action === "take" || advice.action === "pass";
|
||||
// Les lignes de votre décision : doubler mène à l'une des deux lignes
|
||||
// « Double », selon la réponse de l'ordinateur (comme la feuille).
|
||||
const mine = {
|
||||
noDouble: ["noDouble"],
|
||||
double: ["doubleTake", "doublePass"],
|
||||
take: ["doubleTake"],
|
||||
pass: ["doublePass"],
|
||||
}[advice.action];
|
||||
const rows = (["noDouble", "doubleTake", "doublePass"] as const).map((key) => {
|
||||
const note = mine.length === 1 && mine.includes(key) ? " (ma décision)" : "";
|
||||
return `- ${CUBE_ROW_WORDS[key]}${note} : ${formatEquity(advice.equities[key])}`;
|
||||
});
|
||||
return {
|
||||
decision: answering
|
||||
? "prendre ou passer le double de l'ordinateur"
|
||||
: "doubler ou lancer les dés sans doubler",
|
||||
choix: CUBE_ACTION_WORDS[advice.action],
|
||||
choix_moteur: CUBE_ACTION_WORDS[masterCubeAction(advice)],
|
||||
qui_double: answering ? "l'ordinateur" : "moi",
|
||||
equites: rows.join("\n"),
|
||||
ecart: formatEquity(advice.equityLoss),
|
||||
};
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ Le bilan
|
||||
|
||||
/** Une imprécision ou une erreur du joueur, dans son tour. */
|
||||
interface Mistake {
|
||||
readonly turn: number;
|
||||
readonly advice: Advice;
|
||||
/** Le jet du tour, « 6-1 » (vide pour un tour sans jet). */
|
||||
readonly roll: string;
|
||||
}
|
||||
|
||||
/** Les imprécisions et erreurs du joueur sur la ligne courante, dans
|
||||
* l'ordre de la partie. */
|
||||
function mistakesOf(journal: GameJournal): Mistake[] {
|
||||
const advice = journal.advice ?? {};
|
||||
const mistakes: Mistake[] = [];
|
||||
for (const turn of gameTurns(journal)) {
|
||||
for (const event of turn.events) {
|
||||
const a = advice[event.id];
|
||||
if (a !== undefined && verdictOf(a.skill) !== "good") {
|
||||
const roll = turn.roll === null ? "" : `${turn.roll[0]}-${turn.roll[1]}`;
|
||||
mistakes.push({ turn: turn.number, advice: a, roll });
|
||||
}
|
||||
}
|
||||
}
|
||||
return mistakes;
|
||||
}
|
||||
|
||||
/** Une ligne de la liste des erreurs : ce qui a été joué, ce que le moteur
|
||||
* préférait, l'écart et le mot du maître. */
|
||||
function mistakeLine(phrases: MasterPhrases, mistake: Mistake, where: string): string {
|
||||
const a = mistake.advice;
|
||||
const word = masterWords(phrases, a).word;
|
||||
const cost = `écart ${formatEquity(a.equityLoss)} (${word})`;
|
||||
if (a.kind === "move") {
|
||||
return `- ${where}, ${mistake.roll} : j'ai joué ${a.played.notation}, GNU Backgammon préférait ${a.best.notation} ; ${cost}.`;
|
||||
}
|
||||
return `- ${where} : j'ai choisi de ${CUBE_ACTION_WORDS[a.action]}, GNU Backgammon aurait choisi de ${CUBE_ACTION_WORDS[masterCubeAction(a)]} ; ${cost}.`;
|
||||
}
|
||||
|
||||
/** Le niveau de l'ordinateur d'une partie, par son nom. */
|
||||
function computerLevelName(journal: GameJournal): string {
|
||||
const level = replay(currentLine(journal)).level;
|
||||
const name = computerLevelOf(level);
|
||||
return name === null ? `GNU Backgammon, réglage ${level}` : LEVEL_NAMES[name];
|
||||
}
|
||||
|
||||
/** Le résultat d'une partie finie, et le match après elle. */
|
||||
function resultText(journal: GameJournal): string {
|
||||
const line = currentLine(journal);
|
||||
const state = replay(line);
|
||||
const result = state.result;
|
||||
if (result === null) {
|
||||
return "partie pas encore finie";
|
||||
}
|
||||
const who = result.winner === "player" ? "j'ai gagné" : "l'ordinateur a gagné";
|
||||
const kind = {
|
||||
single: "une partie simple",
|
||||
gammon: "un gammon",
|
||||
backgammon: "un backgammon",
|
||||
}[result.kind];
|
||||
const dropped = line.some((e) => e.type === "CubeDropped")
|
||||
? result.winner === "player"
|
||||
? ", l'ordinateur ayant refusé mon double"
|
||||
: ", après mon refus de son double"
|
||||
: "";
|
||||
const cube = state.cube.value > 1 ? `, videau à ${state.cube.value}` : "";
|
||||
const text = `${who} ${kind}, ${plural(result.points, "point", "points")}${cube}${dropped}`;
|
||||
const after = state.matchAfter;
|
||||
if (state.match === null || after === null) {
|
||||
return text;
|
||||
}
|
||||
const over = after.over ? ", match terminé" : "";
|
||||
return `${text} ; match en ${state.match.length} points, score après la partie : moi ${after.score.player}, l'ordinateur ${after.score.computer}${over}`;
|
||||
}
|
||||
|
||||
/** La position que montre le prompt du bilan : celle d'avant le coup à
|
||||
* revoir, ou la position finale si le joueur n'a fait aucune erreur. */
|
||||
export async function summaryPosition(
|
||||
engine: Engine,
|
||||
journal: GameJournal,
|
||||
): Promise<PromptPosition> {
|
||||
const toReview = summarizeAnalysis(journal).toReview;
|
||||
return toReview === null
|
||||
? positionNow(engine, journal)
|
||||
: positionBefore(engine, journal, toReview.advice.eventId);
|
||||
}
|
||||
|
||||
/** Ce que demande le prompt du bilan. */
|
||||
export interface SummaryPromptData {
|
||||
/** La partie finie, analysée (readyForSummary). */
|
||||
readonly journal: GameJournal;
|
||||
/** summaryPosition. */
|
||||
readonly position: PromptPosition;
|
||||
}
|
||||
|
||||
/** Le prompt « Bilan de ma partie ». */
|
||||
export function summaryPrompt(
|
||||
templates: PromptTemplates,
|
||||
phrases: MasterPhrases,
|
||||
data: SummaryPromptData,
|
||||
): Prompt {
|
||||
const summary = summarizeAnalysis(data.journal);
|
||||
const { player, computer, counts } = summary;
|
||||
const errors = counts.bad + counts.veryBad;
|
||||
const veryBad =
|
||||
counts.veryBad > 0
|
||||
? ` (dont ${plural(counts.veryBad, "grosse erreur", "grosses erreurs")})`
|
||||
: "";
|
||||
const mistakes = mistakesOf(data.journal);
|
||||
const listed = [...mistakes]
|
||||
.sort((a, b) => a.advice.equityLoss - b.advice.equityLoss)
|
||||
.slice(0, MAX_ERRORS)
|
||||
.sort((a, b) => a.turn - b.turn)
|
||||
.map((m) => mistakeLine(phrases, m, `Coup ${m.turn}`));
|
||||
if (mistakes.length > listed.length) {
|
||||
listed.push(`- et ${mistakes.length - listed.length} de moins coûteuses.`);
|
||||
}
|
||||
const toReview = summary.toReview;
|
||||
return fillPrompt(templates.summary, {
|
||||
...positionValues(data.position),
|
||||
joueur: playerOf(data.journal),
|
||||
niveau_eval: plyText(summaryPly(data.journal, toReview)),
|
||||
ordinateur: computerLevelName(data.journal),
|
||||
resultat: resultText(data.journal),
|
||||
decomptes: `${plural(counts.good, "bon coup", "bons coups")}, ${plural(counts.doubtful, "imprécision", "imprécisions")}, ${plural(errors, "erreur", "erreurs")}${veryBad}`,
|
||||
taux: formatEquity(-player.errorRate.perDecision),
|
||||
decisions: plural(player.errorRate.decisions, "décision", "décisions"),
|
||||
niveau_jeu: LEVEL_NAMES[player.errorRate.level],
|
||||
taux_ordinateur: `${formatEquity(-computer.errorRate.perDecision)} sur ${plural(computer.errorRate.decisions, "décision", "décisions")}, soit le niveau ${LEVEL_NAMES[computer.errorRate.level]}`,
|
||||
chance: formatEquity(player.luck.perRoll),
|
||||
chance_mots: diceWords(phrases, player.luck.rating),
|
||||
chance_ordinateur: formatEquity(computer.luck.perRoll),
|
||||
erreurs:
|
||||
listed.length === 0 ? "- aucune : j'ai joué comme GNU Backgammon." : listed.join("\n"),
|
||||
position_titre:
|
||||
toReview === null
|
||||
? "Je n'ai fait ni imprécision ni erreur. Voici la position finale"
|
||||
: `Le coup à revoir, ma plus grosse erreur (coup ${toReview.turn}) : la position avant ma décision`,
|
||||
});
|
||||
}
|
||||
|
||||
/** Le niveau des analyses du bilan : celui du coup à revoir, ou de
|
||||
* l'analyse de partie (2 plis). */
|
||||
function summaryPly(journal: GameJournal, toReview: ReviewedDecision | null): Ply {
|
||||
return toReview?.advice.ply ?? journal.review?.ply ?? 2;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------- Un concept
|
||||
|
||||
/** La position d'exemple d'un concept : la partie en cours, ou la
|
||||
* position de départ s'il n'y en a pas (ou si elle est finie), avec
|
||||
* l'évaluation du moteur. */
|
||||
export interface ConceptExample {
|
||||
readonly position: PromptPosition;
|
||||
readonly evaluation: Evaluation;
|
||||
readonly ply: Ply;
|
||||
readonly fromGame: boolean;
|
||||
}
|
||||
|
||||
/** Lit la position d'exemple d'un concept et l'évalue, au niveau ply. */
|
||||
export async function conceptExample(
|
||||
engine: Engine,
|
||||
journal: GameJournal | null,
|
||||
ply: Ply,
|
||||
): Promise<ConceptExample> {
|
||||
const playing =
|
||||
journal !== null && !["opening", "over"].includes(replay(currentLine(journal)).phase);
|
||||
const position = await positionNow(engine, playing ? journal : null);
|
||||
const evaluation = await evaluatePosition(engine, position, ply);
|
||||
return { position, evaluation, ply, fromGame: playing };
|
||||
}
|
||||
|
||||
/** Ce que demande le prompt d'un concept. */
|
||||
export interface ConceptPromptData {
|
||||
/** Le terme, son nom anglais (peut être vide) et sa phrase « En bref ». */
|
||||
readonly term: string;
|
||||
readonly english: string;
|
||||
readonly brief: string;
|
||||
/** La partie en cours, pour le niveau du joueur (null : aucune). */
|
||||
readonly journal: GameJournal | null;
|
||||
readonly example: ConceptExample;
|
||||
}
|
||||
|
||||
/** Le prompt « Expliquer ce concept ». */
|
||||
export function conceptPrompt(templates: PromptTemplates, data: ConceptPromptData): Prompt {
|
||||
const { example } = data;
|
||||
const english =
|
||||
data.english !== "" && data.english.toLowerCase() !== data.term.toLowerCase()
|
||||
? ` (en anglais : ${data.english})`
|
||||
: "";
|
||||
return fillPrompt(templates.concept, {
|
||||
...positionValues(example.position),
|
||||
joueur: playerOf(data.journal),
|
||||
niveau_eval: plyText(example.ply),
|
||||
concept: `« ${data.term} »${english}`,
|
||||
definition: data.brief,
|
||||
position_titre: example.fromGame
|
||||
? "une position de ma partie en cours"
|
||||
: "la position de départ",
|
||||
evaluation: `chances de gagner ${formatPercent(example.evaluation.win)}, équité ${formatEquity(example.evaluation.equity)}`,
|
||||
});
|
||||
}
|
||||
|
||||
// ------------------------------------------------------ Des exercices
|
||||
|
||||
/** Une erreur dans une des parties. */
|
||||
interface GameMistake extends Mistake {
|
||||
/** Rang de la partie, à partir de 1 (la plus ancienne). */
|
||||
readonly game: number;
|
||||
readonly journal: GameJournal;
|
||||
}
|
||||
|
||||
/** Les imprécisions et erreurs de plusieurs parties, de la plus coûteuse à
|
||||
* la moins coûteuse. */
|
||||
function costliest(journals: readonly GameJournal[]): GameMistake[] {
|
||||
return journals
|
||||
.flatMap((journal, i) => mistakesOf(journal).map((m) => ({ ...m, game: i + 1, journal })))
|
||||
.sort((a, b) => a.advice.equityLoss - b.advice.equityLoss);
|
||||
}
|
||||
|
||||
/** La position de la plus coûteuse des erreurs de ces parties (null s'il
|
||||
* n'y en a aucune : pas d'exercice à proposer). */
|
||||
export async function exercisesPosition(
|
||||
engine: Engine,
|
||||
journals: readonly GameJournal[],
|
||||
): Promise<PromptPosition | null> {
|
||||
const worst = costliest(journals)[0];
|
||||
return worst === undefined ? null : positionBefore(engine, worst.journal, worst.advice.eventId);
|
||||
}
|
||||
|
||||
/** Ce que demande le prompt des exercices. */
|
||||
export interface ExercisesPromptData {
|
||||
/** Les parties analysées, de la plus ancienne à la plus récente. */
|
||||
readonly journals: readonly GameJournal[];
|
||||
/** exercisesPosition (non null). */
|
||||
readonly position: PromptPosition;
|
||||
}
|
||||
|
||||
/** Le prompt « Créer des exercices » (progression, étape 1h). Erreur si
|
||||
* les parties n'ont aucune imprécision ni erreur. */
|
||||
export function exercisesPrompt(
|
||||
templates: PromptTemplates,
|
||||
phrases: MasterPhrases,
|
||||
data: ExercisesPromptData,
|
||||
): Prompt {
|
||||
const mistakes = costliest(data.journals);
|
||||
const worst = mistakes[0];
|
||||
if (worst === undefined) {
|
||||
throw new Error("exercices : aucune erreur dans ces parties");
|
||||
}
|
||||
// Le niveau, sur toutes les décisions de ces parties.
|
||||
let total = 0;
|
||||
let decisions = 0;
|
||||
for (const journal of data.journals) {
|
||||
const rate = summarizeAnalysis(journal).player.errorRate;
|
||||
total += rate.total;
|
||||
decisions += rate.decisions;
|
||||
}
|
||||
const level = levelOfErrorRate(decisions > 0 ? total / decisions : 0);
|
||||
const listed = mistakes
|
||||
.slice(0, MAX_ERRORS)
|
||||
.map((m) => mistakeLine(phrases, m, `Partie ${m.game}, coup ${m.turn}`));
|
||||
return fillPrompt(templates.exercises, {
|
||||
...positionValues(data.position),
|
||||
joueur: playerSentence({ decisions, level }),
|
||||
niveau_eval: plyText(worst.advice.ply),
|
||||
parties: String(data.journals.length),
|
||||
erreurs: listed.join("\n"),
|
||||
position_titre: `La plus coûteuse (partie ${worst.game}, coup ${worst.turn}) : la position avant ma décision`,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
// Les gabarits des prompts pour une IA (étape 1f) : lecture de leur source
|
||||
// (docs/prompts/, ADR 0008 : une seule source) et remplissage des repères.
|
||||
// Fonctions pures, testées sous Node sur de vraies analyses du moteur
|
||||
// (app/tests/prompts.test.ts).
|
||||
//
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
|
||||
/**
|
||||
* Un prompt : le texte que l'application prépare pour une IA, rempli avec
|
||||
* la position et les chiffres du moteur, et que le joueur copie.
|
||||
*
|
||||
* Glossaire : docs/glossaire/prompt.md
|
||||
*/
|
||||
export type Prompt = string;
|
||||
|
||||
/** Les cinq gabarits du cadrage (« IA générative en V1 »). */
|
||||
export type PromptKind = "move" | "cube" | "summary" | "concept" | "exercises";
|
||||
|
||||
export const PROMPT_KINDS: readonly PromptKind[] = [
|
||||
"move",
|
||||
"cube",
|
||||
"summary",
|
||||
"concept",
|
||||
"exercises",
|
||||
];
|
||||
|
||||
/** Le fichier de chaque gabarit, dans docs/prompts/. */
|
||||
export const PROMPT_FILES: Readonly<Record<PromptKind, string>> = {
|
||||
move: "coup.md",
|
||||
cube: "videau.md",
|
||||
summary: "bilan.md",
|
||||
concept: "concept.md",
|
||||
exercises: "exercices.md",
|
||||
};
|
||||
|
||||
/** Les gabarits lus, par sorte. */
|
||||
export type PromptTemplates = Readonly<Record<PromptKind, string>>;
|
||||
|
||||
/** Le gabarit d'un fichier de docs/prompts/ : le bloc de texte qui suit le
|
||||
* titre « ## Gabarit ». Erreur s'il manque. */
|
||||
export function parsePromptTemplate(markdown: string): string {
|
||||
const lines = markdown.split("\n");
|
||||
const heading = lines.findIndex((line) => line.trim() === "## Gabarit");
|
||||
const start = lines.findIndex((line, i) => i > heading && line.trim() === "```text");
|
||||
const end = lines.findIndex((line, i) => i > start && line.trim() === "```");
|
||||
if (heading < 0 || start < 0 || end < 0) {
|
||||
throw new Error("gabarit de prompt : bloc « ## Gabarit » absent");
|
||||
}
|
||||
return lines.slice(start + 1, end).join("\n");
|
||||
}
|
||||
|
||||
/** Les repères d'un gabarit, dans l'ordre, sans doublon. */
|
||||
export function placeholders(template: string): string[] {
|
||||
return [...new Set([...template.matchAll(/\{([a-z_]+)\}/g)].map((m) => m[1] as string))];
|
||||
}
|
||||
|
||||
/**
|
||||
* Remplit les repères {nom} d'un gabarit. Erreur sur un repère inconnu ou
|
||||
* une valeur vide : un prompt n'a jamais de trou.
|
||||
*/
|
||||
export function fillPrompt(template: string, values: Readonly<Record<string, string>>): Prompt {
|
||||
return template.replace(/\{([a-z_]+)\}/g, (_, name: string) => {
|
||||
const value = values[name];
|
||||
if (value === undefined) {
|
||||
throw new Error(`gabarit de prompt : repère {${name}} inconnu`);
|
||||
}
|
||||
if (value.trim() === "") {
|
||||
throw new Error(`gabarit de prompt : repère {${name}} vide`);
|
||||
}
|
||||
return value;
|
||||
});
|
||||
}
|
||||
+10
-1
@@ -5,7 +5,7 @@
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
|
||||
import { analyseDecision } from "./domain/analysis/analyse.js";
|
||||
import { ANALYSIS_PLY, analyseDecision } from "./domain/analysis/analyse.js";
|
||||
import { runAnalysis } from "./domain/analysis/game-analysis.js";
|
||||
import { Game } from "./domain/game/game.js";
|
||||
import { linkOf, readPosition } from "./domain/game/position-link.js";
|
||||
@@ -17,6 +17,8 @@ import {
|
||||
shouldTake,
|
||||
tutorialBoard,
|
||||
} from "./domain/learning/tutorial.js";
|
||||
import { positionBefore } from "./domain/prompts/position.js";
|
||||
import { conceptExample, summaryPosition } from "./domain/prompts/prompts.js";
|
||||
import { createWasmEngine } from "./engine/wasm-engine.js";
|
||||
import { type EngineWorker, startEngineWorker } from "./engine/worker-run.js";
|
||||
import { createGameSession } from "./storage/game-session.js";
|
||||
@@ -164,6 +166,13 @@ startApp({
|
||||
// Le bilan et l'analyse de partie (étape 1e) : les analyses qui
|
||||
// manquent, une à une, sur le même moteur à part.
|
||||
analyseGame: (journal, task) => runAnalysis(analysisEngine, journal, task),
|
||||
// Les prompts pour une IA (étape 1f) : la position à décrire, lue par
|
||||
// le moteur, et l'évaluation de la position d'exemple d'un concept.
|
||||
prompts: {
|
||||
before: (journal, eventId) => positionBefore(engine, journal, eventId),
|
||||
summary: (journal) => summaryPosition(engine, journal),
|
||||
concept: (journal) => conceptExample(engine, journal, ANALYSIS_PLY),
|
||||
},
|
||||
startUpdates: (onAvailable) =>
|
||||
startUpdates(new URL("../sw.js", import.meta.url), worker.ready, onAvailable),
|
||||
});
|
||||
|
||||
@@ -48,8 +48,6 @@ export const CUBE_GLOSSARY: Readonly<Record<CubeAction, { file: string; term: st
|
||||
pass: { file: "passer", term: "Passer" },
|
||||
};
|
||||
|
||||
/** Un nombre à la française, signé, à trois décimales : « −0,123 ». */
|
||||
export function formatEquity(value: number): string {
|
||||
const text = Math.abs(value).toFixed(3).replace(".", ",");
|
||||
return value < -0.0005 ? `−${text}` : value > 0.0005 ? `+${text}` : text;
|
||||
}
|
||||
/** Un nombre à la française, signé, à trois décimales : « −0,123 » ; la
|
||||
* même écriture que les prompts (domain/analysis/numbers.ts). */
|
||||
export { formatEquity } from "../domain/analysis/numbers.js";
|
||||
|
||||
+20
-16
@@ -7,6 +7,7 @@
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
|
||||
import { rankingRows } from "../domain/analysis/numbers.js";
|
||||
import { type MasterPhrases, masterWords } from "../domain/analysis/phrases.js";
|
||||
import {
|
||||
type Advice,
|
||||
@@ -17,6 +18,7 @@ import {
|
||||
} from "../domain/game/advice.js";
|
||||
import type { Game } from "../domain/game/game.js";
|
||||
import { ADVICE_TEXTS, CUBE_GLOSSARY, formatEquity } from "./advice-texts.js";
|
||||
import { askAiLink, clearSheet } from "./ask-ai.js";
|
||||
import { type IconName, icon } from "./icons.js";
|
||||
import { boardSvg, createMovePlayer, drawSteps, type MovePlayer } from "./move-board.js";
|
||||
import { showWithNotation } from "./notation.js";
|
||||
@@ -47,6 +49,8 @@ export interface AdviceView {
|
||||
/** Ouvre la feuille sur le conseil de l'événement eventId. */
|
||||
show(eventId: string): Promise<void>;
|
||||
hide(): void;
|
||||
/** La feuille, où la page du prompt prend la place du contenu (1f). */
|
||||
readonly sheet: HTMLElement;
|
||||
}
|
||||
|
||||
type Shown = "mine" | "best";
|
||||
@@ -91,7 +95,7 @@ export function createAdviceView(options: AdviceViewOptions): AdviceView {
|
||||
const id = ++opened;
|
||||
const game = options.game();
|
||||
const advice = game?.advice[eventId] ?? null;
|
||||
sheet.replaceChildren();
|
||||
clearSheet(sheet);
|
||||
veil.hidden = false;
|
||||
const head = element(sheet, "div", { className: "sheet-head" });
|
||||
element(head, "span", { className: "grip" }).setAttribute("aria-hidden", "true");
|
||||
@@ -149,12 +153,21 @@ export function createAdviceView(options: AdviceViewOptions): AdviceView {
|
||||
});
|
||||
link.href = routeHash({ screen: "glossary", term: entry.file });
|
||||
}
|
||||
closeButton();
|
||||
closeButton(advice.eventId);
|
||||
close.focus();
|
||||
}
|
||||
|
||||
function closeButton(): void {
|
||||
/** « Demander à une IA » (étape 1f), s'il y a un conseil, et
|
||||
* « Fermer ». */
|
||||
function closeButton(eventId: string | null = null): void {
|
||||
const actions = element(sheet, "div", { className: "explain-actions" });
|
||||
if (eventId !== null) {
|
||||
askAiLink(
|
||||
actions,
|
||||
"explain-ask-ai",
|
||||
routeHash({ screen: "explanation", event: eventId, ai: true }),
|
||||
);
|
||||
}
|
||||
const done = element(actions, "button", {
|
||||
id: "explain-done",
|
||||
className: "secondary",
|
||||
@@ -278,18 +291,8 @@ export function createAdviceView(options: AdviceViewOptions): AdviceView {
|
||||
if (advice.kind === "move") {
|
||||
// Les écarts du classement se lisent par rapport à son premier
|
||||
// coup ; votre coup, s'il n'y figure pas, avec l'écart de
|
||||
// l'analyse du coup joué.
|
||||
const first = advice.ranking[0]?.equity ?? advice.best.equity;
|
||||
const listed = advice.ranking.map((move, i) => ({
|
||||
move,
|
||||
rank: i + 1,
|
||||
loss: move.equity - first,
|
||||
}));
|
||||
if (!advice.ranking.some((m) => m.notation === advice.played.notation)) {
|
||||
listed.push({ move: advice.played, rank: advice.rank, loss: advice.equityLoss });
|
||||
}
|
||||
for (const { move, rank, loss } of listed) {
|
||||
const mine = move.notation === advice.played.notation;
|
||||
// l'analyse du coup joué (les mêmes lignes que le prompt).
|
||||
for (const { move, rank, loss, played: mine } of rankingRows(advice)) {
|
||||
row(
|
||||
[
|
||||
String(rank),
|
||||
@@ -333,12 +336,13 @@ export function createAdviceView(options: AdviceViewOptions): AdviceView {
|
||||
|
||||
return {
|
||||
show,
|
||||
sheet,
|
||||
hide() {
|
||||
opened++;
|
||||
player?.stop();
|
||||
if (!veil.hidden) {
|
||||
veil.hidden = true;
|
||||
sheet.replaceChildren();
|
||||
clearSheet(sheet);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
@@ -31,6 +31,7 @@ import type { GameJournal } from "../domain/game/events.js";
|
||||
import type { Game, MoveSteps } from "../domain/game/game.js";
|
||||
import { formatHalfMoves } from "../domain/game/position.js";
|
||||
import { VERDICT_ICONS } from "./advice-view.js";
|
||||
import { askAiLink } from "./ask-ai.js";
|
||||
import { icon } from "./icons.js";
|
||||
import { MATCH_TEXTS } from "./match-texts.js";
|
||||
import { boardSvg, createMovePlayer, drawSteps, type MovePlayer } from "./move-board.js";
|
||||
@@ -212,9 +213,11 @@ export function createAnalysisView(options: AnalysisViewOptions): AnalysisView {
|
||||
text: REVIEW_TEXTS.replayFromHere,
|
||||
});
|
||||
replayButton.type = "button";
|
||||
// L'emplacement de « Demander à une IA » (étape 1f).
|
||||
// « Demander à une IA » (étape 1f) : le prompt de la décision du joueur
|
||||
// dans ce coup, s'il y en a une.
|
||||
const slot = element(viewerActions, "div", { className: "ask-ai-slot" });
|
||||
slot.dataset.slot = "ask-ai";
|
||||
const askAi = askAiLink(slot, "analysis-ask-ai", "#/");
|
||||
const replayHint = element(viewerText, "p", {
|
||||
className: "viewer-legend",
|
||||
text: REVIEW_TEXTS.replayHint,
|
||||
@@ -412,6 +415,8 @@ export function createAnalysisView(options: AnalysisViewOptions): AnalysisView {
|
||||
theirs?.eventId ?? "",
|
||||
current.phrases !== null,
|
||||
].join("|");
|
||||
askAi.hidden = main === null;
|
||||
askAi.href = routeHash({ screen: "analysis", game: current.id, turn: selected, ai: true });
|
||||
replayButton.hidden = replayPoint(current.journal, turn) === null;
|
||||
replayHint.hidden = replayButton.hidden;
|
||||
replayButton.onclick = () => {
|
||||
|
||||
+164
-3
@@ -21,7 +21,13 @@
|
||||
// dessine l'écran que désigne l'adresse, au démarrage et à chaque
|
||||
// changement. Les actions de jeu restent des boutons.
|
||||
|
||||
import { missingAnalyses, readyForSummary } from "../domain/analysis/game-analysis.js";
|
||||
import {
|
||||
gameTurns,
|
||||
mainAdvice,
|
||||
missingAnalyses,
|
||||
readyForSummary,
|
||||
summarizeAnalysis,
|
||||
} from "../domain/analysis/game-analysis.js";
|
||||
import {
|
||||
type MasterPhrases,
|
||||
masterLine,
|
||||
@@ -42,6 +48,8 @@ import { ADVICE_TEXTS } from "./advice-texts.js";
|
||||
import { type Analyse, createAdviceTracker } from "./advice-tracker.js";
|
||||
import { createAdviceView, VERDICT_ICONS } from "./advice-view.js";
|
||||
import { createAnalysisView } from "./analysis-view.js";
|
||||
import { createPromptView, type PromptResult } from "./ask-ai.js";
|
||||
import { ASK_AI_TEXTS } from "./ask-ai-texts.js";
|
||||
import {
|
||||
boardModel,
|
||||
moveTargets,
|
||||
@@ -78,8 +86,16 @@ import { MATCH_TEXTS } from "./match-texts.js";
|
||||
import { replacingLink, startNavigation } from "./navigation.js";
|
||||
import { createNewGameView } from "./new-game-view.js";
|
||||
import { showWithNotation } from "./notation.js";
|
||||
import { createPromptSource, type PromptsAccess } from "./prompt-source.js";
|
||||
import { createReviewRunner, type RunAnalysis } from "./review-runner.js";
|
||||
import { isHelpScreen, type Route, routeHash } from "./router.js";
|
||||
import {
|
||||
type AiRoute,
|
||||
isAiRoute,
|
||||
isHelpScreen,
|
||||
type Route,
|
||||
routeHash,
|
||||
withoutAi,
|
||||
} from "./router.js";
|
||||
import {
|
||||
chosenGame,
|
||||
isFirstLaunch,
|
||||
@@ -142,6 +158,8 @@ export interface AppOptions {
|
||||
/** L'analyse d'une partie pour son bilan (étape 1e), une analyse à la
|
||||
* fois, sur le même moteur à part. */
|
||||
readonly analyseGame: RunAnalysis;
|
||||
/** Les positions que décrivent les prompts pour une IA (étape 1f). */
|
||||
readonly prompts: PromptsAccess;
|
||||
}
|
||||
|
||||
/** La source des phrases du maître, copiée dans le site (make site). */
|
||||
@@ -229,6 +247,7 @@ export function startApp(options: AppOptions): void {
|
||||
adviceLine: byId<HTMLAnchorElement>("advice-line"),
|
||||
explainVeil: byId("explain-veil"),
|
||||
summaryVeil: byId("summary-veil"),
|
||||
promptVeil: byId("prompt-veil"),
|
||||
analysis: byId("analysis"),
|
||||
menuAdvice: byId<HTMLButtonElement>("menu-advice"),
|
||||
prompt: byId("prompt"),
|
||||
@@ -1308,6 +1327,112 @@ export function startApp(options: AppOptions): void {
|
||||
back: (link) => leavingLink(link, (route) => route.screen === "analysis"),
|
||||
});
|
||||
|
||||
// ------------------------------------------- Demander à une IA (1f)
|
||||
|
||||
// La feuille du prompt, à l'adresse …/ia : à la place de la feuille du
|
||||
// maître ou du bilan, ou dans sa propre feuille au-dessus de l'analyse et
|
||||
// du glossaire. Une seule feuille à la fois.
|
||||
const promptSource = createPromptSource({
|
||||
access: options.prompts,
|
||||
phrases: () => phrasesLoading,
|
||||
glossaryEntry: loadGlossaryEntry,
|
||||
});
|
||||
const promptView = createPromptView({
|
||||
veil: ui.promptVeil,
|
||||
back: () => closePrompt(),
|
||||
close: () => closePromptSheet(),
|
||||
});
|
||||
// L'adresse de la feuille ouverte (null : fermée).
|
||||
let promptShown: string | null = null;
|
||||
|
||||
/** « ‹ Explication », Échap ou le retour du téléphone : retour à l'écran
|
||||
* d'où on l'a ouverte. */
|
||||
function closePrompt(): void {
|
||||
const route = navigation.route;
|
||||
promptView.hide();
|
||||
promptShown = null;
|
||||
navigation.leave(isAiRoute, isAiRoute(route) ? routeHash(withoutAi(route)) : "#/");
|
||||
}
|
||||
|
||||
/** La croix et « Fermer » : la feuille se ferme. Celle du maître ou le
|
||||
* bilan, dont elle avait pris la place, se ferme avec elle. */
|
||||
function closePromptSheet(): void {
|
||||
const route = navigation.route;
|
||||
if (route.screen === "explanation" || route.screen === "summary") {
|
||||
promptView.hide();
|
||||
promptShown = null;
|
||||
leaveSheet();
|
||||
return;
|
||||
}
|
||||
closePrompt();
|
||||
}
|
||||
|
||||
/** Ouvre (ou met à jour) la feuille du prompt de l'adresse route. */
|
||||
function showPrompt(route: AiRoute): void {
|
||||
promptShown = routeHash(route);
|
||||
const host =
|
||||
route.screen === "explanation"
|
||||
? adviceView.sheet
|
||||
: route.screen === "summary"
|
||||
? summaryView.panel
|
||||
: null;
|
||||
promptView.show(promptOf(route), {
|
||||
host,
|
||||
back: ASK_AI_TEXTS.back[route.screen],
|
||||
backHref: routeHash(withoutAi(route)),
|
||||
});
|
||||
}
|
||||
|
||||
/** Le journal de la partie id, pour son bilan ou son analyse : celui de
|
||||
* la partie à l'écran, ou celui du stockage. */
|
||||
async function journalOf(id: string): Promise<GameJournal | null> {
|
||||
const live = liveGame(id);
|
||||
if (live !== null) {
|
||||
return live.journal;
|
||||
}
|
||||
return reviewed?.id === id ? reviewed.journal : options.games.journal(id);
|
||||
}
|
||||
|
||||
/** Le prompt de l'écran sous la feuille (route sans « /ia »). */
|
||||
async function promptOf(route: AiRoute): Promise<PromptResult> {
|
||||
await options.engineReady;
|
||||
switch (route.screen) {
|
||||
case "explanation": {
|
||||
const advice = game?.advice[route.event];
|
||||
return game === null || advice === undefined
|
||||
? { message: ASK_AI_TEXTS.noAdvice }
|
||||
: promptSource.decision(game.journal, advice);
|
||||
}
|
||||
case "summary": {
|
||||
const journal = await journalOf(route.game);
|
||||
if (journal === null) {
|
||||
return { message: ASK_AI_TEXTS.missingGame };
|
||||
}
|
||||
return readyForSummary(journal)
|
||||
? promptSource.summary(journal)
|
||||
: { message: ASK_AI_TEXTS.notReady };
|
||||
}
|
||||
case "analysis": {
|
||||
const journal = await journalOf(route.game);
|
||||
if (journal === null) {
|
||||
return { message: ASK_AI_TEXTS.missingGame };
|
||||
}
|
||||
// Le coup que montre l'analyse : celui de l'adresse, ou le
|
||||
// coup à revoir (analysis-view.ts).
|
||||
const turns = gameTurns(journal);
|
||||
const fallback = summarizeAnalysis(journal).toReview?.turn ?? 1;
|
||||
const n = Math.min(Math.max(1, route.turn ?? fallback), Math.max(1, turns.length));
|
||||
const turn = turns[n - 1];
|
||||
const advice = turn === undefined ? null : mainAdvice(journal, turn);
|
||||
return advice === null
|
||||
? { message: ASK_AI_TEXTS.noAdvice }
|
||||
: promptSource.decision(journal, advice);
|
||||
}
|
||||
case "glossary":
|
||||
return promptSource.concept(route.term ?? "", game?.journal ?? null);
|
||||
}
|
||||
}
|
||||
|
||||
/** La partie à l'écran, si c'est la partie id. */
|
||||
function liveGame(id: string): Game | null {
|
||||
return game !== null && options.games.shownId() === id ? game : null;
|
||||
@@ -1374,6 +1499,10 @@ export function startApp(options: AppOptions): void {
|
||||
if ((route.screen !== "summary" && route.screen !== "analysis") || route.game !== id) {
|
||||
return;
|
||||
}
|
||||
// La feuille du prompt attendait l'analyse : elle se met à jour.
|
||||
if (isAiRoute(route) && promptView.isOpen && !promptView.ready) {
|
||||
showPrompt(route);
|
||||
}
|
||||
const live = liveGame(id);
|
||||
const shown =
|
||||
missing || reviewed?.id !== id
|
||||
@@ -1734,6 +1863,8 @@ export function startApp(options: AppOptions): void {
|
||||
let menuReturnFocus: HTMLElement | null = null;
|
||||
// L'écran sous la feuille d'installation : le dernier écran affiché.
|
||||
let underSheet: Route = { screen: "game" };
|
||||
// L'événement de la feuille d'explication ouverte.
|
||||
let explained: string | null = null;
|
||||
|
||||
/** Dessine l'écran que désigne l'adresse (ADR 0013, amendement). */
|
||||
function applyRoute(route: Route): void {
|
||||
@@ -1813,8 +1944,14 @@ export function startApp(options: AppOptions): void {
|
||||
if (route.screen === "explanation") {
|
||||
closeTransient();
|
||||
ui.menuVeil.hidden = true;
|
||||
void adviceView.show(route.event);
|
||||
// Ouvrir ou fermer la feuille du prompt ne redessine pas
|
||||
// l'explication dessous.
|
||||
if (ui.explainVeil.hidden || explained !== route.event) {
|
||||
explained = route.event;
|
||||
void adviceView.show(route.event);
|
||||
}
|
||||
} else {
|
||||
explained = null;
|
||||
adviceView.hide();
|
||||
}
|
||||
// Le bilan et l'analyse d'une partie : son journal, lu dans le
|
||||
@@ -1868,6 +2005,18 @@ export function startApp(options: AppOptions): void {
|
||||
shownPosition = null;
|
||||
}
|
||||
|
||||
// La feuille du prompt pour une IA, au-dessus de son écran.
|
||||
if (isAiRoute(route)) {
|
||||
closeTransient();
|
||||
ui.menuVeil.hidden = true;
|
||||
if (!promptView.isOpen || promptShown !== routeHash(route)) {
|
||||
showPrompt(route);
|
||||
}
|
||||
} else if (promptView.isOpen) {
|
||||
promptView.hide();
|
||||
promptShown = null;
|
||||
}
|
||||
|
||||
// Une adresse inconnue : la partie, avec un message, à l'adresse de
|
||||
// la partie.
|
||||
if (route.screen === "unknown") {
|
||||
@@ -2307,6 +2456,15 @@ export function startApp(options: AppOptions): void {
|
||||
if (event.altKey || event.metaKey || (event.ctrlKey && event.key.toLowerCase() !== "z")) {
|
||||
return;
|
||||
}
|
||||
// La feuille du prompt ouverte : Échap la ferme, et rien d'autre ne
|
||||
// répond (les flèches font défiler le texte).
|
||||
if (promptView.isOpen) {
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
closePrompt();
|
||||
}
|
||||
return;
|
||||
}
|
||||
const onButton =
|
||||
event.target instanceof HTMLButtonElement || event.target instanceof HTMLAnchorElement;
|
||||
// L'écran d'analyse : les flèches passent d'un coup à l'autre.
|
||||
@@ -2402,6 +2560,9 @@ export function startApp(options: AppOptions): void {
|
||||
if (route.screen === "explanation") {
|
||||
void adviceView.show(route.event);
|
||||
}
|
||||
if (isAiRoute(route)) {
|
||||
showPrompt(route);
|
||||
}
|
||||
if (route.screen === "summary" || route.screen === "analysis") {
|
||||
void openReview(route.game);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
// Les textes de l'interface de « Demander à une IA » (étape 1f). Les
|
||||
// prompts eux-mêmes sont dans docs/prompts/ (une seule source, ADR 0008).
|
||||
//
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
|
||||
export const ASK_AI_TEXTS = {
|
||||
button: "Demander à une IA",
|
||||
title: "Demander à une IA",
|
||||
lead: "Une question toute prête, avec la position et les chiffres de GNU Backgammon. Relisez-la, copiez-la, puis collez-la dans l'IA de votre choix : le moteur décide, l'IA explique.",
|
||||
textLabel: "Le prompt, à relire avant de le copier",
|
||||
copy: "Copier le prompt",
|
||||
copied: "Copié",
|
||||
notCopied:
|
||||
"Le navigateur refuse la copie : le texte est sélectionné, copiez-le avec le menu de votre téléphone.",
|
||||
preparing: "Préparation du prompt…",
|
||||
close: "Fermer",
|
||||
/** « ‹ Explication » : l'écran d'où l'on vient, pour y revenir. */
|
||||
back: {
|
||||
explanation: "Explication",
|
||||
summary: "Bilan",
|
||||
analysis: "Analyse",
|
||||
glossary: "Glossaire",
|
||||
},
|
||||
failed: "Le prompt n'a pas pu être préparé.",
|
||||
noAdvice: "Pas de prompt pour cette décision : le maître ne l'a pas encore analysée.",
|
||||
notReady: "Le maître analyse encore la partie : le prompt sera prêt avec le bilan.",
|
||||
missingGame: "Cette partie n'est plus sur ce téléphone.",
|
||||
missingTerm: "Ce terme n'est pas dans le glossaire.",
|
||||
} as const;
|
||||
@@ -0,0 +1,310 @@
|
||||
// « Demander à une IA » (étape 1f ; charte, ecrans-etape-1.md, « 1f ») :
|
||||
// le bouton, secondaire, avec l'icône de copie ; et la feuille du prompt
|
||||
// (…/ia, ADR 0013), où le texte se relit avant d'être copié.
|
||||
//
|
||||
// Une seule feuille à la fois (recette de 1f) : ouverte depuis la feuille du
|
||||
// maître ou le bilan, la page du prompt prend la place de leur contenu, à la
|
||||
// même hauteur, avec « ‹ Explication » (ou « ‹ Bilan ») pour y revenir ;
|
||||
// depuis l'analyse ou le glossaire, qui ne sont pas des feuilles, elle monte
|
||||
// dans sa propre feuille. La copie passe par navigator.clipboard ; à défaut,
|
||||
// le texte est sélectionné dans la feuille et copié par le navigateur, ou
|
||||
// laissé sélectionné pour être copié à la main. Le bouton la confirme
|
||||
// (« ✓ Copié », 2 s).
|
||||
//
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
//
|
||||
// L'application n'appelle aucune IA et n'envoie rien : elle copie un texte.
|
||||
|
||||
import { ASK_AI_TEXTS } from "./ask-ai-texts.js";
|
||||
import { icon } from "./icons.js";
|
||||
|
||||
/** Durée de « ✓ Copié » sur le bouton (charte : 2 s). */
|
||||
export const COPIED_MS = 2000;
|
||||
|
||||
/** Ce que montre la feuille : le prompt, ou pourquoi il n'y en a pas. */
|
||||
export type PromptResult = { readonly text: string } | { readonly message: string };
|
||||
|
||||
/** Classe de la page du prompt, posée dans une autre feuille. */
|
||||
const PAGE_CLASS = "prompt-page";
|
||||
|
||||
/** Le bouton « Demander à une IA » : un lien vers la feuille du prompt. */
|
||||
export function askAiLink(parent: HTMLElement, id: string, href: string): HTMLAnchorElement {
|
||||
const link = document.createElement("a");
|
||||
link.id = id;
|
||||
link.className = "button secondary ask-ai";
|
||||
link.href = href;
|
||||
link.append(icon("copy"), ASK_AI_TEXTS.button);
|
||||
parent.append(link);
|
||||
return link;
|
||||
}
|
||||
|
||||
/** Vide une feuille qui peut accueillir la page du prompt, sans l'ôter. */
|
||||
export function clearSheet(sheet: HTMLElement): void {
|
||||
for (const child of [...sheet.children]) {
|
||||
if (!child.classList.contains(PAGE_CLASS)) {
|
||||
child.remove();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** D'où l'on vient : la feuille dont la page prend la place (null : sa
|
||||
* propre feuille) et le nom de l'écran, pour « ‹ Explication ». */
|
||||
export interface PromptPlace {
|
||||
readonly host: HTMLElement | null;
|
||||
readonly back: string;
|
||||
/** L'adresse de l'écran d'où l'on vient (le lien de « ‹ … »). */
|
||||
readonly backHref: string;
|
||||
}
|
||||
|
||||
export interface PromptViewOptions {
|
||||
/** Le voile de sa propre feuille (index.html) ; elle est construite ici. */
|
||||
readonly veil: HTMLElement;
|
||||
/** « ‹ Explication » : revenir à l'écran d'où l'on vient. */
|
||||
readonly back: () => void;
|
||||
/** La croix et « Fermer » : fermer la feuille. */
|
||||
readonly close: () => void;
|
||||
}
|
||||
|
||||
export interface PromptView {
|
||||
/** Ouvre la feuille (ou la met à jour) sur un prompt en préparation. */
|
||||
show(prompt: Promise<PromptResult>, place: PromptPlace): void;
|
||||
hide(): void;
|
||||
readonly isOpen: boolean;
|
||||
/** Le prompt est affiché (pas un message). */
|
||||
readonly ready: boolean;
|
||||
}
|
||||
|
||||
function element<K extends keyof HTMLElementTagNameMap>(
|
||||
parent: HTMLElement,
|
||||
tag: K,
|
||||
options: { id?: string; className?: string; text?: string } = {},
|
||||
): HTMLElementTagNameMap[K] {
|
||||
const child = document.createElement(tag);
|
||||
if (options.id !== undefined) {
|
||||
child.id = options.id;
|
||||
}
|
||||
if (options.className !== undefined) {
|
||||
child.className = options.className;
|
||||
}
|
||||
if (options.text !== undefined) {
|
||||
child.textContent = options.text;
|
||||
}
|
||||
parent.append(child);
|
||||
return child;
|
||||
}
|
||||
|
||||
export function createPromptView(options: PromptViewOptions): PromptView {
|
||||
const { veil } = options;
|
||||
// Sa propre feuille, pour l'analyse et le glossaire.
|
||||
const sheet = element(veil, "div", { id: "prompt-sheet", className: "sheet prompt-sheet" });
|
||||
veil.addEventListener("click", (event) => {
|
||||
if (event.target === veil) {
|
||||
options.close();
|
||||
}
|
||||
});
|
||||
// La page du prompt, posée dans sa feuille ou dans celle d'où l'on vient.
|
||||
const page = element(sheet, "div", { id: "prompt-page", className: PAGE_CLASS });
|
||||
page.setAttribute("role", "dialog");
|
||||
page.setAttribute("aria-modal", "true");
|
||||
page.setAttribute("aria-labelledby", "prompt-title");
|
||||
const head = element(page, "div", { className: "sheet-head prompt-head" });
|
||||
element(head, "span", { className: "grip" }).setAttribute("aria-hidden", "true");
|
||||
const back = element(head, "a", { id: "prompt-back", className: "prompt-back" });
|
||||
back.addEventListener("click", (event) => {
|
||||
if (event.button !== 0 || event.ctrlKey || event.metaKey || event.shiftKey) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
options.back();
|
||||
});
|
||||
const closeIcon = element(head, "button", { id: "prompt-close", className: "icon-button" });
|
||||
closeIcon.type = "button";
|
||||
closeIcon.append(icon("close"));
|
||||
closeIcon.setAttribute("aria-label", ASK_AI_TEXTS.close);
|
||||
closeIcon.onclick = () => options.close();
|
||||
element(page, "h2", { id: "prompt-title", className: "sheet-title", text: ASK_AI_TEXTS.title });
|
||||
element(page, "p", { className: "prompt-lead", text: ASK_AI_TEXTS.lead });
|
||||
const text = element(page, "pre", { id: "prompt-text", className: "prompt-text" });
|
||||
text.tabIndex = 0;
|
||||
text.setAttribute("aria-label", ASK_AI_TEXTS.textLabel);
|
||||
const status = element(page, "p", { id: "prompt-status", className: "prompt-status" });
|
||||
status.setAttribute("role", "status");
|
||||
const actions = element(page, "div", { className: "prompt-actions" });
|
||||
const done = element(actions, "button", {
|
||||
id: "prompt-done",
|
||||
className: "secondary",
|
||||
text: ASK_AI_TEXTS.close,
|
||||
});
|
||||
done.type = "button";
|
||||
done.onclick = () => options.close();
|
||||
const copy = element(actions, "button", { id: "prompt-copy" });
|
||||
copy.type = "button";
|
||||
// Le libellé du bouton est annoncé quand il passe à « Copié ».
|
||||
const copyLabel = document.createElement("span");
|
||||
copyLabel.setAttribute("aria-live", "polite");
|
||||
|
||||
// Chaque ouverture a son numéro : un prompt qui arrive après une autre
|
||||
// ouverture, ou après la fermeture, n'est pas montré.
|
||||
let opened = 0;
|
||||
let ready = false;
|
||||
let open = false;
|
||||
// La feuille d'où l'on vient, dont la page a pris la place.
|
||||
let host: HTMLElement | null = null;
|
||||
let copiedTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
/** « Copier le prompt », ou « ✓ Copié » pendant 2 s. */
|
||||
function showCopied(copied: boolean): void {
|
||||
if (copiedTimeout !== null) {
|
||||
clearTimeout(copiedTimeout);
|
||||
copiedTimeout = null;
|
||||
}
|
||||
copy.classList.toggle("copied", copied);
|
||||
copyLabel.textContent = copied ? ASK_AI_TEXTS.copied : ASK_AI_TEXTS.copy;
|
||||
copy.replaceChildren(icon(copied ? "verdictGood" : "copy"), copyLabel);
|
||||
if (copied) {
|
||||
copiedTimeout = setTimeout(() => showCopied(false), COPIED_MS);
|
||||
}
|
||||
}
|
||||
showCopied(false);
|
||||
|
||||
/** Pose la page dans la feuille d'où l'on vient, ou dans la sienne. */
|
||||
function place(next: HTMLElement | null): void {
|
||||
if (host !== next) {
|
||||
host?.classList.remove("prompting");
|
||||
host = next;
|
||||
}
|
||||
if (next === null) {
|
||||
if (page.parentElement !== sheet) {
|
||||
sheet.append(page);
|
||||
}
|
||||
veil.hidden = false;
|
||||
return;
|
||||
}
|
||||
veil.hidden = true;
|
||||
// La page couvre la feuille telle qu'elle s'affiche, défilée en haut.
|
||||
next.scrollTop = 0;
|
||||
next.classList.add("prompting");
|
||||
if (page.parentElement !== next) {
|
||||
next.append(page);
|
||||
}
|
||||
}
|
||||
|
||||
/** Le texte de la feuille, sélectionné : le repli quand le
|
||||
* presse-papiers n'est pas accessible. */
|
||||
function selectText(): void {
|
||||
const selection = window.getSelection();
|
||||
if (selection === null) {
|
||||
return;
|
||||
}
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(text);
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
}
|
||||
|
||||
async function copyText(): Promise<boolean> {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text.textContent ?? "");
|
||||
return true;
|
||||
} catch {
|
||||
// Hors contexte sûr (http sur le réseau local), ou refusé :
|
||||
// repli ci-dessous.
|
||||
}
|
||||
selectText();
|
||||
try {
|
||||
return document.execCommand("copy");
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
copy.onclick = async () => {
|
||||
if (!ready) {
|
||||
return;
|
||||
}
|
||||
const id = opened;
|
||||
const copied = await copyText();
|
||||
if (id !== opened) {
|
||||
return;
|
||||
}
|
||||
if (copied) {
|
||||
status.textContent = "";
|
||||
showCopied(true);
|
||||
} else {
|
||||
selectText();
|
||||
showCopied(false);
|
||||
status.textContent = ASK_AI_TEXTS.notCopied;
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
show(prompt, where) {
|
||||
const id = ++opened;
|
||||
const opening = !open || host !== where.host;
|
||||
open = true;
|
||||
back.textContent = `‹ ${where.back}`;
|
||||
back.href = where.backHref;
|
||||
place(where.host);
|
||||
if (opening) {
|
||||
ready = false;
|
||||
text.textContent = "";
|
||||
text.hidden = true;
|
||||
copy.disabled = true;
|
||||
showCopied(false);
|
||||
status.textContent = ASK_AI_TEXTS.preparing;
|
||||
delete page.dataset.ready;
|
||||
back.focus();
|
||||
}
|
||||
prompt.then(
|
||||
(result) => {
|
||||
if (id !== opened) {
|
||||
return;
|
||||
}
|
||||
if ("text" in result) {
|
||||
ready = true;
|
||||
text.textContent = result.text;
|
||||
text.hidden = false;
|
||||
copy.disabled = false;
|
||||
status.textContent = "";
|
||||
page.dataset.ready = "true";
|
||||
} else {
|
||||
ready = false;
|
||||
text.hidden = true;
|
||||
copy.disabled = true;
|
||||
status.textContent = result.message;
|
||||
delete page.dataset.ready;
|
||||
}
|
||||
},
|
||||
() => {
|
||||
if (id === opened) {
|
||||
ready = false;
|
||||
copy.disabled = true;
|
||||
status.textContent = ASK_AI_TEXTS.failed;
|
||||
}
|
||||
},
|
||||
);
|
||||
},
|
||||
hide() {
|
||||
opened++;
|
||||
ready = false;
|
||||
if (!open) {
|
||||
return;
|
||||
}
|
||||
open = false;
|
||||
showCopied(false);
|
||||
host?.classList.remove("prompting");
|
||||
host = null;
|
||||
veil.hidden = true;
|
||||
sheet.append(page);
|
||||
text.textContent = "";
|
||||
delete page.dataset.ready;
|
||||
},
|
||||
get isOpen() {
|
||||
return open;
|
||||
},
|
||||
get ready() {
|
||||
return ready;
|
||||
},
|
||||
};
|
||||
}
|
||||
+16
-3
@@ -13,6 +13,7 @@
|
||||
// que dit la réponse, ne s'affichent pas
|
||||
// (docs/identite/charte/accueil-tutoriel-aide.md, « Aide »).
|
||||
|
||||
import { askAiLink } from "./ask-ai.js";
|
||||
import {
|
||||
type GlossaryEntry,
|
||||
type HelpFeature,
|
||||
@@ -32,6 +33,7 @@ export const FEATURES = [
|
||||
"docs/scenarios/jouer-un-match.feature",
|
||||
"docs/scenarios/comprendre-mes-coups.feature",
|
||||
"docs/scenarios/revoir-ma-partie.feature",
|
||||
"docs/scenarios/demander-a-une-ia.feature",
|
||||
"docs/scenarios/apprendre-les-regles.feature",
|
||||
"docs/scenarios/mes-parties.feature",
|
||||
"docs/scenarios/partager-une-position.feature",
|
||||
@@ -124,7 +126,10 @@ export function createHelpView(container: HTMLElement, tabs: HTMLElement): HelpV
|
||||
notice.className = "help-notice";
|
||||
notice.setAttribute("role", "status");
|
||||
const questions: Question[] = [];
|
||||
const terms = new Map<string, { dt: HTMLElement; card: HTMLElement; entry: GlossaryCard }>();
|
||||
const terms = new Map<
|
||||
string,
|
||||
{ dt: HTMLElement; card: HTMLElement; entry: GlossaryCard; askAi: HTMLAnchorElement }
|
||||
>();
|
||||
let loading: Promise<boolean> | null = null;
|
||||
// La question ouverte au dernier affichage : refermée, elle reste en vue.
|
||||
let lastOpen: Question | null = null;
|
||||
@@ -213,7 +218,14 @@ export function createHelpView(container: HTMLElement, tabs: HTMLElement): HelpV
|
||||
a.href = routeHash({ screen: "glossary", term: other.name });
|
||||
});
|
||||
}
|
||||
terms.set(entry.name, { dt, card, entry });
|
||||
// « Demander à une IA » (étape 1f), sur la fiche ouverte.
|
||||
const askAi = askAiLink(
|
||||
dd,
|
||||
`glossary-ask-ai-${entry.name}`,
|
||||
routeHash({ screen: "glossary", term: entry.name, ai: true }),
|
||||
);
|
||||
askAi.hidden = true;
|
||||
terms.set(entry.name, { dt, card, entry, askAi });
|
||||
}
|
||||
search.addEventListener("input", () => {
|
||||
let shown = 0;
|
||||
@@ -307,7 +319,8 @@ export function createHelpView(container: HTMLElement, tabs: HTMLElement): HelpV
|
||||
q.section.classList.toggle("open", open);
|
||||
}
|
||||
const term = route.screen === "glossary" ? route.term : null;
|
||||
for (const [name, { dt, card }] of terms) {
|
||||
for (const [name, { dt, card, askAi }] of terms) {
|
||||
askAi.hidden = name !== term;
|
||||
dt.classList.toggle("current", name === term);
|
||||
card.classList.toggle("current", name === term);
|
||||
if (name === term) {
|
||||
|
||||
@@ -26,8 +26,9 @@ export interface Navigation {
|
||||
replace(hash: string): void;
|
||||
/** Quitte l'écran courant : revient à la dernière adresse de l'onglet
|
||||
* qui ne vérifie pas stay, ou, s'il n'y en a pas (lien ouvert dans un
|
||||
* nouvel onglet), remplace l'adresse par celle de la partie. */
|
||||
leave(stay: (route: Route) => boolean): void;
|
||||
* nouvel onglet), remplace l'adresse par fallback (celle de la partie,
|
||||
* par défaut). */
|
||||
leave(stay: (route: Route) => boolean, fallback?: string): void;
|
||||
}
|
||||
|
||||
interface TrailState {
|
||||
@@ -123,13 +124,13 @@ export function startNavigation(onChange: (route: Route) => void): Navigation {
|
||||
},
|
||||
go,
|
||||
replace,
|
||||
leave(stay) {
|
||||
leave(stay, fallback = "#/") {
|
||||
if (leaving !== null) {
|
||||
return; // déjà en route
|
||||
}
|
||||
const steps = stepsBack(trail, stay);
|
||||
if (steps === null) {
|
||||
replace("#/");
|
||||
replace(fallback);
|
||||
return;
|
||||
}
|
||||
// Au cas où le navigateur ne reviendrait pas (historique
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
// Les prompts pour une IA tels que les demande l'interface (étape 1f) :
|
||||
// les gabarits, lus dans la copie de docs/prompts/ faite par make site
|
||||
// (ADR 0008), les phrases du maître, et les positions que le domaine lit
|
||||
// avec le moteur (PromptsAccess, branché par main.ts). Le remplissage
|
||||
// lui-même est dans le domaine (domain/prompts/prompts.ts).
|
||||
//
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
|
||||
import type { MasterPhrases } from "../domain/analysis/phrases.js";
|
||||
import type { Advice } from "../domain/game/advice.js";
|
||||
import type { GameJournal } from "../domain/game/events.js";
|
||||
import type { PromptPosition } from "../domain/prompts/position.js";
|
||||
import {
|
||||
type ConceptExample,
|
||||
conceptPrompt,
|
||||
decisionPrompt,
|
||||
summaryPrompt,
|
||||
} from "../domain/prompts/prompts.js";
|
||||
import {
|
||||
PROMPT_FILES,
|
||||
PROMPT_KINDS,
|
||||
type PromptTemplates,
|
||||
parsePromptTemplate,
|
||||
} from "../domain/prompts/templates.js";
|
||||
import type { PromptResult } from "./ask-ai.js";
|
||||
import { ASK_AI_TEXTS } from "./ask-ai-texts.js";
|
||||
import type { GlossaryEntry } from "./help-text.js";
|
||||
|
||||
/** La copie des gabarits dans le site (make site). */
|
||||
const PROMPTS_DIR = "docs/prompts/";
|
||||
|
||||
/** Ce que les prompts demandent au domaine et au moteur : la position à
|
||||
* décrire. */
|
||||
export interface PromptsAccess {
|
||||
/** La position d'avant une décision de la partie. */
|
||||
readonly before: (journal: GameJournal, eventId: string) => Promise<PromptPosition>;
|
||||
/** La position que montre le prompt du bilan (coup à revoir, ou
|
||||
* position finale). */
|
||||
readonly summary: (journal: GameJournal) => Promise<PromptPosition>;
|
||||
/** La position d'exemple d'un concept et son évaluation. */
|
||||
readonly concept: (journal: GameJournal | null) => Promise<ConceptExample>;
|
||||
}
|
||||
|
||||
export interface PromptSourceOptions {
|
||||
readonly access: PromptsAccess;
|
||||
readonly phrases: () => Promise<MasterPhrases>;
|
||||
/** Une fiche du glossaire, par son nom de fichier. */
|
||||
readonly glossaryEntry: (file: string) => Promise<GlossaryEntry | null>;
|
||||
}
|
||||
|
||||
export interface PromptSource {
|
||||
/** Le prompt d'une décision du joueur (coup ou videau). */
|
||||
decision(journal: GameJournal, advice: Advice): Promise<PromptResult>;
|
||||
/** Le prompt du bilan d'une partie finie et analysée. */
|
||||
summary(journal: GameJournal): Promise<PromptResult>;
|
||||
/** Le prompt d'un terme du glossaire (nom de sa fiche, sans .md). */
|
||||
concept(term: string, journal: GameJournal | null): Promise<PromptResult>;
|
||||
}
|
||||
|
||||
async function fetchText(url: string): Promise<string> {
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
throw new Error(`${url} : ${response.status}`);
|
||||
}
|
||||
return response.text();
|
||||
}
|
||||
|
||||
export function createPromptSource(options: PromptSourceOptions): PromptSource {
|
||||
let loading: Promise<PromptTemplates> | null = null;
|
||||
|
||||
/** Les gabarits, lus une fois (une nouvelle tentative après un
|
||||
* échec). */
|
||||
function templates(): Promise<PromptTemplates> {
|
||||
loading ??= Promise.all(
|
||||
PROMPT_KINDS.map(async (kind) =>
|
||||
parsePromptTemplate(await fetchText(PROMPTS_DIR + PROMPT_FILES[kind])),
|
||||
),
|
||||
).then(
|
||||
([move, cube, summary, concept, exercises]) => ({
|
||||
move: move ?? "",
|
||||
cube: cube ?? "",
|
||||
summary: summary ?? "",
|
||||
concept: concept ?? "",
|
||||
exercises: exercises ?? "",
|
||||
}),
|
||||
(error: unknown) => {
|
||||
loading = null;
|
||||
throw error;
|
||||
},
|
||||
);
|
||||
return loading;
|
||||
}
|
||||
|
||||
return {
|
||||
async decision(journal, advice) {
|
||||
const [all, phrases, position] = await Promise.all([
|
||||
templates(),
|
||||
options.phrases(),
|
||||
options.access.before(journal, advice.eventId),
|
||||
]);
|
||||
return { text: decisionPrompt(all, phrases, { journal, advice, position }) };
|
||||
},
|
||||
async summary(journal) {
|
||||
const [all, phrases, position] = await Promise.all([
|
||||
templates(),
|
||||
options.phrases(),
|
||||
options.access.summary(journal),
|
||||
]);
|
||||
return { text: summaryPrompt(all, phrases, { journal, position }) };
|
||||
},
|
||||
async concept(term, journal) {
|
||||
const [all, entry, example] = await Promise.all([
|
||||
templates(),
|
||||
options.glossaryEntry(`${term}.md`).catch(() => null),
|
||||
options.access.concept(journal),
|
||||
]);
|
||||
if (entry === null) {
|
||||
return { message: ASK_AI_TEXTS.missingTerm };
|
||||
}
|
||||
return {
|
||||
text: conceptPrompt(all, {
|
||||
term: entry.term,
|
||||
english: entry.english,
|
||||
brief: entry.brief,
|
||||
journal,
|
||||
example,
|
||||
}),
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -10,16 +10,12 @@ import type { Roll } from "../domain/game/roll.js";
|
||||
|
||||
const plural = (n: number, one: string, many: string) => (n > 1 ? many : one);
|
||||
|
||||
/** « 41 % ». */
|
||||
export function percent(chance: number): string {
|
||||
return `${Math.round(chance * 100)} %`;
|
||||
}
|
||||
|
||||
/** Un nombre à la française, signé, à trois décimales : « +0,021 ». */
|
||||
export function signed(value: number): string {
|
||||
const text = Math.abs(value).toFixed(3).replace(".", ",");
|
||||
return value < -0.0005 ? `−${text}` : value > 0.0005 ? `+${text}` : text;
|
||||
}
|
||||
/** « 41 % » et « +0,021 » : la même écriture que les prompts
|
||||
* (domain/analysis/numbers.ts). */
|
||||
export {
|
||||
formatEquity as signed,
|
||||
formatPercent as percent,
|
||||
} from "../domain/analysis/numbers.js";
|
||||
|
||||
export const REVIEW_TEXTS = {
|
||||
// Bilan
|
||||
|
||||
+58
-8
@@ -14,8 +14,9 @@ export type Route =
|
||||
| { readonly screen: "home" }
|
||||
/** #/aide, et une question ouverte : #/aide/<identifiant>. */
|
||||
| { readonly screen: "help"; readonly question: string | null }
|
||||
/** #/glossaire, et un terme : #/glossaire/<fiche sans .md>. */
|
||||
| { readonly screen: "glossary"; readonly term: string | null }
|
||||
/** #/glossaire, et un terme : #/glossaire/<fiche sans .md> ; son
|
||||
* prompt pour une IA : #/glossaire/<fiche>/ia. */
|
||||
| { readonly screen: "glossary"; readonly term: string | null; readonly ai?: boolean }
|
||||
/** #/a-propos. */
|
||||
| { readonly screen: "about" }
|
||||
/** #/tutoriel, une étape : #/tutoriel/<n>, et son écran « à vous » :
|
||||
@@ -27,19 +28,27 @@ export type Route =
|
||||
| { readonly screen: "install" }
|
||||
/** #/parties, et une partie : #/parties/<identifiant>. */
|
||||
| { readonly screen: "games"; readonly game: string | null }
|
||||
/** #/parties/<identifiant>/bilan : le bilan de fin de partie (1e). */
|
||||
| { readonly screen: "summary"; readonly game: string }
|
||||
/** #/parties/<identifiant>/bilan : le bilan de fin de partie (1e) ;
|
||||
* son prompt pour une IA : #/parties/<identifiant>/bilan/ia (1f). */
|
||||
| { readonly screen: "summary"; readonly game: string; readonly ai?: boolean }
|
||||
/** #/parties/<identifiant>/analyse/<coup> : l'écran d'analyse, placé
|
||||
* sur un coup (un tour de la partie, à partir de 1) ; sans coup :
|
||||
* #/parties/<identifiant>/analyse. */
|
||||
| { readonly screen: "analysis"; readonly game: string; readonly turn: number | null }
|
||||
* #/parties/<identifiant>/analyse ; le prompt du coup pour une IA :
|
||||
* #/parties/<identifiant>/analyse/<coup>/ia (1f). */
|
||||
| {
|
||||
readonly screen: "analysis";
|
||||
readonly game: string;
|
||||
readonly turn: number | null;
|
||||
readonly ai?: boolean;
|
||||
}
|
||||
/** #/menu. */
|
||||
| { readonly screen: "menu" }
|
||||
/** #/nouvelle-partie : le choix de la partie (type, niveau, conseil). */
|
||||
| { readonly screen: "newGame" }
|
||||
/** #/explication/<événement> : la feuille d'explication du maître sur
|
||||
* une décision de la partie en cours (étape 1d). */
|
||||
| { readonly screen: "explanation"; readonly event: string }
|
||||
* une décision de la partie en cours (étape 1d) ; son prompt pour une
|
||||
* IA : #/explication/<événement>/ia (1f). */
|
||||
| { readonly screen: "explanation"; readonly event: string; readonly ai?: boolean }
|
||||
/** #/position/<Position ID>/<Match ID> (Match ID facultatif). */
|
||||
| { readonly screen: "position"; readonly position: string; readonly match: string | null }
|
||||
/** Une adresse que l'application ne connaît pas. */
|
||||
@@ -71,6 +80,11 @@ export function parseRoute(hash: string): Route {
|
||||
if (rest.at(-1) === "") {
|
||||
rest.pop();
|
||||
}
|
||||
// La feuille du prompt pour une IA (étape 1f) : « /ia » au bout de
|
||||
// l'adresse de l'écran d'où on la demande.
|
||||
if (rest.length > 1 && rest.at(-1) === "ia") {
|
||||
return aiRoute(parseRoute(`#/${[head, ...rest.slice(0, -1)].join("/")}`)) ?? unknown;
|
||||
}
|
||||
if (head === "position") {
|
||||
return positionRoute(rest.join("/")) ?? unknown;
|
||||
}
|
||||
@@ -113,6 +127,37 @@ export function parseRoute(hash: string): Route {
|
||||
}
|
||||
}
|
||||
|
||||
/** Les écrans qui ont une feuille de prompt pour une IA. */
|
||||
export type AiRoute = Extract<
|
||||
Route,
|
||||
{ screen: "explanation" | "summary" | "analysis" | "glossary" }
|
||||
>;
|
||||
|
||||
/** L'écran, avec sa feuille de prompt ouverte (null : écran sans prompt). */
|
||||
function aiRoute(route: Route): AiRoute | null {
|
||||
switch (route.screen) {
|
||||
case "explanation":
|
||||
case "summary":
|
||||
case "analysis":
|
||||
return route.ai === true ? null : { ...route, ai: true };
|
||||
case "glossary":
|
||||
return route.term === null || route.ai === true ? null : { ...route, ai: true };
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** La feuille du prompt pour une IA est-elle ouverte ? */
|
||||
export function isAiRoute(route: Route): route is AiRoute {
|
||||
return "ai" in route && route.ai === true;
|
||||
}
|
||||
|
||||
/** L'écran sous la feuille du prompt. */
|
||||
export function withoutAi(route: AiRoute): AiRoute {
|
||||
const { ai: _, ...below } = route;
|
||||
return below;
|
||||
}
|
||||
|
||||
/** #/parties/<id>/bilan, #/parties/<id>/analyse, #/parties/<id>/analyse/<n>. */
|
||||
function gameReviewRoute(rest: readonly string[]): Route | null {
|
||||
const [game = "", part, turn, ...more] = rest;
|
||||
@@ -179,6 +224,11 @@ function positionRoute(text: string): Route | null {
|
||||
|
||||
/** Le fragment d'un écran, # compris. */
|
||||
export function routeHash(route: Route): string {
|
||||
const hash = screenHash(route);
|
||||
return isAiRoute(route) ? `${hash}/ia` : hash;
|
||||
}
|
||||
|
||||
function screenHash(route: Route): string {
|
||||
switch (route.screen) {
|
||||
case "game":
|
||||
return "#/";
|
||||
|
||||
@@ -28,6 +28,7 @@ import type { GameJournal } from "../domain/game/events.js";
|
||||
import type { Game } from "../domain/game/game.js";
|
||||
import { COMPUTER_LEVELS } from "../domain/game/level.js";
|
||||
import { VERDICT_ICONS } from "./advice-view.js";
|
||||
import { askAiLink, clearSheet } from "./ask-ai.js";
|
||||
import { icon } from "./icons.js";
|
||||
import { MATCH_TEXTS } from "./match-texts.js";
|
||||
import { boardSvg, drawSteps } from "./move-board.js";
|
||||
@@ -64,6 +65,8 @@ export interface SummaryView {
|
||||
show(data: SummaryData | null): void;
|
||||
hide(): void;
|
||||
readonly isOpen: boolean;
|
||||
/** Le panneau, où la page du prompt prend la place du bilan (1f). */
|
||||
readonly panel: HTMLElement;
|
||||
}
|
||||
|
||||
function element<K extends keyof HTMLElementTagNameMap>(
|
||||
@@ -141,7 +144,7 @@ export function createSummaryView(options: SummaryViewOptions): SummaryView {
|
||||
return;
|
||||
}
|
||||
drawn = key;
|
||||
panel.replaceChildren();
|
||||
clearSheet(panel);
|
||||
delete panel.dataset.ready;
|
||||
const head = element(panel, "div", { className: "summary-head" });
|
||||
element(head, "p", { className: "overline", text: TEXTS.endOverline });
|
||||
@@ -299,9 +302,14 @@ export function createSummaryView(options: SummaryViewOptions): SummaryView {
|
||||
text: REVIEW_TEXTS.reviewMove,
|
||||
});
|
||||
revisit.href = routeHash({ screen: "analysis", game: data.id, turn: reviewed.turn });
|
||||
// L'emplacement de « Demander à une IA » (étape 1f).
|
||||
// « Demander à une IA » (étape 1f) : le prompt du bilan.
|
||||
const slot = element(links, "div", { className: "ask-ai-slot" });
|
||||
slot.dataset.slot = "ask-ai";
|
||||
askAiLink(
|
||||
slot,
|
||||
"summary-ask-ai",
|
||||
routeHash({ screen: "summary", game: data.id, ai: true }),
|
||||
);
|
||||
}
|
||||
|
||||
function actions(data: SummaryData): void {
|
||||
@@ -350,7 +358,7 @@ export function createSummaryView(options: SummaryViewOptions): SummaryView {
|
||||
hide() {
|
||||
if (!veil.hidden) {
|
||||
veil.hidden = true;
|
||||
panel.replaceChildren();
|
||||
clearSheet(panel);
|
||||
delete panel.dataset.ready;
|
||||
drawn = "";
|
||||
}
|
||||
@@ -358,5 +366,6 @@ export function createSummaryView(options: SummaryViewOptions): SummaryView {
|
||||
get isOpen() {
|
||||
return !veil.hidden;
|
||||
},
|
||||
panel,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,429 @@
|
||||
// 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.ok(prompt.includes("analyse à 2 plis"), prompt);
|
||||
}
|
||||
});
|
||||
|
||||
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<GameJournal> {
|
||||
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<GameJournal> {
|
||||
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);
|
||||
});
|
||||
@@ -8,12 +8,14 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
import {
|
||||
isAiRoute,
|
||||
isHelpScreen,
|
||||
parseRoute,
|
||||
type Route,
|
||||
routeHash,
|
||||
slug,
|
||||
stepsBack,
|
||||
withoutAi,
|
||||
} from "../src/ui/router.js";
|
||||
|
||||
const START = "4HPwATDgc/ABMA";
|
||||
@@ -41,6 +43,18 @@ const TABLE: [string, Route][] = [
|
||||
["#/menu", { screen: "menu" }],
|
||||
["#/nouvelle-partie", { screen: "newGame" }],
|
||||
["#/explication/12", { screen: "explanation", event: "12" }],
|
||||
// La feuille du prompt pour une IA (amendement du 2026-11-10, 1f).
|
||||
["#/explication/12/ia", { screen: "explanation", event: "12", ai: true }],
|
||||
["#/glossaire/videau/ia", { screen: "glossary", term: "videau", ai: true }],
|
||||
["#/parties/mh2k3x-a1b2c3/bilan/ia", { screen: "summary", game: "mh2k3x-a1b2c3", ai: true }],
|
||||
[
|
||||
"#/parties/mh2k3x-a1b2c3/analyse/7/ia",
|
||||
{ screen: "analysis", game: "mh2k3x-a1b2c3", turn: 7, ai: true },
|
||||
],
|
||||
[
|
||||
"#/parties/mh2k3x-a1b2c3/analyse/ia",
|
||||
{ screen: "analysis", game: "mh2k3x-a1b2c3", turn: null, ai: true },
|
||||
],
|
||||
[
|
||||
"#/position/4HPwATDgc%2FABMA/cAkAAAAAAAAA",
|
||||
{ screen: "position", position: START, match: MATCH },
|
||||
@@ -55,6 +69,17 @@ test("chaque adresse du tableau de l'ADR 0013 mène à son écran, et inversemen
|
||||
}
|
||||
});
|
||||
|
||||
test("la feuille du prompt pour une IA se pose sur son écran, et s'en retire", () => {
|
||||
const route = parseRoute("#/parties/ab/analyse/3/ia");
|
||||
assert.ok(isAiRoute(route));
|
||||
if (isAiRoute(route)) {
|
||||
assert.equal(routeHash(withoutAi(route)), "#/parties/ab/analyse/3");
|
||||
}
|
||||
assert.ok(!isAiRoute(parseRoute("#/parties/ab/analyse/3")));
|
||||
// Un terme nommé « ia » reste un terme.
|
||||
assert.deepEqual(parseRoute("#/glossaire/ia"), { screen: "glossary", term: "ia" });
|
||||
});
|
||||
|
||||
test("une adresse vide ou « # » est la partie en cours ; une barre finale ne change rien", () => {
|
||||
assert.deepEqual(parseRoute(""), { screen: "game" });
|
||||
assert.deepEqual(parseRoute("#"), { screen: "game" });
|
||||
@@ -84,6 +109,10 @@ test("une adresse inconnue est reconnue comme telle", () => {
|
||||
"#/parties/ab/analyse/x",
|
||||
"#/parties/ab/analyse/3/plus",
|
||||
"#/parties/AB/bilan",
|
||||
"#/aide/blot/ia",
|
||||
"#/menu/ia",
|
||||
"#/parties/ab/ia",
|
||||
"#/explication/12/ia/ia",
|
||||
]) {
|
||||
assert.equal(parseRoute(hash).screen, "unknown", hash);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
// Étapes du scénario docs/scenarios/demander-a-une-ia.feature (ADR 0008) :
|
||||
// les décisions se jouent par le domaine sur le vrai moteur, leur analyse
|
||||
// par le domaine, et les prompts sont remplis par le domaine
|
||||
// (domain/prompts/) avec les gabarits de docs/prompts/, comme dans
|
||||
// l'application.
|
||||
//
|
||||
// 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 { BeforeAll, Given, setDefaultTimeout, Then, When } from "@cucumber/cucumber";
|
||||
import { analyseDecision } from "../../src/domain/analysis/analyse.js";
|
||||
import {
|
||||
missingAnalyses,
|
||||
runAnalysis,
|
||||
summarizeAnalysis,
|
||||
} from "../../src/domain/analysis/game-analysis.js";
|
||||
import { formatEquity, rankingRows } from "../../src/domain/analysis/numbers.js";
|
||||
import { diceWords } 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 { parseHalfMoves } from "../../src/domain/game/position.js";
|
||||
import { fixedDice } from "../../src/domain/game/roll.js";
|
||||
import {
|
||||
type PromptPosition,
|
||||
PUBLIC_SITE,
|
||||
positionBefore,
|
||||
} from "../../src/domain/prompts/position.js";
|
||||
import {
|
||||
conceptExample,
|
||||
conceptPrompt,
|
||||
decisionPrompt,
|
||||
summaryPosition,
|
||||
summaryPrompt,
|
||||
} from "../../src/domain/prompts/prompts.js";
|
||||
import {
|
||||
PROMPT_FILES,
|
||||
type PromptTemplates,
|
||||
parsePromptTemplate,
|
||||
} from "../../src/domain/prompts/templates.js";
|
||||
import { parseGlossaryEntry } from "../../src/ui/help-text.js";
|
||||
import { MATCH_TEXTS } from "../../src/ui/match-texts.js";
|
||||
import { loadPhrases, playOpening } from "../support/advice.js";
|
||||
import { loadNodeEngine } from "../support/node-engine.js";
|
||||
|
||||
setDefaultTimeout(60_000);
|
||||
|
||||
const root = new URL("../../../../", import.meta.url);
|
||||
const read = (path: string) => readFileSync(new URL(path, root), "utf8");
|
||||
const phrases = loadPhrases();
|
||||
const templates: PromptTemplates = {
|
||||
move: parsePromptTemplate(read(`docs/prompts/${PROMPT_FILES.move}`)),
|
||||
cube: parsePromptTemplate(read(`docs/prompts/${PROMPT_FILES.cube}`)),
|
||||
summary: parsePromptTemplate(read(`docs/prompts/${PROMPT_FILES.summary}`)),
|
||||
concept: parsePromptTemplate(read(`docs/prompts/${PROMPT_FILES.concept}`)),
|
||||
exercises: parsePromptTemplate(read(`docs/prompts/${PROMPT_FILES.exercises}`)),
|
||||
};
|
||||
|
||||
/** Le joueur au trait : un pion sur son point 6, un sur son point 1 ;
|
||||
* l'ordinateur : un sur son point 1, un sur son point 2. */
|
||||
const BEAROFF = "BQAACAIAAAAAAA";
|
||||
|
||||
let engine: Engine;
|
||||
let game: Game | null;
|
||||
let journal: GameJournal;
|
||||
let advice: Advice;
|
||||
let position: PromptPosition;
|
||||
let prompt: string;
|
||||
|
||||
BeforeAll(async () => {
|
||||
engine = await loadNodeEngine();
|
||||
});
|
||||
|
||||
Given(/^vous avez joué (.+) avec un (\d)-(\d) d'ouverture$/, async (move, a, b) => {
|
||||
const played = await playOpening(engine, [Number(a), Number(b)], move);
|
||||
const analysed = await analyseDecision(engine, played.game.journal, played.eventId);
|
||||
assert.ok(analysed !== null);
|
||||
// Le conseil rattaché au journal, comme dans l'application.
|
||||
played.game.recordAdvice(analysed);
|
||||
game = played.game;
|
||||
journal = played.game.journal;
|
||||
advice = analysed;
|
||||
});
|
||||
|
||||
When("vous demandez à une IA d'expliquer ce coup", async () => {
|
||||
position = await positionBefore(engine, journal, advice.eventId);
|
||||
prompt = decisionPrompt(templates, phrases, { journal, advice, position });
|
||||
});
|
||||
|
||||
Then("la question donne la position, avec son Position ID, son Match ID et son lien", () => {
|
||||
assert.equal(position.positionId, advice.position);
|
||||
assert.ok(prompt.includes(`Position ID : ${position.positionId}`), prompt);
|
||||
assert.ok(prompt.includes(`Match ID : ${position.matchId}`), prompt);
|
||||
assert.ok(prompt.includes(`${PUBLIC_SITE}#/position/`), prompt);
|
||||
assert.ok(prompt.includes("Mes pions (X), case (nombre) : 24 (2), 13 (5), 8 (3), 6 (5)."));
|
||||
});
|
||||
|
||||
Then(
|
||||
"elle donne les trois meilleurs coups du maître et le vôtre, avec les chiffres de l'explication",
|
||||
() => {
|
||||
assert.equal(advice.kind, "move");
|
||||
if (advice.kind !== "move") {
|
||||
return;
|
||||
}
|
||||
// Les lignes de « Voir les chiffres du moteur » : coup et écart.
|
||||
const rows = rankingRows(advice).filter((row) => row.rank <= 3 || row.played);
|
||||
assert.ok(rows.length >= 3);
|
||||
for (const row of rows) {
|
||||
assert.ok(
|
||||
prompt.includes(`${row.rank}. ${row.move.notation}`) &&
|
||||
prompt.includes(`écart ${formatEquity(row.loss)}`),
|
||||
`${row.move.notation} absent :\n${prompt}`,
|
||||
);
|
||||
}
|
||||
assert.ok(prompt.includes(`J'ai joué ${advice.played.notation} : `), prompt);
|
||||
},
|
||||
);
|
||||
|
||||
Then("elle demande à l'IA de ne pas contredire ces chiffres, et de répondre en français", () => {
|
||||
assert.ok(prompt.includes("ne les recalcule pas et ne les contredis pas"), prompt);
|
||||
assert.ok(prompt.includes("Réponds en français"), prompt);
|
||||
assert.ok(!/[{}]/.test(prompt.replace(/```/g, "")), prompt);
|
||||
});
|
||||
|
||||
/** Toutes les analyses qui manquent, une à une, comme l'application en
|
||||
* arrière-plan. */
|
||||
async function analyseAll(start: GameJournal): Promise<GameJournal> {
|
||||
let current = start;
|
||||
for (let i = 0; i < 100; 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");
|
||||
}
|
||||
|
||||
Given(
|
||||
/^une partie finie où vous avez joué (.+) avec (\d)-(\d), puis passé le double de l'ordinateur$/,
|
||||
async (move: string, a: string, b: string) => {
|
||||
const played = Game.start(engine, {
|
||||
setup: { position: BEAROFF, firstTurn: "player" },
|
||||
level: "2ply",
|
||||
dice: fixedDice([Number(a), Number(b)]),
|
||||
});
|
||||
await played.roll();
|
||||
for (const halfMove of parseHalfMoves(move)) {
|
||||
assert.ok(await played.playHalfMove(halfMove), `demi-coup refusé : ${move}`);
|
||||
}
|
||||
await played.confirmMove();
|
||||
await played.playComputerTurn();
|
||||
assert.equal(played.state.phase, "doubled", "l'ordinateur ne double pas");
|
||||
await played.drop();
|
||||
assert.equal(played.state.phase, "over");
|
||||
journal = await analyseAll(played.journal);
|
||||
},
|
||||
);
|
||||
|
||||
When("vous demandez à une IA le bilan de votre partie", async () => {
|
||||
position = await summaryPosition(engine, journal);
|
||||
prompt = summaryPrompt(templates, phrases, { journal, position });
|
||||
});
|
||||
|
||||
Then("la question donne vos décisions, votre niveau et la chance des dés, comme le bilan", () => {
|
||||
const summary = summarizeAnalysis(journal);
|
||||
const { counts, player } = summary;
|
||||
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(`soit le niveau ${MATCH_TEXTS.levels[player.errorRate.level].name}`),
|
||||
prompt,
|
||||
);
|
||||
assert.ok(prompt.includes(`(${diceWords(phrases, player.luck.rating)})`), prompt);
|
||||
assert.ok(prompt.includes("Résultat : l'ordinateur a gagné"), prompt);
|
||||
});
|
||||
|
||||
Then(/^elle cite votre coup (.+), le coup du maître (.+) et leur écart$/, (mine, best) => {
|
||||
const toReview = summarizeAnalysis(journal).toReview;
|
||||
assert.ok(toReview !== null);
|
||||
assert.ok(
|
||||
prompt.includes(
|
||||
`j'ai joué ${mine}, GNU Backgammon préférait ${best} ; écart ${formatEquity(toReview.advice.equityLoss)} (Grosse erreur).`,
|
||||
),
|
||||
prompt,
|
||||
);
|
||||
});
|
||||
|
||||
Then("elle donne la position du coup à revoir", () => {
|
||||
const toReview = summarizeAnalysis(journal).toReview;
|
||||
assert.equal(position.positionId, toReview?.advice.position);
|
||||
assert.ok(prompt.includes(`Position ID : ${position.positionId}`), prompt);
|
||||
assert.ok(prompt.includes("Le coup à revoir, ma plus grosse erreur (coup 1)"), prompt);
|
||||
});
|
||||
|
||||
Given("aucune partie n'est en cours", () => {
|
||||
game = null;
|
||||
});
|
||||
|
||||
When(/^vous demandez à une IA d'expliquer le mot « (.+) »$/, async (term: string) => {
|
||||
const entry = parseGlossaryEntry(read(`docs/glossaire/${term.toLowerCase()}.md`));
|
||||
assert.ok(entry !== null);
|
||||
const example = await conceptExample(engine, game?.journal ?? null, 2);
|
||||
position = example.position;
|
||||
prompt = conceptPrompt(templates, {
|
||||
term: entry.term,
|
||||
english: entry.english,
|
||||
brief: entry.brief,
|
||||
journal: game?.journal ?? null,
|
||||
example,
|
||||
});
|
||||
});
|
||||
|
||||
Then("la question reprend la définition de sa fiche", () => {
|
||||
const entry = parseGlossaryEntry(read("docs/glossaire/videau.md"));
|
||||
assert.ok(entry !== null && prompt.includes(entry.brief), prompt);
|
||||
assert.ok(prompt.includes("le concept « Videau » (en anglais : Cube)"), prompt);
|
||||
});
|
||||
|
||||
Then(
|
||||
"elle l'illustre par la position de départ, avec les chances de gagner selon GNU Backgammon",
|
||||
() => {
|
||||
assert.ok(prompt.includes("voici la position de départ"), prompt);
|
||||
assert.ok(prompt.includes("Position ID : 4HPwATDgc/ABMA"), prompt);
|
||||
assert.match(prompt, /chances de gagner \d+ %/);
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,82 @@
|
||||
// Affiche des prompts remplis sur de vraies analyses du moteur, pour les
|
||||
// relire (étape 1f) : node build/web/tests/tools/prompt-sample.js
|
||||
//
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
|
||||
import { readFileSync } from "node:fs";
|
||||
import {
|
||||
missingAnalyses,
|
||||
runAnalysis,
|
||||
summarizeAnalysis,
|
||||
} from "../../src/domain/analysis/game-analysis.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 { positionBefore } from "../../src/domain/prompts/position.js";
|
||||
import {
|
||||
decisionPrompt,
|
||||
summaryPosition,
|
||||
summaryPrompt,
|
||||
} from "../../src/domain/prompts/prompts.js";
|
||||
import {
|
||||
PROMPT_FILES,
|
||||
type PromptTemplates,
|
||||
parsePromptTemplate,
|
||||
} from "../../src/domain/prompts/templates.js";
|
||||
import { adviceOn, loadPhrases, 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 read = (file: string) =>
|
||||
parsePromptTemplate(readFileSync(new URL(`docs/prompts/${file}`, root), "utf8"));
|
||||
const templates: PromptTemplates = {
|
||||
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 phrases = loadPhrases();
|
||||
const engine: Engine = await loadNodeEngine();
|
||||
|
||||
const played = await playOpening(engine, [3, 1], "24/23 13/10");
|
||||
const advice = await adviceOn(engine, played);
|
||||
played.game.recordAdvice(advice);
|
||||
const position = await positionBefore(engine, played.game.journal, played.eventId);
|
||||
console.log(decisionPrompt(templates, phrases, { journal: played.game.journal, advice, position }));
|
||||
|
||||
async function analyseAll(journal: GameJournal): Promise<GameJournal> {
|
||||
let current = journal;
|
||||
for (;;) {
|
||||
const next = missingAnalyses(current)[0];
|
||||
if (next === undefined) {
|
||||
return current;
|
||||
}
|
||||
current = await runAnalysis(engine, current, next);
|
||||
}
|
||||
}
|
||||
|
||||
for (const [seed, choice] of [
|
||||
[7, 1],
|
||||
[7, 2],
|
||||
[11, 2],
|
||||
[3, 3],
|
||||
]) {
|
||||
const game = Game.start(engine, {
|
||||
setup: { position: "trsXAADs7mwAAA", firstTurn: "player" },
|
||||
level: "intermediate",
|
||||
dice: seededDice(seed ?? 0),
|
||||
});
|
||||
await playToTheEnd(game, engine, choice);
|
||||
const journal = await analyseAll(game.journal);
|
||||
const summary = summarizeAnalysis(journal);
|
||||
console.log(
|
||||
`\n=== graine ${seed}, choix ${choice} : coup à revoir ${summary.toReview?.turn ?? "aucun"}`,
|
||||
);
|
||||
if (seed === 3) {
|
||||
const where = await summaryPosition(engine, journal);
|
||||
console.log(summaryPrompt(templates, phrases, { journal, position: where }));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user