diff --git a/Makefile b/Makefile index cabeeec..0cbc40b 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 et du tutoriel sous docs/, lues par -# l'aide et le tutoriel de l'application (ADR 0008 : jamais commitées +# - 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 +# (ADR 0008 : jamais commitées # ailleurs) ; # - version.json (commit publié) ; # - sw.js, complété par la liste de tous ces fichiers (scripts/service-worker.sh). @@ -141,7 +142,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 $(BUILD_DIR)/site/docs/ + cp -r docs/glossaire docs/scenarios docs/tutoriel docs/maitre $(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/analyse.ts b/app/src/domain/analysis/analyse.ts index dd7b180..22c38a3 100644 --- a/app/src/domain/analysis/analyse.ts +++ b/app/src/domain/analysis/analyse.ts @@ -10,12 +10,7 @@ // points faits. L'application lance ces analyses sur un second moteur, en // arrière-plan : elles ne retardent jamais la partie. -import { - ADVICE_VERSION, - type Advice, - type AdvisedMove, - type MoveFacts, -} from "../game/advice.js"; +import { ADVICE_VERSION, type Advice, type AdvisedMove, type MoveFacts } from "../game/advice.js"; import type { BoardSide, CubeAction, Engine, EvaluatedMove, Ply } from "../game/engine.js"; import { currentLine, type GameEvent, type GameJournal } from "../game/events.js"; import { engineCube, STANDARD_PLY } from "../game/game.js"; diff --git a/app/src/domain/analysis/phrases.ts b/app/src/domain/analysis/phrases.ts index 517f325..97cf8a2 100644 --- a/app/src/domain/analysis/phrases.ts +++ b/app/src/domain/analysis/phrases.ts @@ -9,9 +9,9 @@ import { type Advice, type CubeAdvice, - masterCubeAction, type MoveAdvice, type MoveFacts, + masterCubeAction, type Verdict, verdictOf, } from "../game/advice.js"; diff --git a/app/src/domain/game/engine.ts b/app/src/domain/game/engine.ts index 9df4fe0..935f6da 100644 --- a/app/src/domain/game/engine.ts +++ b/app/src/domain/game/engine.ts @@ -84,7 +84,12 @@ export interface Engine { * niveau ply, comme la commande « hint » de gnubg (les coups écartés * par ses filtres gardent l'évaluation d'un niveau inférieur, voir leur * ply). */ - rankMoves(position: PositionId, cube: EngineCube, roll: Roll, ply: Ply): Promise; + rankMoves( + position: PositionId, + cube: EngineCube, + roll: Roll, + ply: Ply, + ): Promise; /** L'analyse d'une décision de videau, position vue du joueur au trait * (celui qui double ou non ; take et pass sont la réponse de son diff --git a/app/src/main.ts b/app/src/main.ts index e34bcba..aa9ecbe 100644 --- a/app/src/main.ts +++ b/app/src/main.ts @@ -5,6 +5,7 @@ // Copyright (C) 2026 les auteurs d'Ustagammon. // Licence : GPLv3 ou ultérieure (voir LICENSE). +import { analyseDecision } from "./domain/analysis/analyse.js"; import { Game } from "./domain/game/game.js"; import { linkOf, readPosition } from "./domain/game/position-link.js"; import { exportSgf } from "./domain/game/sgf.js"; @@ -15,7 +16,7 @@ import { tutorialBoard, } from "./domain/learning/tutorial.js"; import { createWasmEngine } from "./engine/wasm-engine.js"; -import { startEngineWorker } from "./engine/worker-run.js"; +import { type EngineWorker, startEngineWorker } from "./engine/worker-run.js"; import { createGameSession } from "./storage/game-session.js"; import { memoryStorage, openGameLibrary } from "./storage/open-library.js"; import { createSettingsStore } from "./storage/settings-store.js"; @@ -27,6 +28,30 @@ import { startUpdates } from "./ui/updates.js"; const worker = startEngineWorker(new URL("../engine-worker.mjs", import.meta.url)); const engine = createWasmEngine(worker.run); +// Le conseil du maître (étape 1d) : les analyses à 2 plis tournent sur un +// second moteur, dans son propre Web Worker. Le premier ne traite qu'une +// commande à la fois : une analyse y retarderait le coup de l'ordinateur. +// Le second démarre à la première analyse demandée, une fois le premier +// chargé et le service worker installé (au plus 20 s d'attente) : si deux +// téléchargements des données du moteur se croisent, Chromium fait échouer +// l'installation hors ligne (voir startUpdates plus bas). Ensuite, les +// données viennent du cache. +let analysisWorker: Promise | null = null; +function startAnalysisWorker(): Promise { + analysisWorker ??= worker.ready + .then(() => { + const installed = + "serviceWorker" in navigator ? navigator.serviceWorker.ready : Promise.resolve(); + return Promise.race([installed, new Promise((resolve) => setTimeout(resolve, 20_000))]); + }) + .then(() => { + const started = startEngineWorker(new URL("../engine-worker.mjs", import.meta.url)); + return started.ready.then(() => started); + }); + return analysisWorker; +} +const analysisEngine = createWasmEngine(async (args) => (await startAnalysisWorker()).run(args)); + // Chaque accès au stockage du navigateur peut lever une exception (stockage // interdit) : on fait alors sans. const localStorage = attempt(() => window.localStorage); @@ -117,6 +142,7 @@ startApp({ // network error »), et l'application n'est pas disponible hors ligne. // Cela évite aussi de télécharger deux fois ces données à la première // visite. + analyse: (journal, eventId) => analyseDecision(analysisEngine, journal, eventId), 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 new file mode 100644 index 0000000..37dc21a --- /dev/null +++ b/app/src/ui/advice-texts.ts @@ -0,0 +1,55 @@ +// Les textes de l'interface du conseil du maître (ligne, feuille +// d'explication, menu). Les mots et les phrases du maître eux-mêmes sont +// dans docs/maitre/phrases.md (une seule source, ADR 0008). +// +// Copyright (C) 2026 les auteurs d'Ustagammon. +// Licence : GPLv3 ou ultérieure (voir LICENSE). + +import type { CubeAction } from "../domain/game/engine.js"; + +export const ADVICE_TEXTS = { + /** Interrupteur du menu, comme sur l'écran « Nouvelle partie ». */ + menuSwitch: "Conseil du maître", + lineLabel: (line: string) => `Conseil du maître : ${line} Toucher pour l'explication.`, + /** Paysage : la pastille du mot, au lecteur d'écran. */ + chipLabel: (line: string) => `Conseil du maître : ${line} Toucher pour l'explication.`, + sheetLabel: "Explication du maître", + yourMove: "Votre coup", + masterMove: "Le coup du maître", + choiceLabel: "Coup montré sur le plateau", + legendMine: (move: string) => + `Votre coup, ${move}, joué pion par pion sur la position d'avant.`, + legendBest: (move: string) => + `Le coup du maître, ${move}, joué pion par pion sur la position d'avant votre coup.`, + legendSame: (move: string) => `${move}, joué pion par pion sur la position d'avant.`, + legendCube: "La position au moment de la décision.", + replay: "Rejouer le coup", + numbers: "Voir les chiffres du moteur", + you: "(vous)", + moveNote: (ply: number) => + `Écart d'équité à ${ply} plis, par rapport au meilleur coup. Un écart de 0,03 ou moins reste un bon coup.`, + cubeNote: (ply: number) => + `Équités de celui qui double, pour un videau de 1, à ${ply} plis. Écart de votre décision : `, + cubeRows: { + noDouble: "Pas de double", + doubleTake: "Double, prend", + doublePass: "Double, passe", + }, + glossary: (term: string) => `En savoir plus : « ${term} »`, + close: "Fermer", + missing: "Le maître n'a pas d'explication pour ce coup.", +} as const; + +/** La fiche du glossaire d'une décision de videau, et son titre. */ +export const CUBE_GLOSSARY: Readonly> = { + double: { file: "doubler", term: "Doubler" }, + noDouble: { file: "doubler", term: "Doubler" }, + take: { file: "prendre", term: "Prendre" }, + pass: { file: "passer", term: "Passer" }, +}; + +/** Un nombre à la française, signé, à trois décimales : « −0,123 ». */ +export function formatEquity(value: number): string { + const text = Math.abs(value).toFixed(3).replace(".", ","); + return value < -0.0005 ? `−${text}` : value > 0.0005 ? `+${text}` : text; +} diff --git a/app/src/ui/advice-tracker.ts b/app/src/ui/advice-tracker.ts new file mode 100644 index 0000000..640cfec --- /dev/null +++ b/app/src/ui/advice-tracker.ts @@ -0,0 +1,152 @@ +// Le conseil du maître pendant la partie : quelles décisions du joueur +// analyser, quand montrer la ligne, quand l'effacer (charte, 1d « La ligne +// du maître »). Sans DOM : testé sous Node (app/tests/advice-tracker.test.ts). +// +// Copyright (C) 2026 les auteurs d'Ustagammon. +// Licence : GPLv3 ou ultérieure (voir LICENSE). +// +// Les analyses partent dès que la décision est jouée, sans être attendues : +// l'application les confie à un second moteur (main.ts), et le tour de +// l'ordinateur continue sur le premier. Chaque résultat est rattaché à sa +// décision dans le journal, pour le bilan (1e). La ligne apparaît quand +// l'analyse attendue est prête, si le joueur n'a rien fait entre-temps ; +// sinon elle n'apparaît pas, et le coup reste commenté dans le journal. + +import { playerDecisions } from "../domain/analysis/analyse.js"; +import type { Advice } from "../domain/game/advice.js"; +import type { GameEvent, GameJournal } from "../domain/game/events.js"; +import type { Game } from "../domain/game/game.js"; + +/** Analyse une décision du joueur (null : rien à en dire). */ +export type Analyse = (journal: GameJournal, eventId: string) => Promise; + +export interface AdviceTracker { + /** Le conseil à montrer maintenant (null : rien). */ + readonly shown: Advice | null; + /** Après une action du joueur sur game : before est la ligne de la + * partie avant l'action. Les décisions ajoutées sont analysées. */ + track(game: Game, before: readonly GameEvent[]): void; + /** L'action suivante du joueur : la ligne s'efface, et une analyse + * encore attendue ne s'affichera pas. */ + clear(): void; +} + +export interface AdviceTrackerOptions { + readonly analyse: Analyse; + /** Un conseil a été rattaché au journal de game (à enregistrer). */ + readonly recorded: (game: Game) => void; + /** Le conseil à montrer a changé. */ + readonly changed: () => void; +} + +export function createAdviceTracker(options: AdviceTrackerOptions): AdviceTracker { + let shown: Advice | null = null; + // Chaque attente de ligne a son jeton : clear() l'annule. + let waiting: object | null = null; + let tracked: Game | null = null; + let pending = new Map>(); + + /** Le conseil d'une décision : déjà rattaché, en cours, ou à demander. */ + function adviceOf(game: Game, id: string): Promise { + const known = game.advice[id]; + if (known !== undefined) { + return Promise.resolve(known); + } + let running = pending.get(id); + if (running === undefined) { + running = options + .analyse(game.journal, id) + .catch(() => null) + .then((advice) => { + if (advice !== null && game.journal.events.some((e) => e.id === id)) { + game.recordAdvice(advice); + options.recorded(game); + } + return advice; + }); + pending.set(id, running); + } + return running; + } + + return { + get shown() { + return shown; + }, + track(game, before) { + if (game !== tracked) { + tracked = game; + pending = new Map(); + } + const known = new Set(before.map((e) => e.id)); + const line = game.events; + const added = line.filter((e) => !known.has(e.id)); + for (const decision of playerDecisions(added)) { + void adviceOf(game, decision.id); + } + const candidates = toShow(line, added); + if (candidates.length === 0) { + return; + } + const token = {}; + waiting = token; + void Promise.all(candidates.map((id) => adviceOf(game, id))).then((results) => { + if (waiting !== token || tracked !== game) { + return; // trop tard : le joueur a déjà agi + } + waiting = null; + shown = choose(results); + if (shown !== null) { + options.changed(); + } + }); + }, + clear() { + waiting = null; + if (shown !== null) { + shown = null; + options.changed(); + } + }, + }; +} + +/** + * Les décisions dont la ligne peut parler après une action : le coup du + * joueur et, juste avant lui, son jet sans doubler (il aurait pu doubler) ; + * ou son double, ou sa réponse au double de l'ordinateur. Un jet seul ne + * montre rien : le joueur réfléchit alors à son coup. + */ +function toShow(line: readonly GameEvent[], added: readonly GameEvent[]): string[] { + const cube = added.find( + (e) => + (e.type === "CubeOffered" || e.type === "CubeTaken" || e.type === "CubeDropped") && + e.side === "player", + ); + if (cube !== undefined) { + return [cube.id]; + } + const move = added.find((e) => e.type === "MovePlayed" && e.side === "player"); + if (move?.type !== "MovePlayed") { + return []; + } + const index = line.findIndex((e) => e.id === move.id); + const roll = line + .slice(0, index) + .findLast((e) => e.type === "DiceRolled" && e.side === "player"); + const ids = move.halfMoves.length > 0 ? [move.id] : []; + return roll === undefined ? ids : [roll.id, ...ids]; +} + +/** Ce que la ligne montre : un double manqué avant le jet s'il était une + * faute, sinon le conseil sur le coup ou sur la décision de videau. */ +function choose(results: readonly (Advice | null)[]): Advice | null { + const advices = results.filter((a): a is Advice => a !== null); + const missedDouble = advices.find( + (a) => a.kind === "cube" && a.action === "noDouble" && a.skill !== "none", + ); + if (missedDouble !== undefined) { + return missedDouble; + } + return advices.find((a) => !(a.kind === "cube" && a.action === "noDouble")) ?? null; +} diff --git a/app/src/ui/advice-view.ts b/app/src/ui/advice-view.ts new file mode 100644 index 0000000..f200018 --- /dev/null +++ b/app/src/ui/advice-view.ts @@ -0,0 +1,399 @@ +// La feuille d'explication du maître (#/explication/<événement>), qui monte +// du bas au-dessus du plateau voilé (charte, 1d « La feuille +// d'explication ») : le mot et le contexte, le coup du maître en titre, sa +// phrase, « Votre coup » et « Le coup du maître » joués pion par pion sur la +// position d'avant, puis les chiffres du moteur, repliés. +// +// Copyright (C) 2026 les auteurs d'Ustagammon. +// Licence : GPLv3 ou ultérieure (voir LICENSE). + +import { type MasterPhrases, masterWords } from "../domain/analysis/phrases.js"; +import { + type Advice, + type CubeAdvice, + type MoveAdvice, + masterCubeAction, + type Verdict, +} from "../domain/game/advice.js"; +import type { Game, MoveSteps } from "../domain/game/game.js"; +import { ADVICE_TEXTS, CUBE_GLOSSARY, formatEquity } from "./advice-texts.js"; +import { boardModel, moveTrace, placeOf } from "./board-model.js"; +import { checkerSpot, NO_MARKS, renderBoard, slideChecker } from "./board-view.js"; +import { boardGeometry, PORTRAIT_ASPECT } from "./geometry.js"; +import { type IconName, icon } from "./icons.js"; +import { routeHash } from "./router.js"; + +const SLIDE_MS = 160; +const PAUSE_MS = 120; + +/** Le pictogramme d'une appréciation (interaction.md, pictogrammes). */ +export const VERDICT_ICONS: Readonly> = { + good: "verdictGood", + doubtful: "verdictDoubtful", + bad: "verdictBad", + veryBad: "verdictBad", +}; + +export interface AdviceViewOptions { + /** Le voile de la feuille (index.html) ; la feuille est construite ici. */ + readonly veil: HTMLElement; + readonly game: () => Game | null; + readonly phrases: () => Promise; + readonly reducedMotion: MediaQueryList; + /** Fermer : quitter l'adresse de la feuille. */ + readonly close: () => void; +} + +export interface AdviceView { + /** Ouvre la feuille sur le conseil de l'événement eventId. */ + show(eventId: string): Promise; + hide(): void; +} + +type Shown = "mine" | "best"; + +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 createAdviceView(options: AdviceViewOptions): AdviceView { + const { veil } = options; + const sheet = element(veil, "div", { id: "explain-sheet", className: "sheet explain-sheet" }); + sheet.setAttribute("role", "dialog"); + sheet.setAttribute("aria-modal", "true"); + sheet.setAttribute("aria-labelledby", "explain-title"); + veil.addEventListener("click", (event) => { + if (event.target === veil) { + options.close(); + } + }); + // Chaque ouverture a son numéro : une ouverture plus récente, ou la + // fermeture, arrête l'animation en cours. + let opened = 0; + // Chaque coup joué sur le plateau de la feuille a aussi le sien. + let playing = 0; + + async function show(eventId: string): Promise { + const id = ++opened; + const game = options.game(); + const advice = game?.advice[eventId] ?? null; + sheet.replaceChildren(); + veil.hidden = false; + const head = element(sheet, "div", { className: "sheet-head" }); + element(head, "span", { className: "grip" }).setAttribute("aria-hidden", "true"); + const verdictLine = element(head, "p", { + id: "explain-verdict", + className: "explain-verdict", + }); + const close = element(head, "button", { id: "explain-close", className: "icon-button" }); + close.type = "button"; + close.append(icon("close")); + close.setAttribute("aria-label", ADVICE_TEXTS.close); + close.onclick = () => options.close(); + const title = element(sheet, "h2", { id: "explain-title", className: "explain-title" }); + if (game === null || advice === null) { + title.textContent = ADVICE_TEXTS.sheetLabel; + element(sheet, "p", { className: "explain-phrase", text: ADVICE_TEXTS.missing }); + closeButton(); + close.focus(); + return; + } + const words = masterWords(await options.phrases(), advice); + if (id !== opened) { + return; + } + verdictLine.dataset.verdict = words.verdict; + verdictLine.append(icon(VERDICT_ICONS[words.verdict])); + element(verdictLine, "span", { text: `${words.word} · ${words.context}` }); + title.textContent = words.title; + element(sheet, "p", { + id: "explain-phrase", + className: "explain-phrase", + text: words.phrase, + }); + + const body = element(sheet, "div", { className: "explain-body" }); + const boardBox = element(body, "div", { className: "explain-board-box" }); + if (advice.kind === "move") { + await showMoves(id, game, advice, body, boardBox); + } else { + await showCube(id, game, advice, boardBox); + } + if (id !== opened) { + return; + } + numbers(advice); + if (advice.kind === "cube") { + const entry = CUBE_GLOSSARY[masterCubeAction(advice)]; + const link = element(sheet, "a", { + id: "explain-glossary", + className: "link-button explain-glossary", + text: ADVICE_TEXTS.glossary(entry.term), + }); + link.href = routeHash({ screen: "glossary", term: entry.file }); + } + closeButton(); + close.focus(); + } + + function closeButton(): void { + const actions = element(sheet, "div", { className: "explain-actions" }); + const done = element(actions, "button", { + id: "explain-done", + className: "secondary", + text: ADVICE_TEXTS.close, + }); + done.type = "button"; + done.onclick = () => options.close(); + } + + /** Le plateau de la feuille, dans son cadre. */ + function boardSvg(box: HTMLElement): SVGSVGElement { + const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); + svg.id = "explain-board"; + svg.setAttribute("class", "board explain-board"); + svg.setAttribute("role", "img"); + box.append(svg); + return svg; + } + + function draw(svg: SVGSVGElement, steps: MoveSteps, index: number, traced: boolean): void { + const boards = steps.boards[index]; + if (boards === undefined) { + return; + } + const width = svg.getBoundingClientRect().width || 358; + renderBoard( + svg, + boardGeometry("portrait", PORTRAIT_ASPECT, width / 640), + boardModel(boards), + { + ...NO_MARKS, + dice: [], + cube: null, + band: null, + controls: null, + trace: traced ? moveTrace(steps.side, steps.halfMoves, steps.hits) : null, + }, + ); + // Les flèches des traces renvoient à leur pointe par identifiant : + // celui du plateau de jeu serait pris à sa place. + for (const marker of svg.querySelectorAll("marker[id]")) { + marker.id = `explain-${marker.id}`; + } + for (const path of svg.querySelectorAll("[marker-end]")) { + const ref = path.getAttribute("marker-end") ?? ""; + path.setAttribute("marker-end", ref.replace("url(#", "url(#explain-")); + } + } + + /** Joue un coup pion par pion (sans mouvement si le système le + * demande), puis laisse ses traces. */ + async function animate(svg: SVGSVGElement, steps: MoveSteps): Promise { + const id = ++playing; + if (!options.reducedMotion.matches) { + for (const [i, halfMove] of steps.halfMoves.entries()) { + if (id !== playing) { + return; + } + if (i > 0) { + await new Promise((resolve) => setTimeout(resolve, PAUSE_MS)); + } + const from = placeOf(steps.side, halfMove.from); + const to = placeOf(steps.side, halfMove.to); + draw(svg, steps, i, false); + const start = checkerSpot(svg, from); + const victim = steps.hits[i] === true ? checkerSpot(svg, to) : null; + draw(svg, steps, i + 1, false); + await Promise.all([ + start && slideChecker(svg, to, start, SLIDE_MS), + victim && slideChecker(svg, "bar-computer", victim, SLIDE_MS), + ]); + } + } + if (id === playing) { + draw(svg, steps, steps.halfMoves.length, true); + } + } + + async function showMoves( + id: number, + game: Game, + advice: MoveAdvice, + body: HTMLElement, + boardBox: HTMLElement, + ): Promise { + const same = advice.rank === 1; + const [mine, best] = await Promise.all([ + game.stepsBefore(advice.eventId, "player", advice.played.halfMoves), + same ? null : game.stepsBefore(advice.eventId, "player", advice.best.halfMoves), + ]); + if (id !== opened) { + return; + } + const legend = element(body, "p", { id: "explain-legend", className: "explain-legend" }); + const svg = boardSvg(boardBox); + if (best === null) { + svg.dataset.move = "mine"; + svg.setAttribute("aria-label", ADVICE_TEXTS.legendSame(advice.played.notation)); + legend.textContent = ADVICE_TEXTS.legendSame(advice.played.notation); + draw(svg, mine, 0, false); + void animate(svg, mine); + return; + } + const choice = element(body, "div", { id: "explain-choice", className: "segmented" }); + choice.setAttribute("role", "radiogroup"); + choice.setAttribute("aria-label", ADVICE_TEXTS.choiceLabel); + body.prepend(choice); + const buttons: Record = { + mine: element(choice, "button", { + id: "explain-mine", + className: "segment", + text: ADVICE_TEXTS.yourMove, + }), + best: element(choice, "button", { + id: "explain-best", + className: "segment", + text: ADVICE_TEXTS.masterMove, + }), + }; + const select = (shown: Shown) => { + for (const key of ["mine", "best"] as const) { + buttons[key].setAttribute("aria-checked", String(key === shown)); + } + const notation = shown === "mine" ? advice.played.notation : advice.best.notation; + const text = + shown === "mine" + ? ADVICE_TEXTS.legendMine(notation) + : ADVICE_TEXTS.legendBest(notation); + legend.textContent = text; + svg.setAttribute("aria-label", text); + svg.dataset.move = shown; + const steps = shown === "mine" ? mine : best; + draw(svg, steps, 0, false); + void animate(svg, steps); + }; + for (const key of ["mine", "best"] as const) { + const button = buttons[key]; + button.type = "button"; + button.setAttribute("role", "radio"); + button.onclick = () => select(key); + } + // Flèches gauche et droite entre les deux segments. + choice.addEventListener("keydown", (event) => { + if (event.key === "ArrowLeft" || event.key === "ArrowRight") { + event.preventDefault(); + const next = buttons.mine.getAttribute("aria-checked") === "true" ? "best" : "mine"; + select(next); + buttons[next].focus(); + } + }); + // Par défaut, le coup du maître (maquette). + select("best"); + } + + async function showCube( + id: number, + game: Game, + advice: CubeAdvice, + boardBox: HTMLElement, + ): Promise { + const steps = await game.stepsBefore(advice.eventId, "player", []); + if (id !== opened) { + return; + } + const svg = boardSvg(boardBox); + svg.setAttribute("aria-label", ADVICE_TEXTS.legendCube); + draw(svg, steps, 0, false); + element(boardBox, "p", { className: "explain-legend", text: ADVICE_TEXTS.legendCube }); + } + + /** « Voir les chiffres du moteur », replié par défaut. */ + function numbers(advice: Advice): void { + const details = element(sheet, "details", { + id: "explain-numbers", + className: "explain-numbers", + }); + element(details, "summary", { text: ADVICE_TEXTS.numbers }); + const table = element(details, "div", { className: "explain-table" }); + const row = (cells: string[], strong: boolean) => { + for (const text of cells) { + element(table, "span", { className: strong ? "strong" : "", text }); + } + }; + if (advice.kind === "move") { + const best = advice.best.equity; + const listed = advice.ranking.map((move, i) => ({ move, rank: i + 1 })); + if (!advice.ranking.some((m) => m.notation === advice.played.notation)) { + listed.push({ move: advice.played, rank: advice.rank }); + } + for (const { move, rank } of listed) { + const mine = move.notation === advice.played.notation; + row( + [ + String(rank), + mine ? `${move.notation} ${ADVICE_TEXTS.you}` : move.notation, + formatEquity(move.equity - best), + ], + mine, + ); + } + element(details, "p", { + className: "explain-note", + text: ADVICE_TEXTS.moveNote(advice.ply), + }); + } else { + // Les lignes de votre décision, en gras : doubler mène à l'une des + // deux lignes « Double », selon la réponse de l'ordinateur. + const mine = { + noDouble: ["noDouble"], + double: ["doubleTake", "doublePass"], + take: ["doubleTake"], + pass: ["doublePass"], + }[advice.action]; + for (const key of ["noDouble", "doubleTake", "doublePass"] as const) { + const bold = mine.includes(key); + const name = ADVICE_TEXTS.cubeRows[key]; + row( + [ + "", + bold && mine.length === 1 ? `${name} ${ADVICE_TEXTS.you}` : name, + formatEquity(advice.equities[key]), + ], + bold, + ); + } + element(details, "p", { + className: "explain-note", + text: `${ADVICE_TEXTS.cubeNote(advice.ply)}${formatEquity(advice.equityLoss)}.`, + }); + } + } + + return { + show, + hide() { + opened++; + playing++; + if (!veil.hidden) { + veil.hidden = true; + sheet.replaceChildren(); + } + }, + }; +} diff --git a/app/src/ui/app.ts b/app/src/ui/app.ts index a2e7029..0ce3f3f 100644 --- a/app/src/ui/app.ts +++ b/app/src/ui/app.ts @@ -21,6 +21,13 @@ // dessine l'écran que désigne l'adresse, au démarrage et à chaque // changement. Les actions de jeu restent des boutons. +import { + type MasterPhrases, + masterLine, + masterWords, + parsePhrases, +} from "../domain/analysis/phrases.js"; +import type { Advice } from "../domain/game/advice.js"; import type { Cube } from "../domain/game/cube.js"; import type { GameJournal } from "../domain/game/events.js"; import type { Game, MoveSteps, NewGame, SideBoards } from "../domain/game/game.js"; @@ -29,6 +36,9 @@ import { type PositionLink, type SharedPosition, setupOf } from "../domain/game/ import type { SavedGameSummary } from "../domain/game/saved-game.js"; import { otherSide, type Side } from "../domain/game/side.js"; import type { GameState } from "../domain/game/state.js"; +import { ADVICE_TEXTS } from "./advice-texts.js"; +import { type Analyse, createAdviceTracker } from "./advice-tracker.js"; +import { createAdviceView, VERDICT_ICONS } from "./advice-view.js"; import { boardModel, moveTargets, @@ -40,6 +50,7 @@ import { placeOf, } from "./board-model.js"; import { + type BoardAdvice, type BoardDecor, checkerSpot, type DiceAction, @@ -119,8 +130,14 @@ export interface AppOptions { /** Les mises à jour de l'application (service worker) : onAvailable est * appelé quand une nouvelle version attend. */ readonly startUpdates: (onAvailable: () => void) => Updates; + /** Le conseil du maître : l'analyse d'une décision du joueur, sur un + * moteur à part (elle ne retarde jamais la partie). */ + readonly analyse: Analyse; } +/** La source des phrases du maître, copiée dans le site (make site). */ +const PHRASES_SOURCE = "docs/maitre/phrases.md"; + /** Ce que « Mes parties » demande au stockage. */ export interface SavedGamesAccess { /** Les parties, la dernière jouée d'abord. */ @@ -194,6 +211,9 @@ export function startApp(options: AppOptions): void { board: byId("board") as unknown as SVGSVGElement, boardBox: child(document.documentElement, ".board-box"), info: byId("info"), + adviceLine: byId("advice-line"), + explainVeil: byId("explain-veil"), + menuAdvice: byId("menu-advice"), prompt: byId("prompt"), strips: { computer: stripOf("strip-computer"), player: stripOf("strip-player") }, bubble: byId("pip-bubble"), @@ -340,6 +360,8 @@ export function startApp(options: AppOptions): void { label(ui.menuHelp, TEXTS.helpAndGlossary, "help"); ui.menuTheme.prepend(icon("theme")); child(ui.menuTheme, ".item-label").textContent = TEXTS.theme; + ui.menuAdvice.prepend(icon("verdictGood")); + child(ui.menuAdvice, ".item-label").textContent = ADVICE_TEXTS.menuSwitch; ui.menuVibrations.prepend(icon("vibrate")); child(ui.menuVibrations, ".item-label").textContent = TEXTS.vibrations; ui.menuScreenOn.prepend(icon("screenOn")); @@ -433,6 +455,36 @@ export function startApp(options: AppOptions): void { // Les parties jouées et gagnées, lues dans « Mes parties » pour // l'accueil. let homeProgress: { played: number; won: number } | null = null; + // Le conseil du maître : ses phrases (lues une fois), les analyses en + // cours et la ligne à montrer. + let phrases: MasterPhrases | null = null; + const phrasesLoading: Promise = fetch(PHRASES_SOURCE) + .then((response) => { + if (!response.ok) { + throw new Error(`phrases du maître : ${response.status}`); + } + return response.text(); + }) + .then((text) => { + phrases = parsePhrases(text); + render(); + return phrases; + }); + phrasesLoading.catch(() => undefined); + const adviceTracker = createAdviceTracker({ + analyse: options.analyse, + // Le conseil rattaché au journal est enregistré avec la partie, si + // elle est toujours à l'écran. + recorded: (g) => { + if (g === game) { + save(); + } + }, + changed: () => render(), + }); + // La ligne du maître dessinée (identifiant de sa décision) : elle n'est + // reconstruite, en fondu, que si elle change. + let adviceDrawn: string | null = null; const landscapeQuery = window.matchMedia("(orientation: landscape) and (max-height: 40rem)"); const portraitQuery = window.matchMedia("(orientation: portrait)"); @@ -538,6 +590,7 @@ export function startApp(options: AppOptions): void { band: landscape && !panelShown ? (text === null ? TEXTS.gameOver : text.short) : null, controls: landscape ? controls() : null, + advice: landscape && !panelShown ? boardAdvice(shownAdvice()) : null, }); } renderStrips(model.pipCount, state.phase === "over" ? null : state.turn); @@ -545,6 +598,8 @@ export function startApp(options: AppOptions): void { // Ligne d'information : un jet sans coup possible reste annoncé // jusqu'à l'action suivante, même après un rechargement de la page. + const advice = shownAdvice(); + renderAdviceLine(notice === null && !stuck ? advice : null); if (notice !== null) { ui.info.textContent = notice; } else if (stuck && state.roll !== null) { @@ -607,6 +662,69 @@ export function startApp(options: AppOptions): void { void keepScreenOn(isPlaying(game) || state.phase === "doubled"); } + /** Le conseil du maître à montrer : le réglage le permet, ses phrases + * sont lues, et l'analyse attendue est prête. */ + function shownAdvice(): { + advice: Advice; + line: string; + words: ReturnType; + } | null { + const advice = adviceTracker.shown; + if (!settings.masterAdvice || phrases === null || advice === null) { + return null; + } + const words = masterWords(phrases, advice); + return { advice, line: masterLine(words), words }; + } + + /** Portrait : la ligne du maître à la place du dernier coup (charte 1d) : + * pictogramme, mot en couleur, phrase en italique, chevron ; un lien vers + * la feuille d'explication. */ + function renderAdviceLine(shown: ReturnType): void { + ui.info.hidden = shown !== null; + ui.adviceLine.hidden = shown === null; + const id = shown?.advice.eventId ?? null; + if (shown === null || id === adviceDrawn) { + adviceDrawn = id; + return; + } + adviceDrawn = id; + const link = ui.adviceLine; + link.replaceChildren(); + link.href = routeHash({ screen: "explanation", event: shown.advice.eventId }); + link.dataset.verdict = shown.words.verdict; + link.setAttribute("aria-label", ADVICE_TEXTS.lineLabel(shown.line)); + link.append(icon(VERDICT_ICONS[shown.words.verdict])); + const text = document.createElement("span"); + text.className = "advice-text"; + const word = document.createElement("strong"); + word.className = "advice-word"; + word.textContent = `${shown.words.word}.`; + const phrase = document.createElement("em"); + phrase.className = "advice-phrase"; + phrase.textContent = shown.words.phrase; + text.append(word, " ", phrase); + link.append(text, icon("chevron")); + // Le fondu d'apparition repart à chaque nouvelle ligne. + link.classList.remove("appear"); + void link.offsetWidth; + link.classList.add("appear"); + } + + /** Paysage : la pastille du conseil dans la bande centrale. */ + function boardAdvice(shown: ReturnType): BoardAdvice | null { + if (shown === null) { + return null; + } + return { + href: routeHash({ screen: "explanation", event: shown.advice.eventId }), + verdict: shown.words.verdict, + icon: VERDICT_ICONS[shown.words.verdict], + word: shown.words.word, + label: ADVICE_TEXTS.chipLabel(shown.line), + }; + } + /** Avant que le moteur réponde : le dernier plateau affiché (ou la * position de départ), non jouable, et les zones à leur place. */ function renderLoading(g: Geometry): void { @@ -751,6 +869,7 @@ export function startApp(options: AppOptions): void { ); child(ui.menuTheme, ".item-value").textContent = TEXTS.themes[settings.theme]; ui.menuVibrations.setAttribute("aria-checked", String(settings.vibrations)); + ui.menuAdvice.setAttribute("aria-checked", String(settings.masterAdvice)); ui.menuScreenOn.setAttribute("aria-checked", String(settings.keepScreenOn)); } @@ -966,11 +1085,19 @@ export function startApp(options: AppOptions): void { bubbleSide = null; notice = null; ui.bubble.hidden = true; + // L'action suivante du joueur efface la ligne du maître. + adviceTracker.clear(); render(); const before = game; + const lineBefore = game.events; const wasOver = game.state.phase === "over"; try { await action(game); + // Ses décisions partent à l'analyse, sans être attendues : le + // tour de l'ordinateur suit tout de suite. + if (game === before) { + adviceTracker.track(game, lineBefore); + } save(); await computerTurns(); // Une partie vient de se terminer (pas une partie finie @@ -1078,7 +1205,10 @@ export function startApp(options: AppOptions): void { // L'adresse de la page dit quel écran montrer (applyRoute, plus bas). const navigation = startNavigation((route) => applyRoute(route)); const isSheet = (route: Route) => - route.screen === "menu" || route.screen === "games" || route.screen === "install"; + route.screen === "menu" || + route.screen === "games" || + route.screen === "install" || + route.screen === "explanation"; // « Mes parties » : reprendre une partie (ou une branche) en change à // l'écran, sans recharger la page. @@ -1102,6 +1232,15 @@ export function startApp(options: AppOptions): void { const helpView = createHelpView(ui.helpContent, ui.helpTabs); + // La feuille d'explication du maître (#/explication/<événement>). + const adviceView = createAdviceView({ + veil: ui.explainVeil, + game: () => game, + phrases: () => phrasesLoading, + reducedMotion, + close: () => leaveSheet(), + }); + /** Joue un demi-coup du joueur ; le coup attend ensuite sa suite ou sa * validation. Le pion qui se pose vibre (deux fois s'il frappe). */ function playHalfMove(halfMove: HalfMove): void { @@ -1271,6 +1410,7 @@ export function startApp(options: AppOptions): void { ui.menuVeil.hidden = true; ui.installVeil.hidden = true; gamesView?.hide(); + adviceView.hide(); if (menuWasOpen) { (menuReturnFocus ?? ui.menuButton).focus(); } @@ -1334,6 +1474,7 @@ export function startApp(options: AppOptions): void { ui.menuFullscreen.onclick = actions.fullscreen; ui.menuTheme.onclick = () => saveSettings({ theme: nextTheme(settings.theme) }); ui.menuVibrations.onclick = () => saveSettings({ vibrations: !settings.vibrations }); + ui.menuAdvice.onclick = () => saveSettings({ masterAdvice: !settings.masterAdvice }); ui.menuScreenOn.onclick = () => saveSettings({ keepScreenOn: !settings.keepScreenOn }); ui.fullscreenButton.onclick = actions.fullscreen; // « Retour » : à l'écran d'avant l'aide (la partie, si l'aide a été @@ -1430,6 +1571,15 @@ export function startApp(options: AppOptions): void { (menuReturnFocus ?? ui.menuButton).focus(); } } + // La feuille d'explication du maître, sur une décision de la partie + // à l'écran. + if (route.screen === "explanation") { + closeTransient(); + ui.menuVeil.hidden = true; + void adviceView.show(route.event); + } else { + adviceView.hide(); + } if (route.screen === "games") { const expanded = route.game; // La liste vient du stockage, ouvert avec le moteur. @@ -1942,6 +2092,10 @@ export function startApp(options: AppOptions): void { if (ui.game.hidden && name !== null && !["close", "help", "menu"].includes(name)) { name = null; } + // La feuille d'explication ouverte : le jeu attend qu'on la ferme. + if (!ui.explainVeil.hidden && name !== null && name !== "close") { + name = null; + } if (name !== null) { event.preventDefault(); actions[name](); @@ -1972,6 +2126,12 @@ export function startApp(options: AppOptions): void { announcedEnd = game.journal.head; } } + // Ouverte par son adresse avant que la partie soit là : la + // feuille d'explication la retrouve maintenant. + const route = navigation.route; + if (route.screen === "explanation") { + void adviceView.show(route.event); + } // Comme après une action du joueur : l'ordinateur joue si c'est // à lui (partie nouvelle ou reprise pendant son tour). Une partie // reprise juste après son coup le montre, sans l'animer. diff --git a/app/src/ui/board-view.ts b/app/src/ui/board-view.ts index 13c4ca2..443a067 100644 --- a/app/src/ui/board-view.ts +++ b/app/src/ui/board-view.ts @@ -83,6 +83,21 @@ export interface BoardDecor { readonly band: string | null; /** Paysage : la colonne de commandes (null : pas de colonne). */ readonly controls: BoardControls | null; + /** Paysage : le conseil du maître, une pastille avant la consigne dans + * la bande centrale (absent ou null : rien). */ + readonly advice?: BoardAdvice | null; +} + +/** Le conseil du maître en paysage : son mot, sur une pastille qui mène à + * la feuille d'explication. */ +export interface BoardAdvice { + readonly href: string; + /** Appréciation : la couleur et le pictogramme. */ + readonly verdict: "good" | "doubtful" | "bad" | "veryBad"; + readonly icon: IconName; + readonly word: string; + /** Ce que lit le lecteur d'écran : le mot, la phrase, l'invitation. */ + readonly label: string; } /** Ce qui ne s'ajoute pas aux pions pendant l'animation d'un coup. */ @@ -188,8 +203,10 @@ export function renderBoard( } svg.append(pieces); drawLandings(svg, g, board, decor.targets); + const advice = decor.band !== null ? (decor.advice ?? null) : null; + const chip = advice === null ? 0 : adviceChipWidth(g, advice) + 8; if (decor.band !== null) { - drawBand(svg, g, decor.band); + drawBand(svg, g, decor.band, chip); } drawDice(svg, g, decor.dice); const cube = decor.cube === null ? null : drawCube(svg, g, decor.cube); @@ -215,6 +232,9 @@ export function renderBoard( if (decor.controls !== null) { drawControls(svg, g, hits, decor.controls); } + if (advice !== null) { + drawAdviceChip(svg, g, hits, advice); + } // Les zones à toucher passent devant tout le reste. svg.append(hits); } @@ -496,12 +516,13 @@ function addRing( } /** Paysage : la consigne courte, dans la moitié gauche de la bande - * centrale, sur une pastille accent-soft. */ -function drawBand(svg: SVGSVGElement, g: Geometry, text: string): void { + * centrale, sur une pastille accent-soft ; elle laisse la place before à + * gauche (la pastille du conseil du maître). */ +function drawBand(svg: SVGSVGElement, g: Geometry, text: string, before = 0): void { const middle = g.height / 2; - const width = 6 * g.pointWidth - 24; + const width = 6 * g.pointWidth - 24 - before; const height = Math.max(44, g.bandFont + 20); - const x = g.x0 + 12; + const x = g.x0 + 12 + before; add(svg, "rect", { class: "band-pill", x, @@ -524,6 +545,63 @@ function drawBand(svg: SVGSVGElement, g: Geometry, text: string): void { ); } +/** Largeur de la pastille du conseil : pictogramme, mot (estimé à 0,55 em + * par lettre, police Alegreya Sans) et marges. */ +function adviceChipWidth(g: Geometry, advice: BoardAdvice): number { + return 16 + g.bandFont + 6 + advice.word.length * g.bandFont * 0.55 + 16; +} + +/** + * Paysage : le conseil du maître, une pastille au début de la bande + * centrale (pictogramme et mot en couleur d'appréciation), lien vers la + * feuille d'explication (charte 1d, adaptée au paysage : la phrase est dans + * la feuille). Posée parmi les zones à toucher, devant celle des dés. + */ +function drawAdviceChip(svg: SVGSVGElement, g: Geometry, hits: Element, advice: BoardAdvice): void { + const middle = g.height / 2; + const height = Math.max(44, g.bandFont + 20); + const width = adviceChipWidth(g, advice); + const x = g.x0 + 12; + const group = add(svg, "g", { class: `advice-chip verdict-${advice.verdict}` }); + add(group, "rect", { + class: "advice-chip-pill", + x, + y: middle - height / 2, + width, + height, + rx: height / 2, + }); + const size = g.bandFont; + const icon = add(group, "g", { + transform: `translate(${x + 16},${middle - size / 2}) scale(${size / 24})`, + }); + add(icon, "path", { class: "advice-chip-icon", d: ICON_PATHS[advice.icon] }); + add( + group, + "text", + { + class: "advice-chip-text", + x: x + 16 + size + 6, + y: middle + g.bandFont * 0.35, + "font-size": g.bandFont, + }, + advice.word, + ); + const link = add(hits, "a", { + href: advice.href, + id: "advice-chip", + role: "link", + "aria-label": advice.label, + }); + add(link, "rect", { + class: "hit action", + x, + y: middle - Math.max(height, g.target) / 2, + width, + height: Math.max(height, g.target), + }); +} + /** * Paysage : le score du match, dans une case sous le menu, dans l'ordre des * camps à l'écran : l'ordinateur en haut, « sur N », vous en bas diff --git a/app/src/ui/help.ts b/app/src/ui/help.ts index 5dc24c6..345fad5 100644 --- a/app/src/ui/help.ts +++ b/app/src/ui/help.ts @@ -30,6 +30,7 @@ import { TEXTS } from "./texts.js"; export const FEATURES = [ "docs/scenarios/jouer-une-partie.feature", "docs/scenarios/jouer-un-match.feature", + "docs/scenarios/comprendre-mes-coups.feature", "docs/scenarios/apprendre-les-regles.feature", "docs/scenarios/mes-parties.feature", "docs/scenarios/partager-une-position.feature", diff --git a/app/src/ui/router.ts b/app/src/ui/router.ts index 2226cfd..46fbe18 100644 --- a/app/src/ui/router.ts +++ b/app/src/ui/router.ts @@ -31,6 +31,9 @@ export type Route = | { 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 } /** #/position// (Match ID facultatif). */ | { readonly screen: "position"; readonly position: string; readonly match: string | null } /** Une adresse que l'application ne connaît pas. */ @@ -90,6 +93,8 @@ export function parseRoute(hash: string): Route { return name === null ? { screen: "menu" } : unknown; case "nouvelle-partie": return name === null ? { screen: "newGame" } : unknown; + case "explication": + return name === null ? unknown : { screen: "explanation", event: name }; case "parties": return { screen: "games", game: name }; case "installer": @@ -172,6 +177,8 @@ export function routeHash(route: Route): string { return "#/menu"; case "newGame": return "#/nouvelle-partie"; + case "explanation": + return `#/explication/${route.event}`; case "position": { const position = `#/position/${encodeURIComponent(route.position)}`; return route.match === null diff --git a/app/tests/advice-tracker.test.ts b/app/tests/advice-tracker.test.ts new file mode 100644 index 0000000..ea571b6 --- /dev/null +++ b/app/tests/advice-tracker.test.ts @@ -0,0 +1,141 @@ +// Le conseil du maître pendant la partie (app/src/ui/advice-tracker.ts) : +// les analyses ne retardent pas le tour de l'ordinateur, la ligne +// n'apparaît qu'après la décision et pas si le joueur a déjà agi, et +// chaque conseil est rattaché au journal. +// +// Copyright (C) 2026 les auteurs d'Ustagammon. +// Licence : GPLv3 ou ultérieure (voir LICENSE). + +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { analyseDecision } from "../src/domain/analysis/analyse.js"; +import type { Advice } from "../src/domain/game/advice.js"; +import type { GameJournal } from "../src/domain/game/events.js"; +import { Game } from "../src/domain/game/game.js"; +import { parseHalfMoves, START_POSITION } from "../src/domain/game/position.js"; +import { fixedDice } from "../src/domain/game/roll.js"; +import { createAdviceTracker } from "../src/ui/advice-tracker.js"; +import { DOUBLE_TAKE_POSITION } from "./support/advice.js"; +import { loadNodeEngine } from "./support/node-engine.js"; + +/** Une analyse qu'on libère à la main. */ +function heldAnalyses() { + const held: { id: string; release: () => Promise }[] = []; + const analyse = (journal: GameJournal, eventId: string) => + new Promise((resolve) => { + held.push({ + id: eventId, + release: async () => { + resolve(await analyseDecision(engine, journal, eventId)); + await new Promise((r) => setTimeout(r, 0)); + }, + }); + }); + return { held, analyse }; +} + +let engine: Awaited>; + +async function opening(dice: number[]): Promise { + engine = await loadNodeEngine(); + return Game.start(engine, { + setup: { position: START_POSITION, firstTurn: "player" }, + dice: fixedDice(dice), + }); +} + +async function play(game: Game, move: string): Promise { + for (const halfMove of parseHalfMoves(move)) { + assert.ok(await game.playHalfMove(halfMove)); + } + await game.confirmMove(); +} + +test("le tour de l'ordinateur n'attend pas l'analyse ; la ligne vient ensuite", async () => { + const game = await opening([3, 1, 6, 5, 4, 2]); + const { held, analyse } = heldAnalyses(); + let saved = 0; + let changes = 0; + const tracker = createAdviceTracker({ + analyse, + recorded: () => saved++, + changed: () => changes++, + }); + let before = game.events; + await game.roll(); + tracker.track(game, before); + // Un jet seul ne montre rien : le joueur réfléchit. + assert.equal(held.length, 1, "le jet est analysé (aurait-il fallu doubler ?)"); + before = game.events; + await play(game, "24/23 13/10"); + tracker.track(game, before); + // Les analyses sont en attente : l'ordinateur joue quand même. + await game.playComputerTurn(); + assert.equal(game.state.turn, "player"); + assert.equal(tracker.shown, null); + for (const h of held) { + await h.release(); + } + const shown = tracker.shown as Advice | null; + assert.equal(shown?.kind, "move"); + assert.equal(shown?.skill, "veryBad"); + assert.equal(changes, 1); + // Le coup est rattaché au journal (le jet aussi s'il y avait à dire). + assert.ok(saved >= 1); + assert.ok(game.advice[shown?.eventId ?? ""] !== undefined); + // L'action suivante du joueur efface la ligne. + tracker.clear(); + assert.equal(tracker.shown, null); +}); + +test("si le joueur a déjà agi, la ligne n'apparaît pas, mais le conseil reste au journal", async () => { + const game = await opening([3, 1, 6, 5]); + const { held, analyse } = heldAnalyses(); + const tracker = createAdviceTracker({ analyse, recorded: () => {}, changed: () => {} }); + await game.roll(); + const before = game.events; + await play(game, "8/5 6/5"); + tracker.track(game, before); + tracker.clear(); // le joueur a relancé avant l'analyse + for (const h of held) { + await h.release(); + } + assert.equal(tracker.shown, null); + const move = game.events.findLast((e) => e.type === "MovePlayed"); + assert.equal(game.advice[move?.id ?? ""]?.skill, "none"); +}); + +test("un double manqué avant le jet passe avant le coup dans la ligne", async () => { + engine = await loadNodeEngine(); + const game = Game.start(engine, { + setup: { position: DOUBLE_TAKE_POSITION, firstTurn: "player" }, + dice: fixedDice([6, 5]), + }); + const tracker = createAdviceTracker({ + analyse: (journal, id) => analyseDecision(engine, journal, id), + recorded: () => {}, + changed: () => {}, + }); + let before = game.events; + await game.roll(); + tracker.track(game, before); + const best = await engine.bestMove( + game.state.position, + { value: 1, owner: "centered" }, + [6, 5], + 0, + ); + assert.ok(best !== null); + before = game.events; + for (const halfMove of best.halfMoves) { + assert.ok(await game.playHalfMove(halfMove)); + } + await game.confirmMove(); + tracker.track(game, before); + for (let i = 0; i < 50 && tracker.shown === null; i++) { + await new Promise((r) => setTimeout(r, 20)); + } + const shown = tracker.shown as Advice | null; + assert.equal(shown?.kind, "cube"); + assert.equal(shown?.kind === "cube" ? shown.action : null, "noDouble"); +}); diff --git a/app/tests/advice.test.ts b/app/tests/advice.test.ts index e065f37..301e60a 100644 --- a/app/tests/advice.test.ts +++ b/app/tests/advice.test.ts @@ -47,47 +47,74 @@ test("la source des phrases a toutes ses sections, sans point d'exclamation", () const cases: { name: string; play: () => Promise; word: string }[] = [ { name: "3-1 joué 8/5 6/5 : le meilleur coup", - play: async () => adviceOn(await loadNodeEngine(), await playOpening(await loadNodeEngine(), [3, 1], "8/5 6/5")), + play: async () => + adviceOn( + await loadNodeEngine(), + await playOpening(await loadNodeEngine(), [3, 1], "8/5 6/5"), + ), word: "Bon coup", }, { name: "2-1 joué 13/11 6/5 : tout près du meilleur", - play: async () => adviceOn(await loadNodeEngine(), await playOpening(await loadNodeEngine(), [2, 1], "13/11 6/5")), + play: async () => + adviceOn( + await loadNodeEngine(), + await playOpening(await loadNodeEngine(), [2, 1], "13/11 6/5"), + ), word: "Bon coup", }, { name: "2-1 joué 13/10 : imprécision", - play: async () => adviceOn(await loadNodeEngine(), await playOpening(await loadNodeEngine(), [2, 1], "13/11 11/10")), + play: async () => + adviceOn( + await loadNodeEngine(), + await playOpening(await loadNodeEngine(), [2, 1], "13/11 11/10"), + ), word: "Imprécision", }, { name: "2-1 joué 24/22 6/5 : erreur", - play: async () => adviceOn(await loadNodeEngine(), await playOpening(await loadNodeEngine(), [2, 1], "24/22 6/5")), + play: async () => + adviceOn( + await loadNodeEngine(), + await playOpening(await loadNodeEngine(), [2, 1], "24/22 6/5"), + ), word: "Erreur", }, { name: "3-1 joué 24/23 13/10 : grosse erreur", - play: async () => adviceOn(await loadNodeEngine(), await playOpening(await loadNodeEngine(), [3, 1], "24/23 13/10")), + play: async () => + adviceOn( + await loadNodeEngine(), + await playOpening(await loadNodeEngine(), [3, 1], "24/23 13/10"), + ), word: "Grosse erreur", }, { name: "double dans une position de double et prise", - play: async () => adviceOn(await loadNodeEngine(), await playerDoubles(await loadNodeEngine())), + play: async () => + adviceOn(await loadNodeEngine(), await playerDoubles(await loadNodeEngine())), word: "Bonne décision", }, { name: "lancer sans doubler dans une position de double", - play: async () => adviceOn(await loadNodeEngine(), await playerRollsWithoutDoubling(await loadNodeEngine())), + play: async () => + adviceOn( + await loadNodeEngine(), + await playerRollsWithoutDoubling(await loadNodeEngine()), + ), word: "Erreur", }, { name: "prendre un double juste", - play: async () => adviceOn(await loadNodeEngine(), await playerAnswers(await loadNodeEngine(), true)), + play: async () => + adviceOn(await loadNodeEngine(), await playerAnswers(await loadNodeEngine(), true)), word: "Bonne décision", }, { name: "passer un double qu'il fallait prendre", - play: async () => adviceOn(await loadNodeEngine(), await playerAnswers(await loadNodeEngine(), false)), + play: async () => + adviceOn(await loadNodeEngine(), await playerAnswers(await loadNodeEngine(), false)), word: "Erreur", }, ]; @@ -113,7 +140,10 @@ for (const c of cases) { test("le coup du maître est nommé, et le fait qui distingue les coups vient du plateau", async () => { const engine = await loadNodeEngine(); - const best = (await adviceOn(engine, await playOpening(engine, [3, 1], "8/5 6/5"))) as MoveAdvice; + const best = (await adviceOn( + engine, + await playOpening(engine, [3, 1], "8/5 6/5"), + )) as MoveAdvice; assert.equal(best.kind, "move"); assert.equal(best.version, ADVICE_VERSION); assert.equal(best.rank, 1); @@ -180,7 +210,9 @@ test("tous les gabarits, remplis avec les plus longues notations, restent sous l skill: rank === 1 ? "none" : skill, }; for (let id = 0; id < 20; id++) { - const line = masterLine(masterWords(phrases, { ...advice, eventId: String(id) })); + const line = masterLine( + masterWords(phrases, { ...advice, eventId: String(id) }), + ); assert.ok(line.length < LINE_LIMIT, `trop long (${line.length}) : ${line}`); } } diff --git a/app/tests/router.test.ts b/app/tests/router.test.ts index 9b2efb9..92b9fba 100644 --- a/app/tests/router.test.ts +++ b/app/tests/router.test.ts @@ -37,6 +37,7 @@ const TABLE: [string, Route][] = [ ["#/parties/mh2k3x-a1b2c3", { screen: "games", game: "mh2k3x-a1b2c3" }], ["#/menu", { screen: "menu" }], ["#/nouvelle-partie", { screen: "newGame" }], + ["#/explication/12", { screen: "explanation", event: "12" }], [ "#/position/4HPwATDgc%2FABMA/cAkAAAAAAAAA", { screen: "position", position: START, match: MATCH }, diff --git a/app/tests/steps/comprendre-mes-coups.steps.ts b/app/tests/steps/comprendre-mes-coups.steps.ts new file mode 100644 index 0000000..cd63cad --- /dev/null +++ b/app/tests/steps/comprendre-mes-coups.steps.ts @@ -0,0 +1,145 @@ +// Étapes du scénario docs/scenarios/comprendre-mes-coups.feature (ADR 0008) : +// les décisions passent par le domaine et le vrai moteur, le conseil par +// l'analyse du domaine, ses mots par la source des phrases du maître, et +// la ligne par le suivi du conseil de l'interface (advice-tracker.ts) avec +// le réglage de l'application. +// +// Copyright (C) 2026 les auteurs d'Ustagammon. +// Licence : GPLv3 ou ultérieure (voir LICENSE). + +import assert from "node:assert/strict"; +import { Before, BeforeAll, Given, setDefaultTimeout, Then, When } from "@cucumber/cucumber"; +import { analyseDecision } from "../../src/domain/analysis/analyse.js"; +import { 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 { Game } from "../../src/domain/game/game.js"; +import { parseHalfMoves, START_POSITION } from "../../src/domain/game/position.js"; +import { fixedDice } from "../../src/domain/game/roll.js"; +import { createAdviceTracker } from "../../src/ui/advice-tracker.js"; +import { parseSettings, type Settings } from "../../src/ui/settings.js"; +import { DOUBLE_TAKE_POSITION, loadPhrases } from "../support/advice.js"; +import { loadNodeEngine } from "../support/node-engine.js"; + +setDefaultTimeout(30_000); + +const phrases = loadPhrases(); +let engine: Engine; +let game: Game; +let settings: Settings; +let tracker: ReturnType; +let ready: Promise; +let markReady: () => void; + +BeforeAll(async () => { + engine = await loadNodeEngine(); +}); + +Before(() => { + settings = parseSettings(null); + ready = new Promise((resolve) => { + markReady = resolve; + }); + tracker = createAdviceTracker({ + analyse: (journal, eventId) => analyseDecision(engine, journal, eventId), + recorded: () => {}, + changed: () => markReady(), + }); +}); + +/** Joue une décision du joueur comme l'écran de jeu : la ligne d'avant + * s'efface, la décision part à l'analyse. */ +async function decide(action: (g: Game) => Promise | void): Promise { + tracker.clear(); + const before = game.events; + await action(game); + tracker.track(game, before); +} + +/** Ce que montre la ligne du maître (null : rien), comme l'écran : le + * conseil prêt, si le réglage le permet. */ +async function line(): Promise<{ word: string; phrase: string; advice: Advice } | null> { + await Promise.race([ready, new Promise((resolve) => setTimeout(resolve, 10_000))]); + const advice = tracker.shown; + if (!settings.masterAdvice || advice === null) { + return null; + } + const words = masterWords(phrases, advice); + return { word: words.word, phrase: words.phrase, advice }; +} + +Given("vous avez {int}-{int} à jouer à la position de départ", async (a: number, b: number) => { + game = Game.start(engine, { + setup: { position: START_POSITION, firstTurn: "player" }, + dice: fixedDice([a, b, 6, 5]), + }); + await decide((g) => g.roll()); +}); + +Given("vous avez coupé le conseil du maître", () => { + settings = { ...settings, masterAdvice: false }; +}); + +When("vous validez le coup {}", async (move: string) => { + await decide(async (g) => { + for (const halfMove of parseHalfMoves(move)) { + assert.ok(await g.playHalfMove(halfMove), `demi-coup refusé : ${move}`); + } + await g.confirmMove(); + }); + // L'ordinateur joue aussitôt, sans attendre l'analyse. + await game.playComputerTurn(); +}); + +Then("le maître dit « {}. »", async (word: string) => { + const shown = await line(); + assert.equal(shown?.word, word); +}); + +Then("sa phrase dit que {} fait votre point {int}", async (move: string, point: number) => { + const shown = await line(); + assert.ok(shown?.phrase.includes(`${move} fait votre point ${point}.`), shown?.phrase); +}); + +Then("sa phrase commence par « {} »", async (start: string) => { + const shown = await line(); + assert.ok(shown?.phrase.startsWith(start), shown?.phrase); +}); + +Then("sa phrase dit « {} »", async (phrase: string) => { + const shown = await line(); + assert.equal(shown?.phrase, phrase); +}); + +Then("l'explication peut montrer votre coup et le coup du maître sur le plateau", async () => { + const shown = await line(); + assert.ok(shown !== null && shown.advice.kind === "move"); + const advice = shown.advice; + const mine = await game.stepsBefore(advice.eventId, "player", advice.played.halfMoves); + const best = await game.stepsBefore(advice.eventId, "player", advice.best.halfMoves); + // Les deux coups partent de la même position, celle d'avant le coup. + assert.deepEqual(mine.boards[0], best.boards[0]); + assert.equal(best.halfMoves.length, 2); + assert.notDeepEqual(mine.boards.at(-1), best.boards.at(-1)); +}); + +Then("le maître ne dit rien", async () => { + assert.equal(await line(), null); + // Le coup est tout de même analysé, pour le bilan de fin de partie. + const move = game.events.find((e) => e.type === "MovePlayed" && e.side === "player"); + assert.ok(game.advice[move?.id ?? ""] !== undefined); +}); + +Given("l'ordinateur vous propose de doubler dans une position où il faut prendre", async () => { + game = Game.start(engine, { + setup: { position: DOUBLE_TAKE_POSITION, firstTurn: "computer" }, + level: "2ply", + dice: fixedDice([]), + }); + await game.playComputerTurn(); + assert.equal(game.state.phase, "doubled"); +}); + +When("vous passez son double", async () => { + await decide((g) => g.drop()); +}); diff --git a/docs/scenarios/README.md b/docs/scenarios/README.md index 6038d7b..9b14727 100644 --- a/docs/scenarios/README.md +++ b/docs/scenarios/README.md @@ -57,6 +57,7 @@ Règles d'écriture : | --- | --- | --- | | [jouer-une-partie.feature](jouer-une-partie.feature) | Jouer une partie contre l'ordinateur | Étape 0d | | [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 | | [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/comprendre-mes-coups.feature b/docs/scenarios/comprendre-mes-coups.feature new file mode 100644 index 0000000..321420c --- /dev/null +++ b/docs/scenarios/comprendre-mes-coups.feature @@ -0,0 +1,48 @@ +# language: fr +Fonctionnalité: Comprendre mes coups avec le conseil du maître + + Après chacun de vos coups, et après chacune de vos décisions de videau, + le maître dit en une ligne ce qu'il en pense : « Bon coup. », + « Imprécision. », « Erreur. » ou « Grosse erreur. », puis une phrase, + par exemple le coup qu'il aurait joué. Il ne parle jamais avant que vous + ayez joué, et la ligne s'efface dès que vous lancez les dés ou touchez un + pion. Touchez-la pour ouvrir l'explication : votre coup et celui du + maître, joués pion par pion sur le plateau, et, si vous le demandez, les + chiffres du moteur. Le maître, c'est GNU Backgammon : il ne dit que ce + que le moteur a calculé. + + Scénario: Le maître approuve un bon coup + Avec 3-1 à l'ouverture, 8/5 6/5 est le coup que le moteur préfère. + + Étant donné que vous avez 3-1 à jouer à la position de départ + Quand vous validez le coup 8/5 6/5 + Alors le maître dit « Bon coup. » + Et sa phrase dit que 8/5 6/5 fait votre point 5 + + Scénario: Le maître montre le coup qu'il aurait joué + Après une erreur, la ligne nomme le coup du maître. L'explication le + joue sur le plateau, à côté du vôtre. + + Étant donné que vous avez 3-1 à jouer à la position de départ + Quand vous validez le coup 24/23 13/10 + Alors le maître dit « Grosse erreur. » + Et sa phrase commence par « J'aurais joué 8/5 6/5. » + Et l'explication peut montrer votre coup et le coup du maître sur le plateau + + Scénario: Le conseil du maître est coupé + Le conseil se coupe dans le menu, ou sur l'écran « Nouvelle partie ». + La ligne reste alors vide, et le plateau ne bouge pas. + + Étant donné que vous avez coupé le conseil du maître + Et que vous avez 3-1 à jouer à la position de départ + Quand vous validez le coup 24/23 13/10 + Alors le maître ne dit rien + + Scénario: Le maître commente une décision de videau + Après un double de l'ordinateur, le maître dit si vous avez bien fait + de prendre ou de passer. + + Étant donné que l'ordinateur vous propose de doubler dans une position où il faut prendre + Quand vous passez son double + Alors le maître dit « Erreur. » + Et sa phrase dit « Ici, j'aurais pris. » diff --git a/web/app.css b/web/app.css index f23d9fb..2267b91 100644 --- a/web/app.css +++ b/web/app.css @@ -2549,3 +2549,221 @@ html.landscape .position-view.with-board .position-actions { html.landscape .position-view.with-board .position-actions > * { padding-inline: 16px; } + +/* ------------------------------------------- Conseil du maître (1d) */ + +/* La ligne du maître : à la place du dernier coup, dans la ligne réservée + * de 44 px (elle ne pousse rien). Pictogramme et mot en couleur + * d'appréciation, phrase en italique tronquée, chevron. */ +.advice-line { + display: flex; + flex: 1; + gap: var(--space-2); + align-items: center; + min-width: 0; + height: 44px; + font-size: 15px; + line-height: 20px; + color: var(--text); + text-decoration: none; + border-radius: 12px; + -webkit-tap-highlight-color: transparent; +} + +.advice-line.appear { + animation: fade-in var(--duration-fast) var(--easing-standard); +} + +.advice-line .icon { + width: 20px; + height: 20px; +} + +.advice-line .icon-chevron { + width: 16px; + height: 16px; + color: var(--muted); +} + +.advice-text { + flex: 1; + min-width: 0; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; +} + +.advice-word { + font-weight: 700; +} + +[data-verdict="good"] .icon:not(.icon-chevron), +[data-verdict="good"] .advice-word { color: var(--verdict-good); } +[data-verdict="doubtful"] .icon:not(.icon-chevron), +[data-verdict="doubtful"] .advice-word { color: var(--verdict-doubtful); } +[data-verdict="bad"] .icon:not(.icon-chevron), +[data-verdict="bad"] .advice-word, +[data-verdict="veryBad"] .icon:not(.icon-chevron), +[data-verdict="veryBad"] .advice-word { color: var(--verdict-bad); } + +/* Paysage : la pastille du mot, au début de la bande centrale. */ +.advice-chip-pill { + fill: var(--surface); + stroke: currentColor; + stroke-width: 2; +} + +.advice-chip-icon { + fill: none; + stroke: currentColor; + stroke-width: 2px; + stroke-linecap: round; + stroke-linejoin: round; + vector-effect: non-scaling-stroke; +} + +.advice-chip-text { + fill: currentColor; + font-weight: 700; +} + +.advice-chip.verdict-good { color: var(--verdict-good); } +.advice-chip.verdict-doubtful { color: var(--verdict-doubtful); } +.advice-chip.verdict-bad, +.advice-chip.verdict-veryBad { color: var(--verdict-bad); } + +/* La feuille d'explication : elle monte du bas, au-dessus du plateau + * voilé. */ +.explain-sheet { + display: flex; + flex-direction: column; + gap: var(--space-3); +} + +.explain-verdict { + display: inline-flex; + gap: 6px; + align-items: center; + margin: 0; + font-size: 15px; + line-height: 20px; + font-weight: 700; +} + +.explain-verdict .icon { + width: 18px; + height: 18px; +} + +.explain-verdict[data-verdict="good"] { color: var(--verdict-good); } +.explain-verdict[data-verdict="doubtful"] { color: var(--verdict-doubtful); } +.explain-verdict[data-verdict="bad"], +.explain-verdict[data-verdict="veryBad"] { color: var(--verdict-bad); } + +.explain-title { + margin: 0; + font-size: 24px; + line-height: 30px; +} + +.explain-phrase { + margin: 0; + font-style: italic; +} + +.explain-body { + display: flex; + flex-direction: column; + gap: var(--space-2); +} + +.explain-body .segment { + min-height: 44px; + font-size: 15px; +} + +/* Le coup montré : le vôtre en couleur du texte, celui du maître en + * couleur du bon coup (charte 1d). */ +.explain-board[data-move="mine"] { --last-move: var(--text); } +.explain-board[data-move="best"] { --last-move: var(--verdict-good); } + +.explain-legend { + margin: 0; + font-size: 13px; + line-height: 18px; + color: var(--muted); +} + +.explain-numbers { + border-top: 1px solid var(--border); +} + +.explain-numbers summary { + display: flex; + align-items: center; + min-height: 44px; + font-size: 15px; + font-weight: 500; + cursor: pointer; +} + +.explain-table { + display: grid; + grid-template-columns: auto 1fr auto; + gap: 4px 12px; + font-size: 15px; + line-height: 20px; + font-variant-numeric: tabular-nums; +} + +.explain-table .strong { + font-weight: 700; +} + +.explain-note { + margin: var(--space-2) 0 0; + font-size: 13px; + line-height: 18px; + color: var(--muted); +} + +.explain-glossary { + min-height: 44px; +} + +.explain-actions { + display: flex; + justify-content: flex-end; +} + +.explain-actions button { + min-width: 140px; +} + +/* En paysage : la feuille prend la hauteur de l'écran ; le plateau à + * gauche, le texte à droite. */ +html.landscape .explain-board { + width: 100%; + height: auto; + aspect-ratio: 640 / 520; +} + +html.landscape .explain-sheet { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + grid-auto-flow: dense; + column-gap: var(--space-4); + align-content: start; + width: min(100%, 60rem); + height: 100%; + border-radius: 0; +} + +html.landscape .explain-sheet > * { + grid-column: 2; +} + +html.landscape .explain-sheet > .explain-body { + grid-column: 1; + grid-row: 1 / span 8; +} diff --git a/web/index.html b/web/index.html index f885694..0c196ac 100644 --- a/web/index.html +++ b/web/index.html @@ -63,8 +63,12 @@ +

+
@@ -143,12 +147,17 @@ + + + +