« Demander à une IA » : bouton, feuille du prompt à son adresse, copie

Le bouton, secondaire, dans la feuille du maître, sur le bilan, dans
l'analyse et sur la fiche ouverte du glossaire. Il ouvre la feuille du
prompt (…/ia, amendement de l'ADR 0013), où le texte se relit avant la
copie : navigator.clipboard, repli par sélection du texte, bandeau
« Prompt copié » (2 s). Gabarits copiés par make site (amendement de
l'ADR 0008), scénario « Demander à une IA », 1f marquée faite.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Pierre MARTIN
2026-11-10 20:56:56 +01:00
co-authored by Claude Opus 5.5
parent 4f0611c24c
commit 2a1fe1b04c
27 changed files with 1593 additions and 48 deletions
+4 -3
View File
@@ -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
+1 -1
View File
@@ -1,4 +1,4 @@
// La position que décrit un prompt (étape 1f) : ses identifiants de GNU
// 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.
//
+3 -7
View File
@@ -75,7 +75,7 @@ export const CUBE_ROW_WORDS = {
doublePass: "Double, passe",
} as const;
/** Les erreurs citées au plus dans un prompt (bilan, exercices). */
/** 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}`;
@@ -322,9 +322,7 @@ export function summaryPrompt(
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"),
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"
@@ -418,9 +416,7 @@ export async function exercisesPosition(
journals: readonly GameJournal[],
): Promise<PromptPosition | null> {
const worst = costliest(journals)[0];
return worst === undefined
? null
: positionBefore(engine, worst.journal, worst.advice.eventId);
return worst === undefined ? null : positionBefore(engine, worst.journal, worst.advice.eventId);
}
/** Ce que demande le prompt des exercices. */
+10 -1
View File
@@ -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),
});
+12 -2
View File
@@ -18,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 } 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";
@@ -149,12 +150,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",
+6 -1
View File
@@ -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 = () => {
+139 -3
View File
@@ -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,8 @@ export function startApp(options: AppOptions): void {
adviceLine: byId<HTMLAnchorElement>("advice-line"),
explainVeil: byId("explain-veil"),
summaryVeil: byId("summary-veil"),
promptVeil: byId("prompt-veil"),
promptBanner: byId("prompt-banner"),
analysis: byId("analysis"),
menuAdvice: byId<HTMLButtonElement>("menu-advice"),
prompt: byId("prompt"),
@@ -1297,6 +1317,86 @@ 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, au-dessus de l'écran d'où on le demande.
const promptSource = createPromptSource({
access: options.prompts,
phrases: () => phrasesLoading,
glossaryEntry: loadGlossaryEntry,
});
const promptView = createPromptView({
veil: ui.promptVeil,
banner: ui.promptBanner,
close: () => closePrompt(),
});
// L'adresse de la feuille ouverte (null : fermée).
let promptShown: string | null = null;
/** Ferme la feuille du prompt : 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)) : "#/");
}
/** Ouvre (ou met à jour) la feuille du prompt de l'adresse route. */
function showPrompt(route: AiRoute): void {
promptShown = routeHash(route);
promptView.show(promptOf(route));
}
/** Le journal de la partie id, pour son bilan ou son analyse : celui de
* la partie à l'écran, ou celui du stockage. */
async function journalOf(id: string): Promise<GameJournal | null> {
const live = liveGame(id);
if (live !== null) {
return live.journal;
}
return reviewed?.id === id ? reviewed.journal : options.games.journal(id);
}
/** Le prompt de l'écran sous la feuille (route sans « /ia »). */
async function promptOf(route: AiRoute): Promise<PromptResult> {
await options.engineReady;
switch (route.screen) {
case "explanation": {
const advice = game?.advice[route.event];
return game === null || advice === undefined
? { message: ASK_AI_TEXTS.noAdvice }
: promptSource.decision(game.journal, advice);
}
case "summary": {
const journal = await journalOf(route.game);
if (journal === null) {
return { message: ASK_AI_TEXTS.missingGame };
}
return readyForSummary(journal)
? promptSource.summary(journal)
: { message: ASK_AI_TEXTS.notReady };
}
case "analysis": {
const journal = await journalOf(route.game);
if (journal === null) {
return { message: ASK_AI_TEXTS.missingGame };
}
// Le coup que montre l'analyse : celui de l'adresse, ou le
// coup à revoir (analysis-view.ts).
const turns = gameTurns(journal);
const fallback = summarizeAnalysis(journal).toReview?.turn ?? 1;
const n = Math.min(Math.max(1, route.turn ?? fallback), Math.max(1, turns.length));
const turn = turns[n - 1];
const advice = turn === undefined ? null : mainAdvice(journal, turn);
return advice === null
? { message: ASK_AI_TEXTS.noAdvice }
: promptSource.decision(journal, advice);
}
case "glossary":
return promptSource.concept(route.term ?? "", game?.journal ?? null);
}
}
/** La partie à l'écran, si c'est la partie id. */
function liveGame(id: string): Game | null {
return game !== null && options.games.shownId() === id ? game : null;
@@ -1363,6 +1463,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 +1815,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 +1896,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 +1957,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 +2406,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 +2510,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);
}
+23
View File
@@ -0,0 +1,23 @@
// 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: "Prompt 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",
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;
+246
View File
@@ -0,0 +1,246 @@
// « 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é. 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 bandeau « Prompt copié » le confirme.
//
// 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 du bandeau « Prompt copié » (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 };
/** 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;
}
export interface PromptViewOptions {
/** Le voile de la feuille (index.html) ; la feuille est construite ici. */
readonly veil: HTMLElement;
/** Le bandeau « Prompt copié » (index.html). */
readonly banner: HTMLElement;
/** Fermer : quitter l'adresse de la feuille. */
readonly close: () => void;
}
export interface PromptView {
/** Ouvre la feuille (ou la met à jour) sur un prompt en préparation. */
show(prompt: Promise<PromptResult>): void;
hide(): void;
readonly isOpen: boolean;
/** Le prompt est affiché (pas un message). */
readonly ready: boolean;
}
function element<K extends keyof HTMLElementTagNameMap>(
parent: HTMLElement,
tag: K,
options: { id?: string; className?: string; text?: string } = {},
): HTMLElementTagNameMap[K] {
const child = document.createElement(tag);
if (options.id !== undefined) {
child.id = options.id;
}
if (options.className !== undefined) {
child.className = options.className;
}
if (options.text !== undefined) {
child.textContent = options.text;
}
parent.append(child);
return child;
}
export function createPromptView(options: PromptViewOptions): PromptView {
const { veil, banner } = options;
const sheet = element(veil, "div", { id: "prompt-sheet", className: "sheet prompt-sheet" });
sheet.setAttribute("role", "dialog");
sheet.setAttribute("aria-modal", "true");
sheet.setAttribute("aria-labelledby", "prompt-title");
const head = element(sheet, "div", { className: "sheet-head" });
element(head, "span", { className: "grip" }).setAttribute("aria-hidden", "true");
element(head, "h2", { id: "prompt-title", className: "sheet-title", text: ASK_AI_TEXTS.title });
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(sheet, "p", { className: "prompt-lead", text: ASK_AI_TEXTS.lead });
const text = element(sheet, "pre", { id: "prompt-text", className: "prompt-text" });
text.tabIndex = 0;
text.setAttribute("aria-label", ASK_AI_TEXTS.textLabel);
const status = element(sheet, "p", { id: "prompt-status", className: "prompt-status" });
status.setAttribute("role", "status");
const actions = element(sheet, "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";
copy.append(icon("copy"), ASK_AI_TEXTS.copy);
veil.addEventListener("click", (event) => {
if (event.target === veil) {
options.close();
}
});
const bannerText = element(banner, "span", { text: ASK_AI_TEXTS.copied });
banner.prepend(icon("verdictGood"));
const timer = element(banner, "span", { className: "banner-timer prompt-timer" });
timer.setAttribute("aria-hidden", "true");
let bannerTimeout: ReturnType<typeof setTimeout> | null = null;
// 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;
function showBanner(): void {
bannerText.textContent = ASK_AI_TEXTS.copied;
banner.hidden = false;
// La barre de temps repart de zéro (charte : 2 s).
timer.classList.remove("running");
void timer.offsetWidth;
timer.classList.add("running");
if (bannerTimeout !== null) {
clearTimeout(bannerTimeout);
}
bannerTimeout = setTimeout(hideBanner, COPIED_MS);
}
function hideBanner(): void {
banner.hidden = true;
timer.classList.remove("running");
if (bannerTimeout !== null) {
clearTimeout(bannerTimeout);
bannerTimeout = null;
}
}
/** Le texte de la feuille, sélectionné : le repli quand le
* presse-papiers n'est pas accessible. */
function selectText(): void {
const selection = window.getSelection();
if (selection === null) {
return;
}
const range = document.createRange();
range.selectNodeContents(text);
selection.removeAllRanges();
selection.addRange(range);
}
async function copyText(): Promise<boolean> {
try {
await navigator.clipboard.writeText(text.textContent ?? "");
return true;
} catch {
// Hors contexte sûr (http sur le réseau local), ou refusé :
// repli ci-dessous.
}
selectText();
try {
return document.execCommand("copy");
} catch {
return false;
}
}
copy.onclick = async () => {
if (!ready) {
return;
}
const id = opened;
const copied = await copyText();
if (id !== opened) {
return;
}
if (copied) {
status.textContent = "";
showBanner();
} else {
selectText();
status.textContent = ASK_AI_TEXTS.notCopied;
}
};
return {
show(prompt) {
const id = ++opened;
const opening = veil.hidden;
veil.hidden = false;
if (opening) {
ready = false;
text.textContent = "";
text.hidden = true;
copy.disabled = true;
status.textContent = ASK_AI_TEXTS.preparing;
closeIcon.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 = "";
sheet.dataset.ready = "true";
} else {
ready = false;
text.hidden = true;
copy.disabled = true;
status.textContent = result.message;
delete sheet.dataset.ready;
}
},
() => {
if (id === opened) {
ready = false;
copy.disabled = true;
status.textContent = ASK_AI_TEXTS.failed;
}
},
);
},
hide() {
opened++;
ready = false;
// Le bandeau « Prompt copié » finit ses 2 s, feuille fermée.
if (!veil.hidden) {
veil.hidden = true;
text.textContent = "";
delete sheet.dataset.ready;
}
},
get isOpen() {
return !veil.hidden;
},
get ready() {
return ready;
},
};
}
+16 -3
View File
@@ -13,6 +13,7 @@
// que dit la réponse, ne s'affichent pas
// (docs/identite/charte/accueil-tutoriel-aide.md, « Aide »).
import { askAiLink } from "./ask-ai.js";
import {
type GlossaryEntry,
type HelpFeature,
@@ -32,6 +33,7 @@ export const FEATURES = [
"docs/scenarios/jouer-un-match.feature",
"docs/scenarios/comprendre-mes-coups.feature",
"docs/scenarios/revoir-ma-partie.feature",
"docs/scenarios/demander-a-une-ia.feature",
"docs/scenarios/apprendre-les-regles.feature",
"docs/scenarios/mes-parties.feature",
"docs/scenarios/partager-une-position.feature",
@@ -124,7 +126,10 @@ export function createHelpView(container: HTMLElement, tabs: HTMLElement): HelpV
notice.className = "help-notice";
notice.setAttribute("role", "status");
const questions: Question[] = [];
const terms = new Map<string, { dt: HTMLElement; card: HTMLElement; entry: GlossaryCard }>();
const terms = new Map<
string,
{ dt: HTMLElement; card: HTMLElement; entry: GlossaryCard; askAi: HTMLAnchorElement }
>();
let loading: Promise<boolean> | null = null;
// La question ouverte au dernier affichage : refermée, elle reste en vue.
let lastOpen: Question | null = null;
@@ -213,7 +218,14 @@ export function createHelpView(container: HTMLElement, tabs: HTMLElement): HelpV
a.href = routeHash({ screen: "glossary", term: other.name });
});
}
terms.set(entry.name, { dt, card, entry });
// « Demander à une IA » (étape 1f), sur la fiche ouverte.
const askAi = askAiLink(
dd,
`glossary-ask-ai-${entry.name}`,
routeHash({ screen: "glossary", term: entry.name, ai: true }),
);
askAi.hidden = true;
terms.set(entry.name, { dt, card, entry, askAi });
}
search.addEventListener("input", () => {
let shown = 0;
@@ -307,7 +319,8 @@ export function createHelpView(container: HTMLElement, tabs: HTMLElement): HelpV
q.section.classList.toggle("open", open);
}
const term = route.screen === "glossary" ? route.term : null;
for (const [name, { dt, card }] of terms) {
for (const [name, { dt, card, askAi }] of terms) {
askAi.hidden = name !== term;
dt.classList.toggle("current", name === term);
card.classList.toggle("current", name === term);
if (name === term) {
+5 -4
View File
@@ -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
+132
View File
@@ -0,0 +1,132 @@
// Les prompts pour une IA tels que les demande l'interface (étape 1f) :
// les gabarits, lus dans la copie de docs/prompts/ faite par make site
// (ADR 0008), les phrases du maître, et les positions que le domaine lit
// avec le moteur (PromptsAccess, branché par main.ts). Le remplissage
// lui-même est dans le domaine (domain/prompts/prompts.ts).
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
import type { MasterPhrases } from "../domain/analysis/phrases.js";
import type { Advice } from "../domain/game/advice.js";
import type { GameJournal } from "../domain/game/events.js";
import type { PromptPosition } from "../domain/prompts/position.js";
import {
type ConceptExample,
conceptPrompt,
decisionPrompt,
summaryPrompt,
} from "../domain/prompts/prompts.js";
import {
PROMPT_FILES,
PROMPT_KINDS,
type PromptTemplates,
parsePromptTemplate,
} from "../domain/prompts/templates.js";
import type { PromptResult } from "./ask-ai.js";
import { ASK_AI_TEXTS } from "./ask-ai-texts.js";
import type { GlossaryEntry } from "./help-text.js";
/** La copie des gabarits dans le site (make site). */
const PROMPTS_DIR = "docs/prompts/";
/** Ce que les prompts demandent au domaine et au moteur : la position à
* décrire. */
export interface PromptsAccess {
/** La position d'avant une décision de la partie. */
readonly before: (journal: GameJournal, eventId: string) => Promise<PromptPosition>;
/** La position que montre le prompt du bilan (coup à revoir, ou
* position finale). */
readonly summary: (journal: GameJournal) => Promise<PromptPosition>;
/** La position d'exemple d'un concept et son évaluation. */
readonly concept: (journal: GameJournal | null) => Promise<ConceptExample>;
}
export interface PromptSourceOptions {
readonly access: PromptsAccess;
readonly phrases: () => Promise<MasterPhrases>;
/** Une fiche du glossaire, par son nom de fichier. */
readonly glossaryEntry: (file: string) => Promise<GlossaryEntry | null>;
}
export interface PromptSource {
/** Le prompt d'une décision du joueur (coup ou videau). */
decision(journal: GameJournal, advice: Advice): Promise<PromptResult>;
/** Le prompt du bilan d'une partie finie et analysée. */
summary(journal: GameJournal): Promise<PromptResult>;
/** Le prompt d'un terme du glossaire (nom de sa fiche, sans .md). */
concept(term: string, journal: GameJournal | null): Promise<PromptResult>;
}
async function fetchText(url: string): Promise<string> {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`${url} : ${response.status}`);
}
return response.text();
}
export function createPromptSource(options: PromptSourceOptions): PromptSource {
let loading: Promise<PromptTemplates> | null = null;
/** Les gabarits, lus une fois (une nouvelle tentative après un
* échec). */
function templates(): Promise<PromptTemplates> {
loading ??= Promise.all(
PROMPT_KINDS.map(async (kind) =>
parsePromptTemplate(await fetchText(PROMPTS_DIR + PROMPT_FILES[kind])),
),
).then(
([move, cube, summary, concept, exercises]) => ({
move: move ?? "",
cube: cube ?? "",
summary: summary ?? "",
concept: concept ?? "",
exercises: exercises ?? "",
}),
(error: unknown) => {
loading = null;
throw error;
},
);
return loading;
}
return {
async decision(journal, advice) {
const [all, phrases, position] = await Promise.all([
templates(),
options.phrases(),
options.access.before(journal, advice.eventId),
]);
return { text: decisionPrompt(all, phrases, { journal, advice, position }) };
},
async summary(journal) {
const [all, phrases, position] = await Promise.all([
templates(),
options.phrases(),
options.access.summary(journal),
]);
return { text: summaryPrompt(all, phrases, { journal, position }) };
},
async concept(term, journal) {
const [all, entry, example] = await Promise.all([
templates(),
options.glossaryEntry(`${term}.md`).catch(() => null),
options.access.concept(journal),
]);
if (entry === null) {
return { message: ASK_AI_TEXTS.missingTerm };
}
return {
text: conceptPrompt(all, {
term: entry.term,
english: entry.english,
brief: entry.brief,
journal,
example,
}),
};
},
};
}
+58 -8
View File
@@ -14,8 +14,9 @@ export type Route =
| { readonly screen: "home" }
/** #/aide, et une question ouverte : #/aide/<identifiant>. */
| { readonly screen: "help"; readonly question: string | null }
/** #/glossaire, et un terme : #/glossaire/<fiche sans .md>. */
| { readonly screen: "glossary"; readonly term: string | null }
/** #/glossaire, et un terme : #/glossaire/<fiche sans .md> ; son
* prompt pour une IA : #/glossaire/<fiche>/ia. */
| { readonly screen: "glossary"; readonly term: string | null; readonly ai?: boolean }
/** #/a-propos. */
| { readonly screen: "about" }
/** #/tutoriel, une étape : #/tutoriel/<n>, et son écran « à vous » :
@@ -27,19 +28,27 @@ export type Route =
| { readonly screen: "install" }
/** #/parties, et une partie : #/parties/<identifiant>. */
| { readonly screen: "games"; readonly game: string | null }
/** #/parties/<identifiant>/bilan : le bilan de fin de partie (1e). */
| { readonly screen: "summary"; readonly game: string }
/** #/parties/<identifiant>/bilan : le bilan de fin de partie (1e) ;
* son prompt pour une IA : #/parties/<identifiant>/bilan/ia (1f). */
| { readonly screen: "summary"; readonly game: string; readonly ai?: boolean }
/** #/parties/<identifiant>/analyse/<coup> : l'écran d'analyse, placé
* sur un coup (un tour de la partie, à partir de 1) ; sans coup :
* #/parties/<identifiant>/analyse. */
| { readonly screen: "analysis"; readonly game: string; readonly turn: number | null }
* #/parties/<identifiant>/analyse ; le prompt du coup pour une IA :
* #/parties/<identifiant>/analyse/<coup>/ia (1f). */
| {
readonly screen: "analysis";
readonly game: string;
readonly turn: number | null;
readonly ai?: boolean;
}
/** #/menu. */
| { readonly screen: "menu" }
/** #/nouvelle-partie : le choix de la partie (type, niveau, conseil). */
| { readonly screen: "newGame" }
/** #/explication/<événement> : la feuille d'explication du maître sur
* une décision de la partie en cours (étape 1d). */
| { readonly screen: "explanation"; readonly event: string }
* une décision de la partie en cours (étape 1d) ; son prompt pour une
* IA : #/explication/<événement>/ia (1f). */
| { readonly screen: "explanation"; readonly event: string; readonly ai?: boolean }
/** #/position/<Position ID>/<Match ID> (Match ID facultatif). */
| { readonly screen: "position"; readonly position: string; readonly match: string | null }
/** Une adresse que l'application ne connaît pas. */
@@ -71,6 +80,11 @@ export function parseRoute(hash: string): Route {
if (rest.at(-1) === "") {
rest.pop();
}
// La feuille du prompt pour une IA (étape 1f) : « /ia » au bout de
// l'adresse de l'écran d'où on la demande.
if (rest.length > 1 && rest.at(-1) === "ia") {
return aiRoute(parseRoute(`#/${[head, ...rest.slice(0, -1)].join("/")}`)) ?? unknown;
}
if (head === "position") {
return positionRoute(rest.join("/")) ?? unknown;
}
@@ -113,6 +127,37 @@ export function parseRoute(hash: string): Route {
}
}
/** Les écrans qui ont une feuille de prompt pour une IA. */
export type AiRoute = Extract<
Route,
{ screen: "explanation" | "summary" | "analysis" | "glossary" }
>;
/** L'écran, avec sa feuille de prompt ouverte (null : écran sans prompt). */
function aiRoute(route: Route): AiRoute | null {
switch (route.screen) {
case "explanation":
case "summary":
case "analysis":
return route.ai === true ? null : { ...route, ai: true };
case "glossary":
return route.term === null || route.ai === true ? null : { ...route, ai: true };
default:
return null;
}
}
/** La feuille du prompt pour une IA est-elle ouverte ? */
export function isAiRoute(route: Route): route is AiRoute {
return "ai" in route && route.ai === true;
}
/** L'écran sous la feuille du prompt. */
export function withoutAi(route: AiRoute): AiRoute {
const { ai: _, ...below } = route;
return below;
}
/** #/parties/<id>/bilan, #/parties/<id>/analyse, #/parties/<id>/analyse/<n>. */
function gameReviewRoute(rest: readonly string[]): Route | null {
const [game = "", part, turn, ...more] = rest;
@@ -179,6 +224,11 @@ function positionRoute(text: string): Route | null {
/** Le fragment d'un écran, # compris. */
export function routeHash(route: Route): string {
const hash = screenHash(route);
return isAiRoute(route) ? `${hash}/ia` : hash;
}
function screenHash(route: Route): string {
switch (route.screen) {
case "game":
return "#/";
+7 -1
View File
@@ -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 } from "./ask-ai.js";
import { icon } from "./icons.js";
import { MATCH_TEXTS } from "./match-texts.js";
import { boardSvg, drawSteps } from "./move-board.js";
@@ -299,9 +300,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 {
+31 -7
View File
@@ -251,7 +251,12 @@ test("prompt d'une décision de videau : les équités de la feuille du maître"
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.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)}.`));
}
@@ -294,10 +299,22 @@ test("prompt du bilan : résultat, décomptes, taux et chance de l'écran, erreu
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(
`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);
@@ -306,8 +323,15 @@ test("prompt du bilan : résultat, décomptes, taux et chance de l'écran, erreu
// 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);
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 () => {
+29
View File
@@ -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);
}
+230
View File
@@ -0,0 +1,230 @@
// Étapes du scénario docs/scenarios/demander-a-une-ia.feature (ADR 0008) :
// les décisions se jouent par le domaine sur le vrai moteur, leur analyse
// par le domaine, et les prompts sont remplis par le domaine
// (domain/prompts/) avec les gabarits de docs/prompts/, comme dans
// l'application.
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { BeforeAll, Given, setDefaultTimeout, Then, When } from "@cucumber/cucumber";
import { analyseDecision } from "../../src/domain/analysis/analyse.js";
import {
missingAnalyses,
runAnalysis,
summarizeAnalysis,
} from "../../src/domain/analysis/game-analysis.js";
import { formatEquity, rankingRows } from "../../src/domain/analysis/numbers.js";
import { diceWords } from "../../src/domain/analysis/phrases.js";
import type { Advice } from "../../src/domain/game/advice.js";
import type { Engine } from "../../src/domain/game/engine.js";
import type { GameJournal } from "../../src/domain/game/events.js";
import { Game } from "../../src/domain/game/game.js";
import { parseHalfMoves } from "../../src/domain/game/position.js";
import { fixedDice } from "../../src/domain/game/roll.js";
import {
type PromptPosition,
PUBLIC_SITE,
positionBefore,
} from "../../src/domain/prompts/position.js";
import {
conceptExample,
conceptPrompt,
decisionPrompt,
summaryPosition,
summaryPrompt,
} from "../../src/domain/prompts/prompts.js";
import {
PROMPT_FILES,
type PromptTemplates,
parsePromptTemplate,
} from "../../src/domain/prompts/templates.js";
import { parseGlossaryEntry } from "../../src/ui/help-text.js";
import { MATCH_TEXTS } from "../../src/ui/match-texts.js";
import { loadPhrases, playOpening } from "../support/advice.js";
import { loadNodeEngine } from "../support/node-engine.js";
setDefaultTimeout(60_000);
const root = new URL("../../../../", import.meta.url);
const read = (path: string) => readFileSync(new URL(path, root), "utf8");
const phrases = loadPhrases();
const templates: PromptTemplates = {
move: parsePromptTemplate(read(`docs/prompts/${PROMPT_FILES.move}`)),
cube: parsePromptTemplate(read(`docs/prompts/${PROMPT_FILES.cube}`)),
summary: parsePromptTemplate(read(`docs/prompts/${PROMPT_FILES.summary}`)),
concept: parsePromptTemplate(read(`docs/prompts/${PROMPT_FILES.concept}`)),
exercises: parsePromptTemplate(read(`docs/prompts/${PROMPT_FILES.exercises}`)),
};
/** Le joueur au trait : un pion sur son point 6, un sur son point 1 ;
* l'ordinateur : un sur son point 1, un sur son point 2. */
const BEAROFF = "BQAACAIAAAAAAA";
let engine: Engine;
let game: Game | null;
let journal: GameJournal;
let advice: Advice;
let position: PromptPosition;
let prompt: string;
BeforeAll(async () => {
engine = await loadNodeEngine();
});
Given(/^vous avez joué (.+) avec un (\d)-(\d) d'ouverture$/, async (move, a, b) => {
const played = await playOpening(engine, [Number(a), Number(b)], move);
const analysed = await analyseDecision(engine, played.game.journal, played.eventId);
assert.ok(analysed !== null);
// Le conseil rattaché au journal, comme dans l'application.
played.game.recordAdvice(analysed);
game = played.game;
journal = played.game.journal;
advice = analysed;
});
When("vous demandez à une IA d'expliquer ce coup", async () => {
position = await positionBefore(engine, journal, advice.eventId);
prompt = decisionPrompt(templates, phrases, { journal, advice, position });
});
Then("la question donne la position, avec son Position ID, son Match ID et son lien", () => {
assert.equal(position.positionId, advice.position);
assert.ok(prompt.includes(`Position ID : ${position.positionId}`), prompt);
assert.ok(prompt.includes(`Match ID : ${position.matchId}`), prompt);
assert.ok(prompt.includes(`${PUBLIC_SITE}#/position/`), prompt);
assert.ok(prompt.includes("Mes pions (X), case (nombre) : 24 (2), 13 (5), 8 (3), 6 (5)."));
});
Then(
"elle donne les trois meilleurs coups du maître et le vôtre, avec les chiffres de l'explication",
() => {
assert.equal(advice.kind, "move");
if (advice.kind !== "move") {
return;
}
// Les lignes de « Voir les chiffres du moteur » : coup et écart.
const rows = rankingRows(advice).filter((row) => row.rank <= 3 || row.played);
assert.ok(rows.length >= 3);
for (const row of rows) {
assert.ok(
prompt.includes(`${row.rank}. ${row.move.notation}`) &&
prompt.includes(`écart ${formatEquity(row.loss)}`),
`${row.move.notation} absent :\n${prompt}`,
);
}
assert.ok(prompt.includes(`J'ai joué ${advice.played.notation} : `), prompt);
},
);
Then("elle demande à l'IA de ne pas contredire ces chiffres, et de répondre en français", () => {
assert.ok(prompt.includes("ne les recalcule pas et ne les contredis pas"), prompt);
assert.ok(prompt.includes("Réponds en français"), prompt);
assert.ok(!/[{}]/.test(prompt.replace(/```/g, "")), prompt);
});
/** Toutes les analyses qui manquent, une à une, comme l'application en
* arrière-plan. */
async function analyseAll(start: GameJournal): Promise<GameJournal> {
let current = start;
for (let i = 0; i < 100; i++) {
const next = missingAnalyses(current)[0];
if (next === undefined) {
return current;
}
current = await runAnalysis(engine, current, next);
}
throw new Error("l'analyse ne finit pas");
}
Given(
/^une partie finie où vous avez joué (.+) avec (\d)-(\d), puis passé le double de l'ordinateur$/,
async (move: string, a: string, b: string) => {
const played = Game.start(engine, {
setup: { position: BEAROFF, firstTurn: "player" },
level: "2ply",
dice: fixedDice([Number(a), Number(b)]),
});
await played.roll();
for (const halfMove of parseHalfMoves(move)) {
assert.ok(await played.playHalfMove(halfMove), `demi-coup refusé : ${move}`);
}
await played.confirmMove();
await played.playComputerTurn();
assert.equal(played.state.phase, "doubled", "l'ordinateur ne double pas");
await played.drop();
assert.equal(played.state.phase, "over");
journal = await analyseAll(played.journal);
},
);
When("vous demandez à une IA le bilan de votre partie", async () => {
position = await summaryPosition(engine, journal);
prompt = summaryPrompt(templates, phrases, { journal, position });
});
Then("la question donne vos décisions, votre niveau et la chance des dés, comme le bilan", () => {
const summary = summarizeAnalysis(journal);
const { counts, player } = summary;
assert.ok(prompt.includes(`Mes décisions : ${counts.good} bon`), prompt);
assert.ok(prompt.includes(`${counts.bad + counts.veryBad} erreur`), prompt);
assert.ok(
prompt.includes(`soit le niveau ${MATCH_TEXTS.levels[player.errorRate.level].name}`),
prompt,
);
assert.ok(prompt.includes(`(${diceWords(phrases, player.luck.rating)})`), prompt);
assert.ok(prompt.includes("Résultat : l'ordinateur a gagné"), prompt);
});
Then(/^elle cite votre coup (.+), le coup du maître (.+) et leur écart$/, (mine, best) => {
const toReview = summarizeAnalysis(journal).toReview;
assert.ok(toReview !== null);
assert.ok(
prompt.includes(
`j'ai joué ${mine}, GNU Backgammon préférait ${best} ; écart ${formatEquity(toReview.advice.equityLoss)} (Grosse erreur).`,
),
prompt,
);
});
Then("elle donne la position du coup à revoir", () => {
const toReview = summarizeAnalysis(journal).toReview;
assert.equal(position.positionId, toReview?.advice.position);
assert.ok(prompt.includes(`Position ID : ${position.positionId}`), prompt);
assert.ok(prompt.includes("Le coup à revoir, ma plus grosse erreur (coup 1)"), prompt);
});
Given("aucune partie n'est en cours", () => {
game = null;
});
When(/^vous demandez à une IA d'expliquer le mot « (.+) »$/, async (term: string) => {
const entry = parseGlossaryEntry(read(`docs/glossaire/${term.toLowerCase()}.md`));
assert.ok(entry !== null);
const example = await conceptExample(engine, game?.journal ?? null, 2);
position = example.position;
prompt = conceptPrompt(templates, {
term: entry.term,
english: entry.english,
brief: entry.brief,
journal: game?.journal ?? null,
example,
});
});
Then("la question reprend la définition de sa fiche", () => {
const entry = parseGlossaryEntry(read("docs/glossaire/videau.md"));
assert.ok(entry !== null && prompt.includes(entry.brief), prompt);
assert.ok(prompt.includes("le concept « Videau » (en anglais : Cube)"), prompt);
});
Then(
"elle l'illustre par la position de départ, avec les chances de gagner selon GNU Backgammon",
() => {
assert.ok(prompt.includes("voici la position de départ"), prompt);
assert.ok(prompt.includes("Position ID : 4HPwATDgc/ABMA"), prompt);
assert.match(prompt, /chances de gagner \d+ %/);
},
);
@@ -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.
+30
View File
@@ -159,3 +159,33 @@ 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/<id>/bilan/ia` |
| Prompt de la décision d'un coup, depuis l'analyse | `#/parties/<id>/analyse/<coup>/ia` (ou `…/analyse/ia`, sur le coup à revoir) |
| Prompt d'un terme du glossaire | `#/glossaire/<fiche sans .md>/ia` |
- La feuille monte au-dessus de l'écran d'en dessous, qui reste dessiné
(la feuille du maître, le bilan, l'analyse ou le glossaire). « Fermer »,
la croix, Échap et le retour du téléphone reviennent à cet écran ; ouverte
par son adresse, sans historique, la fermer remplace l'adresse par celle
de l'écran d'en dessous.
- 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.
- Le bandeau « Prompt copié » n'a pas d'adresse : c'est un bandeau
éphémère.
- Le prompt contient, lui, le lien public de la position
(`https://ustagammon.sans.pub/#/position/<Position ID>/<Match ID>`),
qui se partage.
+1 -1
View File
@@ -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 |
+1
View File
@@ -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 |
+45
View File
@@ -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
+13 -5
View File
@@ -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/<nom>.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"
+401
View File
@@ -0,0 +1,401 @@
// 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 <adresse du site> <chromium|firefox> <dossier des captures>
// 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 ; 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) ; le bandeau « Prompt copié » ; la fermeture qui ramène à
// l'écran d'en dessous ; aucune requête vers un autre site ; l'absence
// d'erreur et de violation de la politique de sécurité. Captures (Chromium)
// dans <dossier des captures>/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 <adresse> <chromium|firefox> <dossier des captures>");
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 (le bandeau
* « Prompt copié » ne dure que 2 s). */
async function capture(name, before = async () => {}) {
if (!full) {
return;
}
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);
}
/** La feuille du prompt, prête : son texte. */
async function openPrompt(button, hashEnd) {
await page.locator(button).scrollIntoViewIfNeeded();
await page.click(button);
await until(
"feuille du prompt prête",
(end) =>
location.hash.endsWith(end) &&
!document.getElementById("prompt-veil").hidden &&
document.getElementById("prompt-sheet")?.dataset.ready === "true",
hashEnd,
);
return page.textContent("#prompt-text");
}
/** 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 suis un joueur de niveau ",
"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`);
}
/** Copie le prompt, attend le bandeau, et (Chromium) relit le
* presse-papiers. */
async function copyPrompt(what) {
await page.click("#prompt-copy");
await until(
"copie faite",
() =>
!document.getElementById("prompt-banner").hidden ||
document.getElementById("prompt-status").textContent !== "",
null,
10_000,
);
const shown = await page.evaluate(() => ({
banner: !document.getElementById("prompt-banner").hidden,
bannerText: document.getElementById("prompt-banner").textContent,
status: document.getElementById("prompt-status").textContent,
text: document.getElementById("prompt-text").textContent,
}));
if (full) {
check(shown.banner && shown.bannerText.includes("Prompt copié"), `${what} : pas de bandeau`);
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.banner || shown.status.includes("sélectionné"), `${what} : ni copie ni repli`);
log(`${what} : ${shown.banner ? "copié" : "repli par sélection"}`);
return shown.text;
}
/** Ferme la feuille du prompt : l'écran d'en dessous revient. */
async function closePrompt(hashBelow) {
await page.click("#prompt-done");
await until(
"feuille du prompt fermée",
(below) => document.getElementById("prompt-veil").hidden && 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(),
);
const moveText = await openPrompt("#explain-ask-ai", "/ia");
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("bandeau", () => !document.getElementById("prompt-banner").hidden);
});
log(`prompt du coup copié (${copied.length} caractères)`);
// Échap ferme la feuille du prompt, pas l'explication dessous.
await page.keyboard.press("Escape");
await until(
"retour à l'explication",
(below) =>
document.getElementById("prompt-veil").hidden &&
!document.getElementById("explain-veil").hidden &&
location.hash === below,
explainHash,
);
// 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-sheet")?.dataset.ready === "true",
);
check((await page.textContent("#prompt-text")) === moveText, "le prompt rechargé diffère");
await closePrompt(explainHash);
await page.click("#explain-done");
await until("feuille du maître fermée", () => document.getElementById("explain-veil").hidden);
// ------------------------------------------------------------- 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");
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");
await closePrompt(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");
// 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 closePrompt(`#/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");
await closePrompt(`#/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");
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");
await closePrompt("#/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)`);
+7 -1
View File
@@ -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.
+125
View File
@@ -3406,3 +3406,128 @@ 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);
}
/* La feuille du prompt passe devant la feuille du maître et le bilan. */
.prompt-veil {
z-index: 12;
}
.prompt-sheet {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.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;
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;
}
html.landscape .prompt-text {
max-height: calc(100vh - 190px);
max-height: calc(100dvh - 190px);
}
/* « Prompt copié » : le bandeau des autres, en verdict-good, 2 s, devant
* la feuille du prompt ; en haut de l'écran, pour ne pas couvrir les
* boutons de la feuille. */
.prompt-banner,
html.landscape .prompt-banner {
top: var(--space-4);
bottom: auto;
z-index: 13;
color: var(--verdict-good);
font-weight: 700;
}
.prompt-banner > span:not(.banner-timer) {
flex: 1;
}
.prompt-banner .icon {
flex: none;
width: 20px;
height: 20px;
}
.prompt-banner .banner-timer {
background: var(--verdict-good);
}
.prompt-banner .banner-timer.running {
animation-duration: 2s;
}
/* 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;
}
+8
View File
@@ -166,6 +166,10 @@
construit par app/src/ui/summary-view.ts. -->
<div id="summary-veil" class="veil summary-veil" hidden></div>
<!-- Le prompt pour une IA (…/ia) : une feuille, au-dessus de l'écran d'où
on le demande, construite par app/src/ui/ask-ai.ts. -->
<div id="prompt-veil" class="veil sheet-veil prompt-veil" hidden></div>
<!-- La marche à suivre pour installer (#/installer) : une feuille. -->
<div id="install-veil" class="veil sheet-veil" hidden>
<div id="install-sheet" class="sheet" role="dialog" aria-modal="true" aria-labelledby="install-title">
@@ -214,6 +218,10 @@
<span class="banner-timer" aria-hidden="true"></span>
</div>
<!-- « Prompt copié », 2 s, au-dessus de la feuille du prompt (contenu
construit par app/src/ui/ask-ai.ts). -->
<div id="prompt-banner" class="banner prompt-banner" role="status" hidden></div>
<!-- « Nouvelle version prête. » : en bas, comme « Reprendre l'ancienne »,
seulement entre deux tours. -->
<div id="update-banner" class="banner update-banner" role="status" hidden>