diff --git a/Makefile b/Makefile index 0cbc40b..cc88fe2 100644 --- a/Makefile +++ b/Makefile @@ -127,8 +127,9 @@ SITE_ICONS := icone.svg icone-192.png icone-512.png icone-maskable-192.png icone # docs/identite/logo/ (une seule source, ADR 0010) ; # - l'application compilée (build/web/src/) sous app/ ; # - le moteur sous wasm/ ; -# - les copies du glossaire, des scénarios, du tutoriel et des phrases du -# maître sous docs/, lues par l'aide, le tutoriel et le conseil du maître +# - les copies du glossaire, des scénarios, du tutoriel, des phrases du +# maître et des prompts pour une IA sous docs/, lues par l'aide, le +# tutoriel, le conseil du maître et « Demander à une IA » # (ADR 0008 : jamais commitées # ailleurs) ; # - version.json (commit publié) ; @@ -142,7 +143,7 @@ site: engine-wasm web ## Assemble le site publié dans build/site/ cp -r $(BUILD_DIR)/web/src $(BUILD_DIR)/site/app cp $(BUILD_DIR)/wasm/engine.mjs $(BUILD_DIR)/wasm/engine.wasm $(BUILD_DIR)/wasm/engine.data \ $(BUILD_DIR)/wasm/positions.json $(BUILD_DIR)/site/wasm/ - cp -r docs/glossaire docs/scenarios docs/tutoriel docs/maitre $(BUILD_DIR)/site/docs/ + cp -r docs/glossaire docs/scenarios docs/tutoriel docs/maitre docs/prompts $(BUILD_DIR)/site/docs/ printf '{"commit": "%s", "dirty": %s}\n' "$$(git rev-parse HEAD)" \ "$$(git diff --quiet HEAD && echo false || echo true)" > $(BUILD_DIR)/site/version.json scripts/service-worker.sh $(BUILD_DIR)/site diff --git a/app/src/domain/analysis/numbers.ts b/app/src/domain/analysis/numbers.ts new file mode 100644 index 0000000..8c29335 --- /dev/null +++ b/app/src/domain/analysis/numbers.ts @@ -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; +} diff --git a/app/src/domain/prompts/position.ts b/app/src/domain/prompts/position.ts new file mode 100644 index 0000000..1121fac --- /dev/null +++ b/app/src/domain/prompts/position.ts @@ -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 { + 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 { + return positionOf(engine, replay(journal === null ? [] : currentLine(journal))); +} + +async function positionOf(engine: Engine, state: GameState): Promise { + // 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 { + 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//, + * 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): 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): 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}`; +} diff --git a/app/src/domain/prompts/prompts.ts b/app/src/domain/prompts/prompts.ts new file mode 100644 index 0000000..15f82c6 --- /dev/null +++ b/app/src/domain/prompts/prompts.ts @@ -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> = { + 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> = { + 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 | 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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`, + }); +} diff --git a/app/src/domain/prompts/templates.ts b/app/src/domain/prompts/templates.ts new file mode 100644 index 0000000..c725b49 --- /dev/null +++ b/app/src/domain/prompts/templates.ts @@ -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> = { + move: "coup.md", + cube: "videau.md", + summary: "bilan.md", + concept: "concept.md", + exercises: "exercices.md", +}; + +/** Les gabarits lus, par sorte. */ +export type PromptTemplates = Readonly>; + +/** 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>): 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; + }); +} diff --git a/app/src/main.ts b/app/src/main.ts index b1aaed8..cbcfc31 100644 --- a/app/src/main.ts +++ b/app/src/main.ts @@ -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), }); diff --git a/app/src/ui/advice-texts.ts b/app/src/ui/advice-texts.ts index 37dc21a..cc309de 100644 --- a/app/src/ui/advice-texts.ts +++ b/app/src/ui/advice-texts.ts @@ -48,8 +48,6 @@ export const CUBE_GLOSSARY: Readonly 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"; diff --git a/app/src/ui/advice-view.ts b/app/src/ui/advice-view.ts index 7b3fc6b..d3207b6 100644 --- a/app/src/ui/advice-view.ts +++ b/app/src/ui/advice-view.ts @@ -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 { routeHash } from "./router.js"; @@ -46,6 +48,8 @@ export interface AdviceView { /** Ouvre la feuille sur le conseil de l'événement eventId. */ show(eventId: string): Promise; hide(): void; + /** La feuille, où la page du prompt prend la place du contenu (1f). */ + readonly sheet: HTMLElement; } type Shown = "mine" | "best"; @@ -90,7 +94,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"); @@ -148,12 +152,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", @@ -276,18 +289,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), @@ -331,12 +334,13 @@ export function createAdviceView(options: AdviceViewOptions): AdviceView { return { show, + sheet, hide() { opened++; player?.stop(); if (!veil.hidden) { veil.hidden = true; - sheet.replaceChildren(); + clearSheet(sheet); } }, }; diff --git a/app/src/ui/analysis-view.ts b/app/src/ui/analysis-view.ts index 03827c1..3f290fe 100644 --- a/app/src/ui/analysis-view.ts +++ b/app/src/ui/analysis-view.ts @@ -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 = () => { diff --git a/app/src/ui/app.ts b/app/src/ui/app.ts index a4cb543..93b8ae6 100644 --- a/app/src/ui/app.ts +++ b/app/src/ui/app.ts @@ -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, @@ -41,6 +47,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, @@ -76,8 +84,16 @@ import { installPlatform, startInstaller } from "./install.js"; import { MATCH_TEXTS } from "./match-texts.js"; import { replacingLink, startNavigation } from "./navigation.js"; import { createNewGameView } from "./new-game-view.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, @@ -140,6 +156,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). */ @@ -227,6 +245,7 @@ export function startApp(options: AppOptions): void { adviceLine: byId("advice-line"), explainVeil: byId("explain-veil"), summaryVeil: byId("summary-veil"), + promptVeil: byId("prompt-veil"), analysis: byId("analysis"), menuAdvice: byId("menu-advice"), prompt: byId("prompt"), @@ -1297,6 +1316,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 { + 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 { + 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; @@ -1363,6 +1488,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 @@ -1711,6 +1840,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 { @@ -1790,8 +1921,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 @@ -1845,6 +1982,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") { @@ -2282,6 +2431,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. @@ -2377,6 +2535,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); } diff --git a/app/src/ui/ask-ai-texts.ts b/app/src/ui/ask-ai-texts.ts new file mode 100644 index 0000000..7ba435a --- /dev/null +++ b/app/src/ui/ask-ai-texts.ts @@ -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; diff --git a/app/src/ui/ask-ai.ts b/app/src/ui/ask-ai.ts new file mode 100644 index 0000000..1d3e517 --- /dev/null +++ b/app/src/ui/ask-ai.ts @@ -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, place: PromptPlace): void; + hide(): void; + readonly isOpen: boolean; + /** Le prompt est affiché (pas un message). */ + readonly ready: boolean; +} + +function element( + 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 | 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 { + 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; + }, + }; +} diff --git a/app/src/ui/help.ts b/app/src/ui/help.ts index d374108..552ec67 100644 --- a/app/src/ui/help.ts +++ b/app/src/ui/help.ts @@ -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(); + const terms = new Map< + string, + { dt: HTMLElement; card: HTMLElement; entry: GlossaryCard; askAi: HTMLAnchorElement } + >(); let loading: Promise | 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) { diff --git a/app/src/ui/navigation.ts b/app/src/ui/navigation.ts index c47d913..8c59e06 100644 --- a/app/src/ui/navigation.ts +++ b/app/src/ui/navigation.ts @@ -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 diff --git a/app/src/ui/prompt-source.ts b/app/src/ui/prompt-source.ts new file mode 100644 index 0000000..d5cec80 --- /dev/null +++ b/app/src/ui/prompt-source.ts @@ -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; + /** La position que montre le prompt du bilan (coup à revoir, ou + * position finale). */ + readonly summary: (journal: GameJournal) => Promise; + /** La position d'exemple d'un concept et son évaluation. */ + readonly concept: (journal: GameJournal | null) => Promise; +} + +export interface PromptSourceOptions { + readonly access: PromptsAccess; + readonly phrases: () => Promise; + /** Une fiche du glossaire, par son nom de fichier. */ + readonly glossaryEntry: (file: string) => Promise; +} + +export interface PromptSource { + /** Le prompt d'une décision du joueur (coup ou videau). */ + decision(journal: GameJournal, advice: Advice): Promise; + /** Le prompt du bilan d'une partie finie et analysée. */ + summary(journal: GameJournal): Promise; + /** Le prompt d'un terme du glossaire (nom de sa fiche, sans .md). */ + concept(term: string, journal: GameJournal | null): Promise; +} + +async function fetchText(url: string): Promise { + 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 | null = null; + + /** Les gabarits, lus une fois (une nouvelle tentative après un + * échec). */ + function templates(): Promise { + 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, + }), + }; + }, + }; +} diff --git a/app/src/ui/review-texts.ts b/app/src/ui/review-texts.ts index bc99509..ce4de4b 100644 --- a/app/src/ui/review-texts.ts +++ b/app/src/ui/review-texts.ts @@ -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 diff --git a/app/src/ui/router.ts b/app/src/ui/router.ts index 18c1ee9..584f6a2 100644 --- a/app/src/ui/router.ts +++ b/app/src/ui/router.ts @@ -14,8 +14,9 @@ export type Route = | { readonly screen: "home" } /** #/aide, et une question ouverte : #/aide/. */ | { readonly screen: "help"; readonly question: string | null } - /** #/glossaire, et un terme : #/glossaire/. */ - | { readonly screen: "glossary"; readonly term: string | null } + /** #/glossaire, et un terme : #/glossaire/ ; son + * prompt pour une IA : #/glossaire//ia. */ + | { readonly screen: "glossary"; readonly term: string | null; readonly ai?: boolean } /** #/a-propos. */ | { readonly screen: "about" } /** #/tutoriel, une étape : #/tutoriel/, et son écran « à vous » : @@ -27,19 +28,27 @@ export type Route = | { readonly screen: "install" } /** #/parties, et une partie : #/parties/. */ | { readonly screen: "games"; readonly game: string | null } - /** #/parties//bilan : le bilan de fin de partie (1e). */ - | { readonly screen: "summary"; readonly game: string } + /** #/parties//bilan : le bilan de fin de partie (1e) ; + * son prompt pour une IA : #/parties//bilan/ia (1f). */ + | { readonly screen: "summary"; readonly game: string; readonly ai?: boolean } /** #/parties//analyse/ : l'écran d'analyse, placé * sur un coup (un tour de la partie, à partir de 1) ; sans coup : - * #/parties//analyse. */ - | { readonly screen: "analysis"; readonly game: string; readonly turn: number | null } + * #/parties//analyse ; le prompt du coup pour une IA : + * #/parties//analyse//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// (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//bilan, #/parties//analyse, #/parties//analyse/. */ 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 "#/"; diff --git a/app/src/ui/summary-view.ts b/app/src/ui/summary-view.ts index 3b22ecf..e09356c 100644 --- a/app/src/ui/summary-view.ts +++ b/app/src/ui/summary-view.ts @@ -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( @@ -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, }; } diff --git a/app/tests/prompts.test.ts b/app/tests/prompts.test.ts new file mode 100644 index 0000000..3583492 --- /dev/null +++ b/app/tests/prompts.test.ts @@ -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 { + let current = journal; + for (let i = 0; i < 500; i++) { + const next = missingAnalyses(current)[0]; + if (next === undefined) { + return current; + } + current = await runAnalysis(engine, current, next); + } + throw new Error("l'analyse ne finit pas"); +} + +/** Une course courte jouée jusqu'au bout et analysée (le joueur prend le + * choice-ième coup légal). */ +async function analysedRace(engine: Engine, seed: number, choice: number): Promise { + const game = Game.start(engine, { + setup: { position: "trsXAADs7mwAAA", firstTurn: "player" }, + level: "intermediate", + dice: seededDice(seed), + }); + await playToTheEnd(game, engine, choice); + return analyseAll(engine, game.journal); +} + +test("prompt du bilan : résultat, décomptes, taux et chance de l'écran, erreurs", async () => { + const engine = await loadNodeEngine(); + const journal = await analysedRace(engine, 3, 3); + const summary = summarizeAnalysis(journal); + assert.ok(summary.toReview !== null, "la partie devrait avoir une erreur"); + const position = await summaryPosition(engine, journal); + const prompt = summaryPrompt(templates, phrases, { journal, position }); + assertComplete(prompt, position); + const { player, computer, counts } = summary; + // Les chiffres de « Voir les chiffres du moteur » de l'analyse, et les + // trois cases du bilan. + assert.ok( + prompt.includes( + `Mon erreur par décision : ${signed(-player.errorRate.perDecision)} sur ${player.errorRate.decisions} décision`, + ), + ); + assert.ok( + prompt.includes(`soit le niveau ${MATCH_TEXTS.levels[player.errorRate.level].name} ;`), + ); + assert.ok( + prompt.includes(`celle de l'ordinateur : ${signed(-computer.errorRate.perDecision)} sur`), + ); + assert.ok( + prompt.includes( + `La chance de mes dés par tour : ${signed(player.luck.perRoll)} (${diceWords(phrases, player.luck.rating)})`, + ), + ); + assert.ok(prompt.includes(`celle de l'ordinateur : ${signed(computer.luck.perRoll)}.`)); + assert.ok(prompt.includes(`Mes décisions : ${counts.good} bon`), prompt); + assert.ok(prompt.includes(`, ${counts.bad + counts.veryBad} erreur`), prompt); + assert.ok(prompt.includes("Résultat : "), prompt); + assert.ok(prompt.includes("contre l'ordinateur (niveau Intermédiaire)"), prompt); + // Le coup à revoir : sa position, et sa ligne avec son écart. + const worst = summary.toReview.advice; + assert.equal(position.positionId, worst.position); + assert.ok( + prompt.includes(`Le coup à revoir, ma plus grosse erreur (coup ${summary.toReview.turn})`), + ); + assert.ok( + prompt.includes( + `écart ${formatEquity(worst.equityLoss)} (${masterWords(phrases, worst).word})`, + ), + prompt, + ); +}); + +test("prompt du bilan sans erreur : la position finale", async () => { + const engine = await loadNodeEngine(); + // Le joueur au trait : un pion sur 6, un sur 1 ; il joue 6/off 1/off et + // gagne (scénario « Revoir ma partie »). + const game = Game.start(engine, { + setup: { position: "BQAACAIAAAAAAA", firstTurn: "player" }, + dice: () => 6, + }); + await game.roll(); + const moves = await engine.legalMoves(game.state.position, game.state.roll ?? [6, 6]); + for (const halfMove of moves[0]?.halfMoves ?? []) { + await game.playHalfMove(halfMove); + } + await game.confirmMove(); + assert.equal(game.state.phase, "over"); + const journal = await analyseAll(engine, game.journal); + const position = await summaryPosition(engine, journal); + const prompt = summaryPrompt(templates, phrases, { journal, position }); + assertComplete(prompt, position); + assert.ok(prompt.includes("- aucune : j'ai joué comme GNU Backgammon."), prompt); + assert.ok(prompt.includes("Voici la position finale"), prompt); + assert.ok(prompt.includes("j'ai gagné une partie simple, 1 point"), prompt); +}); + +test("prompt d'un concept : la position de départ, ou celle de la partie en cours", async () => { + const engine = await loadNodeEngine(); + const alone = await conceptExample(engine, null, 2); + const prompt = conceptPrompt(templates, { + term: "Videau", + english: "Cube", + brief: "le dé des enchères.", + journal: null, + example: alone, + }); + assertComplete(prompt, alone.position); + assert.ok(prompt.includes("le concept « Videau » (en anglais : Cube)."), prompt); + assert.ok(prompt.includes("voici la position de départ"), prompt); + assert.ok(prompt.includes("précis. Je débute au backgammon.\n"), prompt); + assert.match(prompt, /chances de gagner \d+ %, équité [+−]?\d,\d{3}/); + + const played = await playOpening(engine, [3, 1], "24/23 13/10"); + played.game.recordAdvice(await adviceOn(engine, played)); + const during = await conceptExample(engine, played.game.journal, 2); + assert.ok(during.fromGame); + const second = conceptPrompt(templates, { + term: "Blot", + english: "Blot", + brief: "un pion seul sur une case.", + journal: played.game.journal, + example: during, + }); + assertComplete(second, during.position); + assert.ok(second.includes("le concept « Blot »."), second); + assert.ok(second.includes("une position de ma partie en cours"), second); + // Une seule décision analysée : pas de niveau, pas de taux d'erreur. + assert.ok(second.includes("Je débute au backgammon."), second); + assert.ok(!second.includes("erreur par décision"), second); +}); + +test("prompt des exercices : les erreurs de plusieurs parties, la plus coûteuse en position", async () => { + const engine = await loadNodeEngine(); + const journals = [await analysedRace(engine, 3, 3), await analysedRace(engine, 7, 2)]; + const position = await exercisesPosition(engine, journals); + assert.ok(position !== null); + const prompt = exercisesPrompt(templates, phrases, { journals, position }); + assertComplete(prompt, position); + assert.ok(prompt.includes("Voici mes 2 dernières parties"), prompt); + assert.match(prompt, /- Partie [12], coup \d+/); + assert.ok(prompt.includes("La plus coûteuse (partie"), prompt); + assert.ok(prompt.includes("cinq exercices"), prompt); + assert.equal(await exercisesPosition(engine, []), null); +}); diff --git a/app/tests/router.test.ts b/app/tests/router.test.ts index b976dd6..814acb3 100644 --- a/app/tests/router.test.ts +++ b/app/tests/router.test.ts @@ -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); } diff --git a/app/tests/steps/demander-a-une-ia.steps.ts b/app/tests/steps/demander-a-une-ia.steps.ts new file mode 100644 index 0000000..b9aa31e --- /dev/null +++ b/app/tests/steps/demander-a-une-ia.steps.ts @@ -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 { + 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+ %/); + }, +); diff --git a/app/tests/tools/prompt-sample.ts b/app/tests/tools/prompt-sample.ts new file mode 100644 index 0000000..72b50fc --- /dev/null +++ b/app/tests/tools/prompt-sample.ts @@ -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 { + 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 })); + } +} diff --git a/docs/adr/0008-glossaire-et-scenarios-source-unique.md b/docs/adr/0008-glossaire-et-scenarios-source-unique.md index 67e9034..17d440f 100644 --- a/docs/adr/0008-glossaire-et-scenarios-source-unique.md +++ b/docs/adr/0008-glossaire-et-scenarios-source-unique.md @@ -108,3 +108,13 @@ avec Gherkin et l'exécution de bout en bout par l'interface. - **Perspective** : si les scénarios peuvent un jour être joués dans une instance réelle de l'application, la même exécution produira les captures d'écran de l'aide, toujours à jour avec la version installée. + +### 2026-11-10 — Les prompts pour une IA, même principe + +Avec « Demander à une IA » (étape 1f), les gabarits des prompts suivent la +même règle que le glossaire, les scénarios, le tutoriel et les phrases du +maître : une seule source, `docs/prompts/` (un fichier par gabarit, sous +CC BY-SA 4.0), copiée dans le site par `make site` et lue par l'application +à l'exécution ; jamais recopiée dans le code. `make test` remplit chaque +gabarit avec de vraies analyses du moteur (`app/tests/prompts.test.ts`) et +le scénario `docs/scenarios/demander-a-une-ia.feature` en fait l'aide. diff --git a/docs/adr/0013-position-dans-l-adresse.md b/docs/adr/0013-position-dans-l-adresse.md index e443000..693abce 100644 --- a/docs/adr/0013-position-dans-l-adresse.md +++ b/docs/adr/0013-position-dans-l-adresse.md @@ -159,3 +159,36 @@ non, lancer, jouer), à partir de 1, sur sa ligne courante. - Comme la feuille d'explication, ces adresses n'ont de sens que sur l'appareil qui a joué la partie. Une partie absente donne un message, jamais une page cassée. + +### 2026-11-10 — Adresses de la feuille du prompt pour une IA + +Ajoutées avec « Demander à une IA » (étape 1f), dans le même schéma. La +feuille montre le prompt pour qu'on le relise avant de le copier : c'est un +écran à part entière, elle a donc son adresse, celle de l'écran d'où on la +demande suivie de `/ia` : + +| Écran | Adresse | +| --- | --- | +| Prompt d'une décision, depuis la feuille d'explication du maître | `#/explication/<événement>/ia` | +| Prompt du bilan d'une partie | `#/parties//bilan/ia` | +| Prompt de la décision d'un coup, depuis l'analyse | `#/parties//analyse//ia` (ou `…/analyse/ia`, sur le coup à revoir) | +| Prompt d'un terme du glossaire | `#/glossaire//ia` | + +- Une seule feuille à la fois : depuis la feuille du maître ou le bilan, + la page du prompt prend la place de leur contenu, à la même hauteur ; + depuis l'analyse ou le glossaire, elle monte dans sa propre feuille. + « ‹ Explication » (« ‹ Bilan », « ‹ Analyse », « ‹ Glossaire »), Échap et + le retour du téléphone reviennent à l'écran d'où on l'a demandée ; la + croix et « Fermer » ferment la feuille, et avec elle la feuille du maître + ou le bilan dont elle avait pris la place. Ouverte par son adresse, sans + historique, le retour remplace l'adresse par celle de l'écran d'avant. +- Rechargée, l'adresse rouvre la même feuille : le prompt est recalculé à + partir du journal enregistré. Comme ses écrans, elle n'a de sens que sur + l'appareil qui a joué la partie (sauf pour le glossaire). Une décision + sans conseil, une partie absente ou pas encore analysée donne un message, + jamais une page cassée. +- La confirmation de la copie (« Copié » sur le bouton, 2 s) n'a pas + d'adresse. +- Le prompt contient, lui, le lien public de la position + (`https://ustagammon.sans.pub/#/position//`), + qui se partage. diff --git a/docs/etape-1.md b/docs/etape-1.md index 0caed13..34e3972 100644 --- a/docs/etape-1.md +++ b/docs/etape-1.md @@ -22,7 +22,7 @@ Repris du cadrage (modules, écrans clés, IA générative en V1) et des ADR 001 | 1c. Match et réglages (fait) | Partie libre ou match en N points, règle Crawford, règle Jacoby, choix du niveau de l'ordinateur, conseil du maître activable ou non ; écran de nouvelle partie | 1a, 1b | | 1d. Conseil du maître (fait) | Après chaque coup du joueur, jamais avant (ADR 0011) : appréciation du moteur (bon coup, imprécision, erreur, grosse erreur) avec mot, icône et couleur, phrase du maître fondée sur les chiffres du moteur, coup recommandé montré sur demande ; aussi pour les décisions de videau ; calcul en arrière-plan sans retarder le jeu | 1a | | 1e. Fin de partie et analyse (fait) | Bilan (ADR 0011) : résultat, coups par appréciation, taux d'erreur comparé à la chance, « le coup à revoir » ; écran d'analyse : graphe d'équité (toucher un point ouvre la position), liste des erreurs filtrable, visualiseur pas à pas coup joué contre meilleur coup, « Rejouer d'ici » | 1a, 1b, 1d | -| 1f. Prompts à copier | Gabarits versionnés et testés (`docs/prompts/` ou équivalent, source unique), bouton « Demander à une IA » sur le plateau, le conseil du maître, l'analyse et la fin de partie ; le moteur décide, l'IA explique | 1d, 1e | +| 1f. Prompts à copier (fait) | Gabarits versionnés et testés (`docs/prompts/` ou équivalent, source unique), bouton « Demander à une IA » sur le plateau, le conseil du maître, l'analyse et la fin de partie ; le moteur décide, l'IA explique | 1d, 1e | | 1g. Accueil, tutoriel et leçons | Accueil (reprendre, nouvelle partie, prochaine leçon, progression résumée), tutoriel des règles interactif et illustré (0e.5), cinq premières leçons numérotées (ADR 0011) avec positions et « à vous de jouer », progression locale ; contenus CC BY-SA 4.0 en brouillon, à relire par un joueur fort | 0e.5, 1a | | 1h. Progression | Historique des parties et courbe du taux d'erreur | 1b, 1e | | 1i. Plateau soigné | Dessin du plateau aligné sur la charte (`docs/identite/charte/board.js`), animations des pions, glisser-déposer en plus du toucher, double toucher pour le seul coup évident | 0e.3 | diff --git a/docs/glossaire/README.md b/docs/glossaire/README.md index ddf8fe0..fb393df 100644 --- a/docs/glossaire/README.md +++ b/docs/glossaire/README.md @@ -67,6 +67,7 @@ exportée. `make test` vérifie le lien dans les deux sens : chaque fiche avec | [Pli](pli.md) | Ply | | [Point](point.md) | Point | | [Prendre](prendre.md) | Take | +| [Prompt](prompt.md) | Prompt | | [Règle de Crawford](regle-de-crawford.md) | Crawford rule | | [Règle de Jacoby](regle-de-jacoby.md) | Jacoby rule | | [SGF](sgf.md) | SGF | diff --git a/docs/glossaire/prompt.md b/docs/glossaire/prompt.md new file mode 100644 index 0000000..5d8754c --- /dev/null +++ b/docs/glossaire/prompt.md @@ -0,0 +1,16 @@ +--- +term: Prompt +english: Prompt +code: Prompt +--- +# Prompt + +**En bref** : la question toute prête que l'application copie pour une IA (« Demander à une IA ») : la position, les chiffres de GNU Backgammon, et ce qu'on demande ; il suffit de la coller dans l'IA de votre choix. + +L'application n'appelle aucune IA et n'envoie rien : elle prépare le texte, +vous le relisez, vous le copiez. Le moteur décide, l'IA explique : le prompt +lui demande de ne pas contredire les évaluations de GNU Backgammon, qui +restent celles que l'application affiche. Il contient aussi le lien de la +position, pour la retrouver dans l'application. + +Voir aussi : [Lien de position](lien-de-position.md), [Moteur](moteur.md), [Conseil du maître](conseil-du-maitre.md) diff --git a/docs/identite/charte/ecrans-etape-1.md b/docs/identite/charte/ecrans-etape-1.md index a22b8b6..95fc8d8 100644 --- a/docs/identite/charte/ecrans-etape-1.md +++ b/docs/identite/charte/ecrans-etape-1.md @@ -206,9 +206,21 @@ Sur ordinateur, `←` et `→` passent d'un coup à l'autre. Le bouton est secondaire partout, jamais l'action principale, avec l'icône de copie : dans la feuille du maître (1d), dans la carte « Le coup à revoir » du bilan, dans le visualiseur de l'analyse, et dans une fiche du -glossaire. Après la copie, le bandeau « Prompt copié » (`verdict-good`, 2 s), -puis le texte du prompt reste lisible dans une zone `surface-sunken` -repliable, pour qui veut le relire avant de le coller (composant `AskAI`). +glossaire. Le bouton ouvre la feuille du prompt : le texte y est lisible +dans une zone `surface-sunken`, pour qui veut le relire avant de le copier +(composant `AskAI`). + +Recette de 1f : + +- **Une seule feuille à la fois.** Depuis la feuille du maître, la feuille + du prompt en remplace le contenu, à la même hauteur, avec « ‹ Explication » + en haut à gauche pour revenir ; de même depuis le bilan (« ‹ Bilan »). + Depuis l'analyse et le glossaire, elle monte seule (« ‹ Analyse », + « ‹ Glossaire »). Le retour du téléphone ramène à l'écran précédent. +- **La copie se confirme sur le bouton**, sans bandeau : « Copier le + prompt » devient « ✓ Copié » (coche, `verdict-good`) pendant 2 s, puis + revient. Si le navigateur refuse la copie, le texte est sélectionné et + une phrase invite à le copier avec le menu du téléphone. ## 1g. Leçons (`#/lecons`, `#/lecons//<écran>`) diff --git a/docs/plans/2026-11-03-prompts.md b/docs/plans/2026-11-03-prompts.md deleted file mode 100644 index f369520..0000000 --- a/docs/plans/2026-11-03-prompts.md +++ /dev/null @@ -1,42 +0,0 @@ -# Prompts à copier (1f) - -- **Date** : 2026-11-03 -- **Étape** : 1f (`docs/etape-1.md`) -- **Statut** : prévu (après 1e) -- **Exécutant** : sous-agent general-purpose, dans un worktree - -## Objectif - -Mettre en place la section « IA générative en V1 » du cadrage. Un bouton « Demander à une IA » copie un prompt rempli avec le contexte exact. Le moteur décide, l'IA explique. L'app n'appelle aucune IA. - -## Consignes - -1. **Gabarits.** Les cinq gabarits du cadrage sont versionnés dans une source unique sous `docs/prompts/`, sous licence CC BY-SA 4.0 : - - « pourquoi ce coup est meilleur » ; - - « expliquer cette décision de videau » ; - - « bilan de ma partie » ; - - « expliquer ce concept » ; - - « créer des exercices », à garder pour la progression. - - Chaque gabarit contient : - - le rôle et le niveau du joueur ; - - le Position ID et le Match ID ; - - un diagramme texte de la position, produit à partir du plateau décodé par le moteur ; - - les chiffres du moteur, avec le niveau d'évaluation ; - - la consigne de ne pas contredire ces chiffres ; - - la langue. - - Ajouter aussi le lien `#/position/...` de l'ADR 0013, avec l'adresse publique du site. -2. **Remplissage.** Une fonction pure remplit les gabarits. Elle est testée sur des analyses réelles du moteur : aucun champ vide ni `{inconnu}`, et les chiffres sont identiques à ceux du moteur. -3. **Boutons.** Ils sont placés sur le conseil du maître (feuille d'explication), le bilan, l'analyse et la fiche d'un terme du glossaire. La copie passe par `navigator.clipboard`, avec un repli de sélection du texte. Un bandeau « Prompt copié » l'accompagne, et le texte reste visible pour qui veut le relire. -4. **Tests.** - - Scénarios Gherkin : copier le prompt d'une erreur, et celui d'un bilan. - - `make lint`, `make test` et `make test-browser`, avec lecture du presse-papiers dans Chromium et captures. - -## Critères de réussite - -Le prompt copié après une erreur contient la position, les identifiants gnubg et le top 3 du moteur avec les mêmes chiffres que l'app. Les trois commandes passent. - -## Résultat - -À remplir à la clôture. diff --git a/docs/prompts/README.md b/docs/prompts/README.md new file mode 100644 index 0000000..1758b5b --- /dev/null +++ b/docs/prompts/README.md @@ -0,0 +1,58 @@ +# Les prompts à copier pour une IA + +Les textes que l'application copie dans le presse-papiers quand on touche +« Demander à une IA » (étape 1f ; `docs/cadrage.md`, « IA générative en +V1 »). L'application n'appelle aucune IA : elle prépare une question +complète, que le joueur colle dans l'IA de son choix. Règle d'or : **le +moteur décide, l'IA explique**. + +Une seule source (ADR 0008) : `make site` copie ce dossier dans le site +publié (`docs/prompts/`), l'application lit les gabarits à l'exécution +(`app/src/domain/prompts/`), et `make test` remplit chacun d'eux avec de +vraies analyses du moteur (`app/tests/prompts.test.ts`) : aucun repère +laissé vide, et les chiffres du prompt sont ceux que l'application affiche. + +Textes sous licence **CC BY-SA 4.0**. Version 1, rédigée par Claude Code +d'après le cadrage, **à relire** par Pierre. + +## Les gabarits + +| Fichier | Bouton | Où | +| --- | --- | --- | +| [coup.md](coup.md) | Pourquoi ce coup est meilleur | Feuille du maître, bilan, analyse | +| [videau.md](videau.md) | Expliquer cette décision de videau | Feuille du maître, bilan, analyse | +| [bilan.md](bilan.md) | Bilan de ma partie | Bilan de fin de partie | +| [concept.md](concept.md) | Expliquer ce concept | Fiche d'un terme du glossaire | +| [exercices.md](exercices.md) | Créer des exercices | Progression (étape 1h, pas encore de bouton) | + +Chaque prompt contient : + +- le rôle de l'IA (un coach) et, quand il est mesuré, le niveau du joueur ; +- la position : Position ID et Match ID de GNU Backgammon, le lien qui + l'ouvre dans l'application (`https://ustagammon.sans.pub/#/position/…`, + ADR 0013) et un diagramme texte dessiné d'après le plateau décodé par le + moteur ; +- les chiffres du moteur, avec le niveau d'évaluation (en plis) ; +- la consigne de ne pas recalculer ni contredire ces chiffres ; +- la langue de la réponse. + +## Format + +Dans chaque fichier, le gabarit est le bloc de texte qui suit le titre +`## Gabarit`. Le reste du fichier n'est pas lu. Les mots entre accolades sont +remplis par l'application ; un repère inconnu ou une valeur vide est une +erreur, vérifiée par les tests. + +Repères communs à plusieurs gabarits : + +| Repère | Ce qu'il devient | +| --- | --- | +| `{joueur}` | « Je suis un joueur de niveau Avancé. », le niveau que donne GNU Backgammon d'après l'erreur par décision, seulement à partir de 20 décisions analysées ; en dessous, « Je débute au backgammon. ». Le taux d'erreur lui-même n'apparaît que dans le bilan. | +| `{position_id}`, `{match_id}` | les identifiants de GNU Backgammon de la position | +| `{au_trait}` | « moi » ou « l'ordinateur » : celui dont les identifiants décrivent le point de vue | +| `{lien}` | l'adresse de la position dans l'application publiée | +| `{diagramme}` | le plateau en texte : vos pions X, ceux de l'ordinateur O, dans votre numérotation | +| `{score}` | « partie libre » ou le score du match | +| `{videau}` | sa valeur et qui le possède | +| `{niveau_eval}` | le niveau d'évaluation de GNU Backgammon, « analyse à 2 plis » | +| `{langue}` | la langue de la réponse : « français » | diff --git a/docs/prompts/bilan.md b/docs/prompts/bilan.md new file mode 100644 index 0000000..c022628 --- /dev/null +++ b/docs/prompts/bilan.md @@ -0,0 +1,44 @@ +# Bilan de ma partie + +Bouton « Demander à une IA » de la carte « Le coup à revoir » du bilan de +fin de partie. Texte sous licence CC BY-SA 4.0 (voir +[README.md](README.md)). + +Repères propres à ce gabarit : + +| Repère | Ce qu'il devient | +| --- | --- | +| `{ordinateur}` | le niveau de l'ordinateur, « Intermédiaire » | +| `{resultat}` | le résultat de la partie, et le score du match s'il y en a un | +| `{decomptes}` | vos décisions par appréciation, comme les trois cases du bilan | +| `{taux}`, `{decisions}`, `{niveau_jeu}` | votre erreur par décision, le nombre de décisions comptées et le niveau correspondant, comme « Voir les chiffres du moteur » de l'analyse | +| `{taux_ordinateur}` | la même mesure pour l'ordinateur | +| `{chance}`, `{chance_mots}`, `{chance_ordinateur}` | la chance par tour de vos dés, en chiffres et en mots, et celle de l'ordinateur | +| `{erreurs}` | vos imprécisions et erreurs, coup par coup, avec leur coût | +| `{position_titre}` | ce que montre la position : le coup à revoir, ou la position finale s'il n'y en a pas | + +## Gabarit + +```text +Tu es un coach de backgammon, bienveillant et précis. {joueur} + +Je viens de finir une partie contre l'ordinateur (niveau {ordinateur}), analysée par GNU Backgammon ({niveau_eval}). +Résultat : {resultat}. +Mes décisions : {decomptes}. +Mon erreur par décision : {taux} sur {decisions}, soit le niveau {niveau_jeu} ; celle de l'ordinateur : {taux_ordinateur}. +La chance de mes dés par tour : {chance} ({chance_mots}) ; celle de l'ordinateur : {chance_ordinateur}. + +Mes imprécisions et erreurs, avec leur coût en équité : +{erreurs} + +{position_titre} (GNU Backgammon, Position ID : {position_id}, Match ID : {match_id} ; ces identifiants la décrivent du point de vue du joueur qui a la main : {au_trait}). +Elle s'ouvre aussi à cette adresse : {lien} + +{diagramme} + +Score : {score}. Videau : {videau}. + +Ces évaluations font foi : ne les recalcule pas et ne les contredis pas. +À partir de ce bilan, identifie trois thèmes que je devrais travailler en priorité, avec pour chacun un conseil concret. +Réponds en {langue}, simplement, en moins de 300 mots. +``` diff --git a/docs/prompts/concept.md b/docs/prompts/concept.md new file mode 100644 index 0000000..76355ba --- /dev/null +++ b/docs/prompts/concept.md @@ -0,0 +1,36 @@ +# Expliquer ce concept + +Bouton « Demander à une IA » sur la fiche d'un terme du glossaire (« Les +mots du jeu »). La position d'exemple est celle de la partie en cours, ou la +position de départ s'il n'y en a pas. Texte sous licence CC BY-SA 4.0 (voir +[README.md](README.md)). + +Repères propres à ce gabarit : + +| Repère | Ce qu'il devient | +| --- | --- | +| `{concept}` | le terme, et son nom anglais s'il en a un | +| `{definition}` | la phrase « En bref » de sa fiche | +| `{position_titre}` | « une position de ma partie en cours » ou « la position de départ » | +| `{evaluation}` | les chances de gagner et l'équité du joueur au trait, avant de lancer les dés | + +## Gabarit + +```text +Tu es un coach de backgammon, bienveillant et précis. {joueur} + +Explique-moi le concept {concept}. +Mon application le définit ainsi : {definition} + +Pour l'illustrer, voici {position_titre} (GNU Backgammon, Position ID : {position_id}, Match ID : {match_id} ; ces identifiants la décrivent du point de vue du joueur qui a la main : {au_trait}). +Elle s'ouvre aussi à cette adresse : {lien} + +{diagramme} + +Score : {score}. Videau : {videau}. +Évaluation de GNU Backgammon ({niveau_eval}), pour le joueur qui a la main ({au_trait}), avant de lancer les dés : {evaluation}. + +Ces évaluations font foi : ne les recalcule pas et ne les contredis pas. +Explique ce concept simplement, avec une analogie de la vie courante, puis dis s'il joue un rôle dans cette position et comment. +Réponds en {langue}, en moins de 250 mots. +``` diff --git a/docs/prompts/coup.md b/docs/prompts/coup.md new file mode 100644 index 0000000..47fc1a5 --- /dev/null +++ b/docs/prompts/coup.md @@ -0,0 +1,36 @@ +# Pourquoi ce coup est meilleur + +Bouton « Demander à une IA » sur un coup du joueur : feuille d'explication +du maître, carte « Le coup à revoir » du bilan, visualiseur de l'analyse. +Texte sous licence CC BY-SA 4.0 (voir [README.md](README.md)). + +Repères propres à ce gabarit : + +| Repère | Ce qu'il devient | +| --- | --- | +| `{des}` | le jet joué, « 3-1 » | +| `{coup_joue}` | votre coup, dans la notation de GNU Backgammon | +| `{appreciation}` | le mot du maître : « Bon coup », « Imprécision », « Erreur » ou « Grosse erreur » | +| `{rang}` | « le meilleur coup » ou « le 4e choix sur 15 coups possibles » | +| `{classement}` | le classement du moteur : les trois meilleurs coups, et le vôtre s'il n'en est pas, avec leur équité et leur écart au meilleur, comme dans « Voir les chiffres du moteur » | + +## Gabarit + +```text +Tu es un coach de backgammon, bienveillant et précis. {joueur} + +Voici la position avant mon coup (GNU Backgammon, Position ID : {position_id}, Match ID : {match_id} ; ces identifiants la décrivent du point de vue du joueur qui a la main : {au_trait}). +Elle s'ouvre aussi à cette adresse : {lien} + +{diagramme} + +Score : {score}. Videau : {videau}. Dés : {des}. + +J'ai joué {coup_joue} : {rang}, jugé « {appreciation} ». +Classement de GNU Backgammon ({niveau_eval}), équités du point de vue du joueur qui joue : +{classement} + +Ces évaluations font foi : ne les recalcule pas et ne les contredis pas. +Explique-moi pourquoi le meilleur coup est supérieur au mien : quels concepts sont en jeu, et ce que je dois retenir pour la prochaine fois. +Réponds en {langue}, simplement, en moins de 200 mots. +``` diff --git a/docs/prompts/exercices.md b/docs/prompts/exercices.md new file mode 100644 index 0000000..50eb59f --- /dev/null +++ b/docs/prompts/exercices.md @@ -0,0 +1,36 @@ +# Créer des exercices + +Prévu pour l'écran de progression (étape 1h) : le gabarit est écrit et +testé, sans bouton pour l'instant. Il rassemble les erreurs de vos +dernières parties analysées. Texte sous licence CC BY-SA 4.0 (voir +[README.md](README.md)). + +Repères propres à ce gabarit : + +| Repère | Ce qu'il devient | +| --- | --- | +| `{parties}` | le nombre de parties analysées | +| `{erreurs}` | vos erreurs les plus coûteuses sur ces parties, avec leur coût | +| `{position_titre}` | la plus coûteuse d'entre elles | + +## Gabarit + +```text +Tu es un coach de backgammon, bienveillant et précis. {joueur} + +Voici mes {parties} dernières parties contre l'ordinateur, analysées par GNU Backgammon ({niveau_eval}). + +Mes erreurs les plus coûteuses, avec leur coût en équité : +{erreurs} + +{position_titre} (GNU Backgammon, Position ID : {position_id}, Match ID : {match_id} ; ces identifiants la décrivent du point de vue du joueur qui a la main : {au_trait}). +Elle s'ouvre aussi à cette adresse : {lien} + +{diagramme} + +Score : {score}. Videau : {videau}. + +Ces évaluations font foi : ne les recalcule pas et ne les contredis pas. +Repère les thèmes qui reviennent dans ces erreurs, puis propose-moi cinq exercices ciblés : pour chacun, le thème, une position type décrite simplement, et ce que je dois y chercher. +Réponds en {langue}, en moins de 350 mots. +``` diff --git a/docs/prompts/videau.md b/docs/prompts/videau.md new file mode 100644 index 0000000..d5ddb02 --- /dev/null +++ b/docs/prompts/videau.md @@ -0,0 +1,40 @@ +# Expliquer cette décision de videau + +Bouton « Demander à une IA » sur une décision de videau du joueur (doubler, +lancer sans doubler, prendre, passer) : feuille d'explication du maître, +carte « Le coup à revoir » du bilan, visualiseur de l'analyse. Texte sous +licence CC BY-SA 4.0 (voir [README.md](README.md)). + +Repères propres à ce gabarit : + +| Repère | Ce qu'il devient | +| --- | --- | +| `{decision}` | la question posée : « doubler ou lancer les dés sans doubler » ou « prendre ou passer le double de l'ordinateur » | +| `{choix}` | votre décision, « passer » | +| `{choix_moteur}` | la décision que préfère GNU Backgammon | +| `{appreciation}` | le mot du maître : « Bonne décision », « Imprécision », « Erreur » ou « Grosse erreur » | +| `{qui_double}` | « moi » ou « l'ordinateur » : celui qui double ou non | +| `{equites}` | les équités sans double, double et prise, double et refus, comme dans « Voir les chiffres du moteur » | +| `{ecart}` | l'équité perdue par votre décision | + +## Gabarit + +```text +Tu es un coach de backgammon, bienveillant et précis. {joueur} + +Voici la position au moment d'une décision de videau (GNU Backgammon, Position ID : {position_id}, Match ID : {match_id} ; ces identifiants la décrivent du point de vue du joueur qui a la main : {au_trait}). +Elle s'ouvre aussi à cette adresse : {lien} + +{diagramme} + +Score : {score}. Videau : {videau}. + +Il fallait choisir : {decision}. J'ai choisi de {choix}, jugé « {appreciation} » ; GNU Backgammon aurait choisi de {choix_moteur}. +Équités calculées par GNU Backgammon ({niveau_eval}), du point de vue de celui qui double ou non ({qui_double}), pour un videau de 1 : +{equites} +Écart de ma décision : {ecart}. + +Ces évaluations font foi : ne les recalcule pas et ne les contredis pas. +Explique-moi cette décision : pourquoi doubler ou non, prendre ou passer, quels repères de la position comptent, et ce que je dois retenir. +Réponds en {langue}, simplement, en moins de 200 mots. +``` diff --git a/docs/scenarios/README.md b/docs/scenarios/README.md index 20c5adf..7eac25d 100644 --- a/docs/scenarios/README.md +++ b/docs/scenarios/README.md @@ -59,6 +59,7 @@ Règles d'écriture : | [jouer-un-match.feature](jouer-un-match.feature) | Choisir sa partie (libre ou match, niveau) et jouer un match | Étape 1c | | [comprendre-mes-coups.feature](comprendre-mes-coups.feature) | Comprendre ses coups avec le conseil du maître | Étape 1d | | [revoir-ma-partie.feature](revoir-ma-partie.feature) | Revoir sa partie : bilan, coup à revoir, analyse, rejouer d'ici | Étape 1e | +| [demander-a-une-ia.feature](demander-a-une-ia.feature) | Demander à une IA d'expliquer un coup, une partie ou un mot | Étape 1f | | [mes-parties.feature](mes-parties.feature) | Retrouver mes parties, leurs branches, les exporter en SGF | Étape 1b | | [partager-une-position.feature](partager-une-position.feature) | Ouvrir et partager le lien d'une position | Étape 0e.4 | | [regler-l-application.feature](regler-l-application.feature) | Régler l'application (menu), la mettre à jour | Étape 0e | diff --git a/docs/scenarios/demander-a-une-ia.feature b/docs/scenarios/demander-a-une-ia.feature new file mode 100644 index 0000000..3597b2e --- /dev/null +++ b/docs/scenarios/demander-a-une-ia.feature @@ -0,0 +1,45 @@ +# language: fr +Fonctionnalité: Demander à une IA d'expliquer un coup, une partie ou un mot + + Le maître dit quel coup il aurait joué ; pour comprendre pourquoi, vous + pouvez demander à une IA de votre choix. Touchez « Demander à une IA » + dans l'explication du maître, sur le bilan, dans l'analyse ou sur la fiche + d'un mot du jeu : l'application prépare une question complète, avec la + position, son lien et les chiffres de GNU Backgammon. Relisez-la, copiez-la, + puis collez-la dans l'IA. L'application n'appelle aucune IA et n'envoie + rien : le moteur décide, l'IA explique, et la question lui demande de ne + pas contredire les chiffres du moteur. + + Scénario: Comment demander à une IA d'expliquer mon erreur ? + Touchez « Demander à une IA » dans l'explication du maître : la question + donne la position avant votre coup, dessinée en texte, ses identifiants + de GNU Backgammon et son lien, puis les trois meilleurs coups du maître et + le vôtre, avec les mêmes chiffres que l'explication du maître. + + Étant donné que vous avez joué 24/23 13/10 avec un 3-1 d'ouverture + Quand vous demandez à une IA d'expliquer ce coup + Alors la question donne la position, avec son Position ID, son Match ID et son lien + Et elle donne les trois meilleurs coups du maître et le vôtre, avec les chiffres de l'explication + Et elle demande à l'IA de ne pas contredire ces chiffres, et de répondre en français + + Scénario: Comment demander à une IA le bilan de ma partie ? + Sur le bilan, « Demander à une IA » prépare une question sur toute la + partie : le résultat, vos décisions par appréciation, votre niveau, la + chance des dés, vos erreurs et leur coût, et la position du coup à + revoir. L'IA vous propose trois thèmes à travailler. + + Étant donné une partie finie où vous avez joué 6/5 5/off avec 6-1, puis passé le double de l'ordinateur + Quand vous demandez à une IA le bilan de votre partie + Alors la question donne vos décisions, votre niveau et la chance des dés, comme le bilan + Et elle cite votre coup 6/off, le coup du maître 6/off 1/off et leur écart + Et elle donne la position du coup à revoir + + Scénario: Puis-je demander à une IA d'expliquer un mot du jeu ? + Oui : sur la fiche d'un mot du glossaire, la question reprend sa + définition et l'illustre par la position de votre partie en cours, ou par + la position de départ, avec l'évaluation de GNU Backgammon. + + Étant donné qu'aucune partie n'est en cours + Quand vous demandez à une IA d'expliquer le mot « Videau » + Alors la question reprend la définition de sa fiche + Et elle l'illustre par la position de départ, avec les chances de gagner selon GNU Backgammon diff --git a/tests/browser.sh b/tests/browser.sh index 9867947..bb8eaed 100755 --- a/tests/browser.sh +++ b/tests/browser.sh @@ -11,10 +11,11 @@ # Le conseil du maître (tests/browser/maitre.mjs), captures dans # build/browser-shots/maitre/. Le bilan de fin de partie et l'écran # d'analyse (tests/browser/bilan.mjs), captures dans -# build/browser-shots/bilan/. +# build/browser-shots/bilan/. « Demander à une IA » (tests/browser/prompts.mjs), +# captures dans build/browser-shots/prompts/. # -# BROWSERS=chromium et SCRIPTS=accueil (ou play, match, maitre, bilan) -# restreignent le test. +# BROWSERS=chromium et SCRIPTS=accueil (ou play, match, maitre, bilan, +# prompts) restreignent le test. # # Outils : l'image de Playwright (navigateurs) et le paquet playwright, # installé une fois par npm dans build/browser-tools/ (réseau nécessaire la @@ -27,7 +28,7 @@ cd "$(dirname "$0")/.." image="${PLAYWRIGHT_IMAGE:?lancer via make test-browser}" browsers="${BROWSERS:-chromium firefox}" # Les scripts à lancer (tests/browser/.mjs), tous par défaut. -scripts="${SCRIPTS:-play accueil match maitre bilan}" +scripts="${SCRIPTS:-play accueil match maitre bilan prompts}" unset MAKEFLAGS MFLAGS MAKELEVEL make -s package >/dev/null @@ -65,7 +66,7 @@ update_port="$(docker port "$update_container" 80/tcp | head -n1 | sed 's/.*://' rm -rf build/browser-shots mkdir -p build/browser-shots cp tests/browser/play.mjs tests/browser/accueil.mjs tests/browser/match.mjs tests/browser/recette.mjs \ - tests/browser/maitre.mjs tests/browser/bilan.mjs "$tools/" + tests/browser/maitre.mjs tests/browser/bilan.mjs tests/browser/prompts.mjs "$tools/" status=0 for browser in $browsers; do # Réseau de l'hôte : le site est sur 127.0.0.1. Le service worker @@ -105,5 +106,12 @@ for browser in $browsers; do -v "$PWD/$tools:/tools" -v "$PWD/build/browser-shots:/shots" -w /tools "$image" \ node bilan.mjs "http://localhost:$port/" "$browser" /shots || status=1 fi + if [[ " $scripts " == *" prompts "* ]]; then + # « Demander à une IA » : boutons, feuille du prompt, copie (relue + # dans Chromium) ; captures dans build/browser-shots/prompts/. + docker run --rm --init --ipc=host --network host --user "$(id -u):$(id -g)" -e HOME=/tmp \ + -v "$PWD/$tools:/tools" -v "$PWD/build/browser-shots:/shots" -w /tools "$image" \ + node prompts.mjs "http://localhost:$port/" "$browser" /shots || status=1 + fi done exit "$status" diff --git a/tests/browser/prompts.mjs b/tests/browser/prompts.mjs new file mode 100644 index 0000000..22b59be --- /dev/null +++ b/tests/browser/prompts.mjs @@ -0,0 +1,543 @@ +// prompts.mjs -- « Demander à une IA » (étape 1f) par l'interface, dans un +// vrai navigateur sans écran (Playwright), sur le site servi par l'image de +// production (charte, ecrans-etape-1.md, « 1f. Demander à une IA »). +// +// Copyright (C) 2026 les auteurs d'Ustagammon. +// Licence : GPLv3 ou ultérieure (voir LICENSE). +// +// Usage : node prompts.mjs +// Lancé par tests/browser.sh. La fin de course courte de bilan.mjs : le +// joueur a 6-1, deux pions sur 6 et 1, et joue 6/5/off (grosse erreur) ; +// l'ordinateur double, le joueur passe. Vérifie le bouton « Demander à une +// IA » dans la feuille du maître, sur le bilan, dans l'analyse et sur une +// fiche du glossaire ; la feuille du prompt à son adresse (…/ia), lisible +// avant la copie, rechargeable ; une seule feuille à la fois (depuis la +// feuille du maître ou le bilan, la page du prompt prend leur place, à la +// même hauteur, avec « ‹ Explication » ou « ‹ Bilan ») ; la copie (dans +// Chromium, le presse-papiers est relu et comparé au texte de la feuille, et +// aux chiffres de la feuille du maître), confirmée sur le bouton (« Copié », +// 2 s) ; le retour (« ‹ … », Échap, retour du téléphone) qui ramène à +// l'écran d'avant, la croix qui ferme ; aucune requête vers un autre site ; +// l'absence d'erreur et de violation de la politique de sécurité. Captures (Chromium) +// dans /prompts/, en 412×915 et 915×412. + +import { mkdirSync } from "node:fs"; +import { chromium, firefox } from "playwright"; +import { hiddenUnderActionBar, tallButtons } from "./recette.mjs"; + +const [base, browserName, shots] = process.argv.slice(2); +const browserType = { chromium, firefox }[browserName]; +if (!base || !browserType || !shots) { + console.error("usage : node prompts.mjs "); + process.exit(2); +} +const full = browserName === "chromium"; +const dir = `${shots}/prompts`; +mkdirSync(dir, { recursive: true }); +const PORTRAIT = { width: 412, height: 915 }; +const LANDSCAPE = { width: 915, height: 412 }; +const SIZES = [PORTRAIT, LANDSCAPE]; +const BEAROFF = "BQAACAIAAAAAAA"; + +const problems = []; +const log = (text) => console.log(`[${browserName}] ${text}`); +const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); +let captures = 0; + +function check(condition, message) { + if (!condition) { + throw new Error(message); + } +} + +const browser = await browserType.launch(); +const context = await browser.newContext({ + viewport: PORTRAIT, + deviceScaleFactor: 1, + locale: "fr-FR", + reducedMotion: "reduce", +}); +if (full) { + // Chromium : le test relit le presse-papiers. + await context.grantPermissions(["clipboard-read", "clipboard-write"], { + origin: new URL(base).origin, + }); +} +await context.addInitScript(() => { + window.__dice = []; + let state = 5; + Math.random = () => { + const die = window.__dice.shift(); + if (die !== undefined) { + return (die - 0.5) / 6; + } + state = (state * 1103515245 + 12345) % 2147483648; + return state / 2147483648; + }; + window.__csp = []; + document.addEventListener("securitypolicyviolation", (e) => + window.__csp.push(`${e.effectiveDirective} ${e.blockedURI}`), + ); +}); +const page = await context.newPage(); +page.on("pageerror", (error) => problems.push(`erreur de la page : ${error.message}`)); +page.on("console", (message) => { + if (message.type() === "error") { + problems.push(`console : ${message.text()}`); + } +}); +// Aucun appel réseau hors du site : l'application n'appelle aucune IA. +const origin = new URL(base).origin; +page.on("request", (request) => { + const url = request.url(); + if (!url.startsWith(origin) && !url.startsWith("data:") && !url.startsWith("blob:")) { + problems.push(`requête hors du site : ${url}`); + } +}); + +/** Attend une condition dans la page (sans page.waitForFunction, qui passe + * par eval, interdit par la politique de sécurité du site). */ +async function until(what, fn, arg, ms = 120_000) { + for (let waited = 0; waited < ms; waited += 100) { + if (await page.evaluate(fn, arg)) { + return; + } + await sleep(100); + } + await page.screenshot({ path: `${dir}/${browserName}-bloque.png` }).catch(() => {}); + throw new Error(`attente trop longue : ${what}`); +} + +const idle = () => until("écran libre", () => !document.body.classList.contains("busy")); + +/** Une capture de la recette dans les deux tailles (Chromium seulement), + * après la règle commune : rien d'interactif sous une barre d'action, pas + * de bouton étiré. before() est rejoué avant chaque taille (« Copié » ne + * dure que 2 s sur le bouton). */ +async function capture(name, before = null) { + if (!full) { + return; + } + // « Copié » d'une copie précédente est reparti (sauf pour sa propre + // capture, où before le fait réapparaître). + await until("bouton revenu", notCopied, null, 10_000); + for (const size of SIZES) { + await page.setViewportSize(size); + await sleep(300); + await before?.(); + const hidden = await hiddenUnderActionBar(page); + check( + hidden.length === 0, + `${name} ${size.width}x${size.height} : caché sous la barre d'action : ${hidden.join(", ")}`, + ); + const tall = await tallButtons(page); + check(tall.length === 0, `${name} ${size.width}x${size.height} : boutons étirés : ${tall.join(", ")}`); + await page.screenshot({ path: `${dir}/${name}-${size.width}x${size.height}.png` }); + captures++; + } + await page.setViewportSize(PORTRAIT); + await sleep(300); +} + +/** Le bouton de copie n'est pas (ou plus) en « Copié ». */ +const notCopied = () => { + const copy = document.getElementById("prompt-copy"); + return copy === null || !copy.classList.contains("copied"); +}; + +/** La page du prompt, prête et visible : son texte. */ +async function openPrompt(button, hashEnd) { + await page.locator(button).scrollIntoViewIfNeeded(); + await page.click(button); + await until( + "feuille du prompt prête", + (end) => { + const prompt = document.getElementById("prompt-page"); + return ( + location.hash.endsWith(end) && + prompt?.dataset.ready === "true" && + prompt.getClientRects().length > 0 + ); + }, + hashEnd, + ); + return page.textContent("#prompt-text"); +} + +/** Une seule feuille visible : la page du prompt, dans la feuille hostId + * (feuille du maître, bilan) ou dans la sienne (null) ; une poignée, une + * croix et un titre visibles au plus ; « ‹ back ». */ +async function checkOneSheet(hostId, back, what) { + const seen = await page.evaluate((host) => { + const visible = (el) => + el.getClientRects().length > 0 && getComputedStyle(el).visibility === "visible"; + const count = (selector) => [...document.querySelectorAll(selector)].filter(visible).length; + const prompt = document.getElementById("prompt-page"); + return { + parent: prompt.parentElement.id, + ownSheet: !document.getElementById("prompt-veil").hidden, + grips: count(".grip"), + closes: count('[role="dialog"] .icon-button'), + titles: count('[role="dialog"] h2'), + back: document.getElementById("prompt-back").textContent, + hostVisible: host === null || !document.getElementById(host).parentElement.hidden, + }; + }, hostId); + check(seen.parent === (hostId ?? "prompt-sheet"), `${what} : page du prompt dans ${seen.parent}`); + check(seen.ownSheet === (hostId === null), `${what} : feuille propre ${seen.ownSheet}`); + check(seen.hostVisible, `${what} : la feuille d'origine n'est pas affichée`); + check(seen.grips <= 1, `${what} : ${seen.grips} poignées visibles`); + check(seen.closes === 1, `${what} : ${seen.closes} croix visibles`); + check(seen.titles === 1, `${what} : ${seen.titles} titres visibles`); + check(seen.back === `‹ ${back}`, `${what} : retour « ${seen.back} »`); +} + +/** La hauteur et le haut d'un élément. */ +const boxOf = (selector) => + page.evaluate((s) => { + const r = document.querySelector(s).getBoundingClientRect(); + return { top: Math.round(r.top), height: Math.round(r.height) }; + }, selector); + +/** Ce que tout prompt doit contenir (cadrage, « IA générative en V1 »). */ +function checkPrompt(text, what) { + for (const part of [ + "Tu es un coach de backgammon", + "Je débute au backgammon.", + "Position ID : ", + "Match ID : ", + "https://ustagammon.sans.pub/#/position/", + "X : mes pions", + "GNU Backgammon (analyse à 2 plis)", + "ne les contredis pas", + "Réponds en français", + ]) { + check(text.includes(part), `${what} : « ${part} » absent`); + } + check(!/\{[a-z_]+\}/.test(text) && !text.includes("undefined"), `${what} : repère vide`); +} + +/** La copie, confirmée sur le bouton : « Copié », coche, verdict-good. */ +const copiedOnButton = () => { + const copy = document.getElementById("prompt-copy"); + return copy.classList.contains("copied") && copy.textContent === "Copié"; +}; + +/** Copie faite (« Copié ») ou repli (le message sous le texte). */ +const copyDone = () => { + const copy = document.getElementById("prompt-copy"); + return ( + copy.classList.contains("copied") || + document.getElementById("prompt-status").textContent !== "" + ); +}; + +/** Copie le prompt, attend « Copié » sur le bouton puis son retour à + * « Copier le prompt » après 2 s, et (Chromium) relit le presse-papiers. */ +async function copyPrompt(what) { + await page.click("#prompt-copy"); + await until("copie faite", copyDone, null, 10_000); + const shown = await page.evaluate(() => { + const copy = document.getElementById("prompt-copy"); + return { + copied: copy.classList.contains("copied"), + label: copy.textContent, + check: copy.querySelector("svg.icon") !== null, + color: getComputedStyle(copy).color, + good: getComputedStyle(document.documentElement) + .getPropertyValue("--verdict-good") + .trim(), + status: document.getElementById("prompt-status").textContent, + text: document.getElementById("prompt-text").textContent, + banners: document.querySelectorAll("#prompt-banner").length, + }; + }); + check(shown.banners === 0, `${what} : un bandeau de copie`); + if (shown.copied) { + check(shown.label === "Copié" && shown.check, `${what} : bouton « ${shown.label} »`); + // La couleur du bouton est verdict-good (#rrggbb dans app.css). + const rgb = shown.good + .replace("#", "") + .match(/../g) + .map((h) => Number.parseInt(h, 16)) + .join(", "); + check(shown.color === `rgb(${rgb})`, `${what} : couleur ${shown.color} pour ${shown.good}`); + // Deux secondes après, le bouton revient. + await until("« Copier le prompt » revenu", notCopied, null, 5_000); + const label = await page.textContent("#prompt-copy"); + check(label === "Copier le prompt", `${what} : le bouton reste « ${label} »`); + } + if (full) { + check(shown.copied, `${what} : pas de « Copié » sur le bouton`); + const clipboard = await page.evaluate(() => navigator.clipboard.readText()); + check(clipboard === shown.text, `${what} : le presse-papiers diffère du texte affiché`); + return clipboard; + } + // Firefox : la copie, ou son repli (texte sélectionné, et un message). + check(shown.copied || shown.status.includes("sélectionné"), `${what} : ni copie ni repli`); + log(`${what} : ${shown.copied ? "copié" : "repli par sélection"}`); + return shown.text; +} + +/** « ‹ … » : la page du prompt s'en va, l'écran d'avant revient. */ +async function backFromPrompt(hashBelow) { + await page.click("#prompt-back"); + await promptGone(hashBelow); +} + +/** La page du prompt n'est plus affichée, à l'adresse hashBelow. */ +async function promptGone(hashBelow) { + await until( + "feuille du prompt fermée", + (below) => + document.getElementById("prompt-veil").hidden && + document.querySelector(".prompting") === null && + location.hash === below, + hashBelow, + ); +} + +// --------------------------------------- Une erreur, et son explication + +await page.goto(`${base}#/position/${encodeURIComponent(BEAROFF)}`); +await until("position lue", () => { + const play = document.getElementById("position-play"); + return !play.hidden && !play.disabled; +}); +await page.click("#position-play"); +await until( + "partie commencée", + () => location.hash === "#/" && !document.body.classList.contains("busy") && + !document.getElementById("roll").hidden, +); +await page.evaluate(() => window.__dice.push(6, 1)); +await page.click("#roll"); +await idle(); +for (const [from, to] of [[6, 5], [5, 0]]) { + await page.click(`#board .hit[data-place="p${from}"]`); + await idle(); + const target = to === 0 ? "off-player" : `p${to}`; + const land = page.locator(`#board .hit.can-land[data-place="${target}"]`); + if ((await land.count()) > 0) { + await land.click(); + await idle(); + } +} +await page.click("#confirm"); +await until("double de l'ordinateur", () => !document.getElementById("drop").hidden); +await until("ligne du maître", () => !document.getElementById("advice-line").hidden); +await page.click("#advice-line"); +await until("feuille du maître", () => document.getElementById("explain-ask-ai") !== null); +const explainHash = await page.evaluate(() => location.hash); +// Les écarts de « Voir les chiffres du moteur », pour les comparer au prompt. +await page.click("#explain-numbers summary"); +const sheetNumbers = await page.evaluate(() => + [...document.querySelectorAll("#explain-numbers .explain-table span")].map((s) => s.textContent), +); +await capture("1-bouton-feuille-du-maitre", () => + page.locator("#explain-ask-ai").scrollIntoViewIfNeeded(), +); +// La feuille du maître, avant : sa hauteur, que la page du prompt garde. +const explainBox = await boxOf("#explain-sheet"); +const moveText = await openPrompt("#explain-ask-ai", "/ia"); +await checkOneSheet("explain-sheet", "Explication", "feuille du maître"); +const promptBox = await boxOf("#prompt-page"); +check( + Math.abs(promptBox.top - explainBox.top) <= 1 && + Math.abs(promptBox.height - explainBox.height) <= 1, + `la feuille change de hauteur : ${JSON.stringify(explainBox)} puis ${JSON.stringify(promptBox)}`, +); +check((await page.evaluate(() => location.hash)) === `${explainHash}/ia`, "adresse de la feuille"); +checkPrompt(moveText, "prompt du coup"); +check(moveText.includes("J'ai joué 6/off : "), "prompt du coup : coup joué"); +check(moveText.includes("1. 6/off 1/off : équité"), "prompt du coup : meilleur coup"); +check(moveText.includes("« Grosse erreur »"), "prompt du coup : appréciation"); +// Chaque ligne du classement de la feuille (rang, coup, écart) est dans le +// prompt, avec le même écart. +for (let i = 0; i + 2 < sheetNumbers.length; i += 3) { + const [rank, move, loss] = sheetNumbers.slice(i, i + 3); + const notation = move.replace(" (vous)", ""); + if (Number(rank) <= 3 || move.includes("(vous)")) { + check( + moveText.includes(`${rank}. ${notation}`) && moveText.includes(`écart ${loss}`), + `prompt du coup : ligne ${rank} ${notation} ${loss} absente`, + ); + } +} +await capture("2-feuille-du-prompt"); +const copied = await copyPrompt("prompt du coup"); +await capture("3-prompt-copie", async () => { + await page.click("#prompt-copy"); + await until("« Copié »", copiedOnButton); +}); +log(`prompt du coup copié (${copied.length} caractères)`); +// Échap ramène à l'explication, qui n'est pas fermée. +await page.keyboard.press("Escape"); +await promptGone(explainHash); +check( + await page.evaluate( + () => + !document.getElementById("explain-veil").hidden && + getComputedStyle(document.getElementById("explain-close")).visibility === "visible", + ), + "l'explication n'est pas revenue", +); +// Rechargée, l'adresse de la feuille la rouvre. +await page.goto(`${base}${explainHash}/ia`); +await page.reload(); +await until( + "feuille rouverte au rechargement", + () => document.getElementById("prompt-page")?.dataset.ready === "true", +); +check((await page.textContent("#prompt-text")) === moveText, "le prompt rechargé diffère"); +await checkOneSheet("explain-sheet", "Explication", "feuille du maître rechargée"); +// « ‹ Explication » y ramène. +await backFromPrompt(explainHash); +// La croix ferme la feuille : l'explication avec. +await openPrompt("#explain-ask-ai", "/ia"); +await page.click("#prompt-close"); +await until( + "feuille du maître fermée", + () => document.getElementById("explain-veil").hidden && location.hash === "#/", +); +await promptGone("#/"); + +// ------------------------------------------------------------- Le bilan + +await page.click("#drop"); +await until( + "bilan prêt", + () => + /^#\/parties\/[a-z0-9-]+\/bilan$/.test(location.hash) && + document.getElementById("summary")?.dataset.ready === "true" && + document.getElementById("summary-ask-ai") !== null, +); +await until("mini-plateau du coup à revoir", () => + document.querySelector("#summary-board .board-frame") !== null, +); +const summaryHash = await page.evaluate(() => location.hash); +const id = summaryHash.split("/")[2]; +await capture("4-bouton-bilan", () => page.locator("#summary-ask-ai").scrollIntoViewIfNeeded()); +const summaryText = await openPrompt("#summary-ask-ai", "/bilan/ia"); +await checkOneSheet("summary", "Bilan", "bilan"); +checkPrompt(summaryText, "prompt du bilan"); +check(summaryText.includes("Résultat : l'ordinateur a gagné"), "prompt du bilan : résultat"); +check( + summaryText.includes("j'ai joué 6/off, GNU Backgammon préférait 6/off 1/off"), + "prompt du bilan : erreur", +); +check(summaryText.includes("Le coup à revoir, ma plus grosse erreur (coup 1)"), "coup à revoir"); +await capture("5-feuille-du-prompt-bilan"); +await copyPrompt("prompt du bilan"); +// Le retour du téléphone ramène au bilan. +await page.goBack(); +await promptGone(summaryHash); +check( + await page.evaluate(() => document.getElementById("summary")?.dataset.ready === "true"), + "le bilan n'est plus là après la feuille du prompt", +); + +// ----------------------------------------------------------- L'analyse + +await page.click("#summary-revisit"); +await until( + "analyse sur le coup à revoir", + (game) => + location.hash === `#/parties/${game}/analyse/1` && + document.querySelector("#analysis-board .board-frame") !== null && + !document.getElementById("analysis-ask-ai").hidden, + id, +); +await capture("6-bouton-analyse", () => + page.locator("#analysis-ask-ai").scrollIntoViewIfNeeded(), +); +const analysisText = await openPrompt("#analysis-ask-ai", `/parties/${id}/analyse/1/ia`); +checkPrompt(analysisText, "prompt de l'analyse"); +await checkOneSheet(null, "Analyse", "analyse"); +// Le même coup, les mêmes chiffres que dans la feuille du maître (le niveau +// du joueur, lui, compte maintenant toutes ses décisions de la partie). +const numbersOf = (text) => text.slice(text.indexOf("J'ai joué"), text.indexOf("Ces évaluations")); +check( + numbersOf(analysisText) === numbersOf(moveText) && numbersOf(moveText).length > 50, + "les chiffres du coup diffèrent entre la feuille du maître et l'analyse", +); +await backFromPrompt(`#/parties/${id}/analyse/1`); +// Coup 2 : le joueur a passé le double, une bonne décision ; son prompt de +// videau. +await page.goto(`${base}#/parties/${id}/analyse/2`); +await until("coup 2", () => + document.getElementById("analysis-turn")?.textContent.startsWith("Coup 2 sur 2") && + !document.getElementById("analysis-ask-ai").hidden, +); +const cubeText = await openPrompt("#analysis-ask-ai", "/analyse/2/ia"); +checkPrompt(cubeText, "prompt du videau"); +check(cubeText.includes("prendre ou passer le double de l'ordinateur"), "prompt du videau"); +check(cubeText.includes("J'ai choisi de passer"), "prompt du videau : décision"); +// « Fermer » : sans feuille dessous, retour à l'analyse. +await page.click("#prompt-done"); +await promptGone(`#/parties/${id}/analyse/2`); + +// --------------------------------------------------------- Le glossaire + +await page.goto(`${base}#/glossaire/videau`); +await until( + "fiche du videau", + () => { + const link = document.getElementById("glossary-ask-ai-videau"); + return link !== null && !link.hidden; + }, +); +check( + (await page.locator(".term-card .ask-ai:not([hidden])").count()) === 1, + "un seul bouton « Demander à une IA » dans le glossaire", +); +await capture("7-bouton-glossaire", () => + page.locator("#glossary-ask-ai-videau").scrollIntoViewIfNeeded(), +); +const conceptText = await openPrompt("#glossary-ask-ai-videau", "#/glossaire/videau/ia"); +checkPrompt(conceptText, "prompt du concept"); +await checkOneSheet(null, "Glossaire", "glossaire"); +check(conceptText.includes("le concept « Videau » (en anglais : Cube)"), "prompt du concept"); +check(/chances de gagner \d+ %/.test(conceptText), "prompt du concept : évaluation"); +await capture("8-feuille-du-prompt-glossaire"); +await copyPrompt("prompt du concept"); +if (full) { + // Le repli : navigator.clipboard refuse (comme hors contexte sûr) ; le + // texte est sélectionné dans la feuille et copié par le navigateur. + await until("bouton revenu", notCopied, null, 5_000); + await page.evaluate(async () => { + await navigator.clipboard.writeText(""); + navigator.clipboard.writeText = () => Promise.reject(new Error("refusé")); + }); + await page.click("#prompt-copy"); + await until("copie de repli", copyDone, null, 10_000); + const fallback = await page.evaluate(async () => ({ + selected: window.getSelection().toString(), + text: document.getElementById("prompt-text").textContent, + copied: document.getElementById("prompt-copy").classList.contains("copied"), + clipboard: await navigator.clipboard.readText(), + })); + // Copié depuis une sélection, le texte perd ses espaces insécables + // (« 41 % ») : ce sont des espaces ordinaires dans le presse-papiers. + const plain = (text) => text.replace(/ /g, " ").trim(); + check(plain(fallback.selected) === plain(fallback.text), "repli : texte non sélectionné"); + check( + fallback.copied && plain(fallback.clipboard) === plain(fallback.text), + `repli : le navigateur n'a pas copié le texte sélectionné (« Copié » ${fallback.copied}, presse-papiers ${fallback.clipboard.length} car. pour ${fallback.text.length})`, + ); + log("repli par sélection du texte : copié"); +} +await backFromPrompt("#/glossaire/videau"); + +const csp = await page.evaluate(() => window.__csp); +for (const violation of csp) { + problems.push(`politique de sécurité : ${violation}`); +} +await browser.close(); +if (problems.length > 0) { + for (const problem of problems) { + console.error(`[${browserName}] ${problem}`); + } + process.exit(1); +} +log(`demander à une IA : tout est conforme (${captures} captures)`); diff --git a/tests/test_site.sh b/tests/test_site.sh index dcfd41c..f610bc4 100755 --- a/tests/test_site.sh +++ b/tests/test_site.sh @@ -87,7 +87,13 @@ cmp -s <(curl -s "$base/docs/glossaire/videau.md") docs/glossaire/videau.md \ check "docs/tutoriel/regles.md" "text/markdown" cmp -s <(curl -s "$base/docs/tutoriel/regles.md") docs/tutoriel/regles.md \ || report "/docs/tutoriel/regles.md diffère de la source" -for feature in apprendre-les-regles decouvrir-l-application installer-l-application; do +# Prompts pour une IA : copie de leur source unique (ADR 0008, étape 1f). +for template in coup videau bilan concept exercices; do + check "docs/prompts/$template.md" "text/markdown" + cmp -s <(curl -s "$base/docs/prompts/$template.md") "docs/prompts/$template.md" \ + || report "/docs/prompts/$template.md diffère de la source" +done +for feature in apprendre-les-regles decouvrir-l-application installer-l-application demander-a-une-ia; do check "docs/scenarios/$feature.feature" "text/plain" done # Le service worker attend l'accord du joueur avant de prendre la main. diff --git a/web/app.css b/web/app.css index f1b8457..40adcc4 100644 --- a/web/app.css +++ b/web/app.css @@ -3415,3 +3415,158 @@ html.landscape .analysis-board { .summary-board .point-number { display: none; } + +/* ------------------------------------------- Demander à une IA (1f) */ + +/* Le bouton : secondaire partout, avec l'icône de copie (charte, 1f). */ +a.button.ask-ai .icon, +#prompt-copy .icon { + flex: none; + width: 20px; + height: 20px; +} + +.ask-ai-slot .ask-ai { + width: 100%; +} + +.explain-actions { + gap: var(--space-2); + flex-wrap: wrap; +} + +.term-card .ask-ai { + margin-top: var(--space-2); +} + +/* Sa propre feuille, au-dessus de l'analyse et du glossaire. */ +.prompt-veil { + z-index: 12; +} + +.prompt-page { + display: flex; + flex-direction: column; + gap: var(--space-2); +} + +/* Depuis la feuille du maître ou le bilan, la page du prompt prend la place + * de leur contenu, à la même hauteur : la feuille garde sa taille, son + * contenu reste en place mais invisible, et la page la couvre ; le texte du + * prompt défile dans la hauteur qui reste. Une seule feuille, une seule + * poignée, une seule croix. */ +.sheet.prompting, +.panel.prompting { + position: relative; + overflow: hidden; +} + +.prompting > :not(.prompt-page) { + visibility: hidden; +} + +/* (plus précis que « html.landscape .explain-sheet > * », qui la placerait + * dans une colonne) */ +.sheet.prompting > .prompt-page, +.panel.prompting > .prompt-page { + position: absolute; + inset: 0; + grid-area: auto; + padding: inherit; +} + +.prompting .prompt-text { + flex: 1 1 auto; + max-height: none; +} + +/* Le bilan est un panneau : pas de poignée. */ +.panel .prompt-page .grip { + display: none; +} + +/* « ‹ Explication » : revenir à l'écran d'où l'on vient. */ +a.prompt-back { + display: inline-flex; + align-items: center; + min-height: 44px; + padding-right: var(--space-2); + font-weight: 500; + color: var(--accent); + text-decoration: none; + touch-action: manipulation; + -webkit-tap-highlight-color: transparent; +} + +.prompt-lead { + margin: 0; + color: var(--muted); +} + +/* Le texte du prompt, à relire avant de le copier : une zone en creux qui + * défile, en chasse fixe pour que le diagramme garde ses colonnes. */ +.prompt-text { + min-height: 0; + max-height: 50vh; + max-height: 50dvh; + margin: 0; + padding: var(--space-3); + overflow: auto; + font-family: ui-monospace, "DejaVu Sans Mono", "Roboto Mono", monospace; + font-size: 13px; + line-height: 18px; + white-space: pre-wrap; + overflow-wrap: anywhere; + color: var(--text); + background: var(--surface-sunken); + border: 1px solid var(--border); + border-radius: var(--radius-md); + user-select: text; +} + +.prompt-status { + margin: 0; + color: var(--muted); +} + +.prompt-status:empty { + display: none; +} + +.prompt-actions { + display: flex; + flex: none; + gap: var(--space-2); + justify-content: flex-end; +} + +.prompt-actions button { + display: inline-flex; + gap: 6px; + align-items: center; + justify-content: center; + min-width: 120px; +} + +/* « ✓ Copié », 2 s : la copie se confirme sur le bouton (recette de 1f). */ +#prompt-copy.copied { + color: var(--verdict-good); + background: var(--surface); + border-color: var(--verdict-good); +} + +html.landscape .prompt-text { + max-height: calc(100vh - 190px); + max-height: calc(100dvh - 190px); +} + +html.landscape .prompting .prompt-text { + max-height: none; +} + +/* Le bouton ne se coupe pas : trop étroit à côté de « Revoir ce coup » ou + * « Rejouer d'ici », il passe à la ligne. */ +.summary-review-actions, +.viewer-actions { + flex-wrap: wrap; +} diff --git a/web/index.html b/web/index.html index c3510cb..48bc2ad 100644 --- a/web/index.html +++ b/web/index.html @@ -166,6 +166,11 @@ construit par app/src/ui/summary-view.ts. --> + + +