From 1330ee6df48be6c83194ea90bd4f264ea5a0fe00 Mon Sep 17 00:00:00 2001 From: Pierre MARTIN Date: Tue, 10 Nov 2026 04:36:57 +0100 Subject: [PATCH 01/10] =?UTF-8?q?Conseil=20du=20ma=C3=AEtre=20:=20analyse?= =?UTF-8?q?=20des=20d=C3=A9cisions,=20phrases=20du=20ma=C3=AEtre,=20consei?= =?UTF-8?q?l=20rattach=C3=A9=20au=20journal?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- app/src/domain/analysis/analyse.ts | 171 ++++++++++++++++++ app/src/domain/analysis/phrases.ts | 263 ++++++++++++++++++++++++++++ app/src/domain/game/advice.ts | 124 +++++++++++++ app/src/domain/game/engine.ts | 6 + app/src/domain/game/events.ts | 52 +++++- app/src/domain/game/game.ts | 63 +++++-- app/src/engine/wasm-engine.ts | 8 + app/tests/advice.test.ts | 224 +++++++++++++++++++++++ app/tests/game.test.ts | 1 + app/tests/support/advice.ts | 108 ++++++++++++ docs/glossaire/README.md | 1 + docs/glossaire/conseil-du-maitre.md | 18 ++ docs/maitre/phrases.md | 216 +++++++++++++++++++++++ 13 files changed, 1232 insertions(+), 23 deletions(-) create mode 100644 app/src/domain/analysis/analyse.ts create mode 100644 app/src/domain/analysis/phrases.ts create mode 100644 app/src/domain/game/advice.ts create mode 100644 app/tests/advice.test.ts create mode 100644 app/tests/support/advice.ts create mode 100644 docs/glossaire/conseil-du-maitre.md create mode 100644 docs/maitre/phrases.md diff --git a/app/src/domain/analysis/analyse.ts b/app/src/domain/analysis/analyse.ts new file mode 100644 index 0000000..dd7b180 --- /dev/null +++ b/app/src/domain/analysis/analyse.ts @@ -0,0 +1,171 @@ +// L'analyse des décisions du joueur par le moteur, pour le conseil du +// maître (étape 1d, ADR 0011) et le bilan de fin de partie (1e). +// +// Copyright (C) 2026 les auteurs d'Ustagammon. +// Licence : GPLv3 ou ultérieure (voir LICENSE). +// +// Tout vient du moteur : l'analyse du coup joué (rang, meilleur coup, équité +// perdue, appréciation), le classement des coups, l'analyse de la décision +// de videau, et les plateaux décodés, où se lisent les pions frappés et les +// points faits. L'application lance ces analyses sur un second moteur, en +// arrière-plan : elles ne retardent jamais la partie. + +import { + ADVICE_VERSION, + type Advice, + type AdvisedMove, + type MoveFacts, +} from "../game/advice.js"; +import type { BoardSide, CubeAction, Engine, EvaluatedMove, Ply } from "../game/engine.js"; +import { currentLine, type GameEvent, type GameJournal } from "../game/events.js"; +import { engineCube, STANDARD_PLY } from "../game/game.js"; +import { replay } from "../game/state.js"; + +/** Niveau de l'analyse : 2 plis, comme l'analyse de partie de gnubg par + * défaut, et le niveau « standard » (ADR 0009). */ +export const ANALYSIS_PLY: Ply = STANDARD_PLY; + +/** Nombre de coups gardés du classement du moteur, pour « Voir les + * chiffres du moteur ». */ +export const RANKING_SIZE = 5; + +/** + * Les décisions du joueur à analyser parmi des événements : ses coups (pas + * un tour passé faute de coup possible), ses jets (il aurait pu doubler + * avant), ses doubles et ses réponses aux doubles de l'ordinateur. + */ +export function playerDecisions(events: readonly GameEvent[]): GameEvent[] { + return events.filter((event) => { + switch (event.type) { + case "MovePlayed": + return event.side === "player" && event.halfMoves.length > 0; + case "DiceRolled": + case "CubeOffered": + case "CubeTaken": + case "CubeDropped": + return event.side === "player"; + default: + return false; + } + }); +} + +/** + * Le conseil du moteur sur la décision du joueur eventId, au niveau ply ; + * null s'il n'y a rien à analyser (pas une décision du joueur, aucun coup + * possible, videau inaccessible ou position de départ pour gnubg). + */ +export async function analyseDecision( + engine: Engine, + journal: GameJournal, + eventId: string, + ply: Ply = ANALYSIS_PLY, +): Promise { + const line = currentLine({ ...journal, head: eventId }); + const event = line.at(-1); + if (event === undefined || playerDecisions([event]).length === 0) { + return null; + } + const earlier = line.slice(0, -1); + const before = replay(earlier); + if (event.type === "MovePlayed") { + if (before.roll === null) { + return null; + } + const cube = engineCube(before, "player"); + // Le coup joué est enregistré vu de l'ordinateur : le moteur donne la + // même position vue du joueur. + const after = await engine.board(event.position, engineCube(before, "computer")); + const analysis = await engine.analyseMove( + before.position, + cube, + before.roll, + after.swappedPosition, + ply, + ); + if (analysis === null) { + return null; + } + const ranking = await engine.rankMoves(before.position, cube, before.roll, ply); + const start = await engine.board(before.position, cube); + const facts = async (move: EvaluatedMove) => + factsOf(start.onRoll, start.opponent, await engine.board(move.position, cube)); + return { + version: ADVICE_VERSION, + kind: "move", + eventId, + ply, + position: before.position, + roll: before.roll, + opening: !earlier.some((e) => e.type === "MovePlayed"), + moveCount: analysis.moveCount, + rank: analysis.rank, + played: advised(analysis.played), + best: advised(analysis.best), + playedFacts: await facts(analysis.played), + bestFacts: await facts(analysis.best), + ranking: ranking.slice(0, RANKING_SIZE).map(advised), + equityLoss: analysis.equityLoss, + skill: analysis.skill, + }; + } + let action: CubeAction; + let decider: "player" | "computer" = "player"; + if (event.type === "DiceRolled") { + if (before.phase !== "rolling") { + return null; + } + action = "noDouble"; + } else if (event.type === "CubeOffered") { + action = "double"; + } else { + action = event.type === "CubeTaken" ? "take" : "pass"; + decider = "computer"; + } + const analysis = await engine.analyseCube( + before.position, + engineCube(before, decider), + action, + ply, + ); + if (!analysis.analysed || analysis.equities === null) { + return null; + } + return { + version: ADVICE_VERSION, + kind: "cube", + eventId, + ply, + action, + position: before.position, + equities: analysis.equities, + equityLoss: analysis.equityLoss, + skill: analysis.skill, + }; +} + +function advised(move: EvaluatedMove): AdvisedMove { + return { + notation: move.notation, + halfMoves: move.halfMoves.map((h) => ({ from: h.from, to: h.to })), + equity: move.equity, + ply: move.ply, + }; +} + +/** Ce que le coup a changé sur le plateau, lu sur les plateaux du moteur + * (vus du joueur) : pions adverses envoyés sur la barre, cases où le + * joueur a désormais deux pions ou plus. */ +function factsOf( + mine: BoardSide, + theirs: BoardSide, + after: { readonly onRoll: BoardSide; readonly opponent: BoardSide }, +): MoveFacts { + const madePoints: number[] = []; + for (let i = 0; i < 24; i++) { + if ((mine.points[i] ?? 0) < 2 && (after.onRoll.points[i] ?? 0) >= 2) { + madePoints.push(i + 1); + } + } + return { hits: Math.max(0, after.opponent.bar - theirs.bar), madePoints }; +} diff --git a/app/src/domain/analysis/phrases.ts b/app/src/domain/analysis/phrases.ts new file mode 100644 index 0000000..517f325 --- /dev/null +++ b/app/src/domain/analysis/phrases.ts @@ -0,0 +1,263 @@ +// Les mots et les phrases du maître : lecture de leur source +// (docs/maitre/phrases.md, ADR 0008 : une seule source) et remplissage des +// gabarits avec un conseil du moteur. Fonctions pures, testées sous Node +// sur de vraies analyses (app/tests/advice.test.ts). +// +// Copyright (C) 2026 les auteurs d'Ustagammon. +// Licence : GPLv3 ou ultérieure (voir LICENSE). + +import { + type Advice, + type CubeAdvice, + masterCubeAction, + type MoveAdvice, + type MoveFacts, + type Verdict, + verdictOf, +} from "../game/advice.js"; +import type { CubeAction } from "../game/engine.js"; + +/** Les gabarits, par clé de section (« coup.meilleur »…). */ +export type MasterPhrases = ReadonlyMap; + +/** Longueur maximale de la ligne du maître (mot et phrase), en caractères + * (charte, « Les phrases du maître ») : elle doit rester en dessous. */ +export const LINE_LIMIT = 140; + +/** Ce que le maître dit d'une décision. */ +export interface MasterWords { + readonly verdict: Verdict; + /** Le mot d'appréciation, sans point : « Bon coup », « Erreur »… */ + readonly word: string; + /** La phrase du maître. */ + readonly phrase: string; + /** Le titre de la feuille d'explication. */ + readonly title: string; + /** Le contexte de la décision : « votre 3-1 d'ouverture »… */ + readonly context: string; +} + +/** Lit la source des phrases : les sections « ## clé » à partir de la + * première « ## mot.… », un gabarit par ligne « - … ». */ +export function parsePhrases(text: string): MasterPhrases { + const phrases = new Map(); + let key: string | null = null; + let started = false; + for (const raw of text.split("\n")) { + const line = raw.trim(); + const heading = /^## (\S+)$/.exec(line); + if (heading !== null) { + const name = heading[1] as string; + started ||= name.startsWith("mot."); + key = started ? name : null; + if (key !== null) { + phrases.set(key, []); + } + } else if (key !== null && line.startsWith("- ")) { + phrases.get(key)?.push(line.slice(2).trim()); + } + } + return phrases; +} + +/** Les clés que la source doit contenir (vérifié par les tests). */ +export const PHRASE_KEYS: readonly string[] = [ + "mot.coup.bon", + "mot.videau.bon", + "mot.douteux", + "mot.mauvais", + "mot.tres-mauvais", + "coup.meilleur", + "coup.seul", + "coup.proche", + "coup.douteux", + "coup.mauvais", + "coup.tres-mauvais", + "fait.frappe", + "fait.frappe-plusieurs", + "fait.point", + "fait.frappe-et-point", + "fait.maitre-frappe", + "fait.maitre-point", + "fait.joueur-frappe", + ...["bon", "proche", "autre"].flatMap((group) => + ["double", "pas-de-double", "prendre", "passer"].map((a) => `videau.${group}.${a}`), + ), + "titre.coup.meme", + "titre.coup.autre", + "titre.videau.meme", + ...["double", "pas-de-double", "prendre", "passer"].map((a) => `titre.videau.${a}`), + "contexte.coup", + "contexte.coup.ouverture", + "contexte.videau.double", + "contexte.videau.pas-de-double", + "contexte.videau.reponse", +]; + +/** Ce que le maître dit d'un conseil du moteur. */ +export function masterWords(phrases: MasterPhrases, advice: Advice): MasterWords { + return advice.kind === "move" ? moveWords(phrases, advice) : cubeWords(phrases, advice); +} + +/** La ligne du maître telle qu'elle s'affiche : « Erreur. J'aurais… ». */ +export function masterLine(words: MasterWords): string { + return `${words.word}. ${words.phrase}`; +} + +const ACTION_KEYS: Readonly> = { + double: "double", + noDouble: "pas-de-double", + take: "prendre", + pass: "passer", +}; + +function wordKey(verdict: Verdict, kind: Advice["kind"]): string { + switch (verdict) { + case "good": + return kind === "move" ? "mot.coup.bon" : "mot.videau.bon"; + case "doubtful": + return "mot.douteux"; + case "bad": + return "mot.mauvais"; + case "veryBad": + return "mot.tres-mauvais"; + } +} + +function moveWords(phrases: MasterPhrases, advice: MoveAdvice): MasterWords { + const verdict = verdictOf(advice.skill); + const word = pick(phrases, wordKey(verdict, "move"), advice.eventId); + const values = { + best: advice.best.notation, + played: advice.played.notation, + rank: String(advice.rank), + count: String(advice.moveCount), + roll: `${advice.roll[0]}-${advice.roll[1]}`, + }; + const fill = (key: string, extra: Readonly> = {}) => + fillIn(pick(phrases, key, advice.eventId), { ...values, ...extra }); + let base: string; + let fact: string | null; + if (advice.moveCount <= 1) { + base = fill("coup.seul"); + fact = null; + } else if (advice.rank === 1) { + base = fill("coup.meilleur"); + fact = selfFact(phrases, advice.eventId, advice.played.notation, advice.playedFacts); + } else { + const key = { + good: "coup.proche", + doubtful: "coup.douteux", + bad: "coup.mauvais", + veryBad: "coup.tres-mauvais", + }[verdict]; + base = fill(key); + fact = differenceFact(phrases, advice); + } + const withFact = fact === null ? base : `${base} ${fact}`; + const phrase = `${word}. ${withFact}`.length < LINE_LIMIT ? withFact : base; + return { + verdict, + word, + phrase, + title: fill(advice.rank === 1 ? "titre.coup.meme" : "titre.coup.autre"), + context: fill(advice.opening ? "contexte.coup.ouverture" : "contexte.coup"), + }; +} + +/** Ce que fait le coup lui-même : pions frappés, points faits. */ +function selfFact( + phrases: MasterPhrases, + id: string, + move: string, + facts: MoveFacts, +): string | null { + const values = { move, points: pointsText(facts.madePoints), hits: String(facts.hits) }; + const made = facts.madePoints.length > 0; + if (facts.hits === 1 && made) { + return fillIn(pick(phrases, "fait.frappe-et-point", id), values); + } + if (facts.hits > 1) { + return fillIn(pick(phrases, "fait.frappe-plusieurs", id), values); + } + if (facts.hits === 1) { + return fillIn(pick(phrases, "fait.frappe", id), values); + } + return made ? fillIn(pick(phrases, "fait.point", id), values) : null; +} + +/** Un fait qui distingue le coup du maître du coup joué, s'il y en a un. */ +function differenceFact(phrases: MasterPhrases, advice: MoveAdvice): string | null { + const best = advice.bestFacts; + const played = advice.playedFacts; + if (best.hits > 0 && played.hits === 0) { + return pick(phrases, "fait.maitre-frappe", advice.eventId); + } + const onlyBest = best.madePoints.filter((p) => !played.madePoints.includes(p)); + if (onlyBest.length > 0) { + return fillIn(pick(phrases, "fait.maitre-point", advice.eventId), { + points: pointsText(onlyBest), + }); + } + if (played.hits > 0 && best.hits === 0) { + return pick(phrases, "fait.joueur-frappe", advice.eventId); + } + return null; +} + +function cubeWords(phrases: MasterPhrases, advice: CubeAdvice): MasterWords { + const verdict = verdictOf(advice.skill); + const master = masterCubeAction(advice); + const same = master === advice.action; + const group = verdict !== "good" ? "autre" : same ? "bon" : "proche"; + const shown = group === "bon" ? advice.action : master; + const context = + advice.action === "take" || advice.action === "pass" + ? "contexte.videau.reponse" + : `contexte.videau.${ACTION_KEYS[advice.action]}`; + return { + verdict, + word: pick(phrases, wordKey(verdict, "cube"), advice.eventId), + phrase: pick(phrases, `videau.${group}.${ACTION_KEYS[shown]}`, advice.eventId), + title: pick( + phrases, + same ? "titre.videau.meme" : `titre.videau.${ACTION_KEYS[master]}`, + advice.eventId, + ), + context: pick(phrases, context, advice.eventId), + }; +} + +/** « votre point 5 », « vos points 5 et 7 », « vos points 4, 5 et 7 ». */ +function pointsText(points: readonly number[]): string { + const sorted = [...points].sort((a, b) => a - b).map(String); + if (sorted.length <= 1) { + return `votre point ${sorted[0] ?? ""}`; + } + return `vos points ${sorted.slice(0, -1).join(", ")} et ${sorted.at(-1)}`; +} + +/** Un gabarit de la section key ; plusieurs tournent selon l'identifiant + * de l'événement. Erreur si la section manque. */ +function pick(phrases: MasterPhrases, key: string, id: string): string { + const choices = phrases.get(key) ?? []; + if (choices.length === 0) { + throw new Error(`phrases du maître : section « ${key} » absente`); + } + const n = Number.parseInt(id, 10); + // Les identifiants des coups du joueur vont souvent de quatre en quatre : + // un brassage (hachage de Knuth, bits de poids fort) les fait tourner. + const index = (Math.imul(Number.isNaN(n) ? 0 : n, 2654435761) >>> 16) % choices.length; + return choices[index] as string; +} + +/** Remplace les {repères} ; erreur sur un repère inconnu. */ +function fillIn(template: string, values: Readonly>): string { + return template.replace(/\{([a-z]+)\}/g, (_, name: string) => { + const value = values[name]; + if (value === undefined) { + throw new Error(`phrases du maître : repère {${name}} inconnu`); + } + return value; + }); +} diff --git a/app/src/domain/game/advice.ts b/app/src/domain/game/advice.ts new file mode 100644 index 0000000..772c55b --- /dev/null +++ b/app/src/domain/game/advice.ts @@ -0,0 +1,124 @@ +// Le conseil du maître sur une décision du joueur : ce que le moteur en dit, +// rattaché à l'événement de la décision dans le journal (étape 1d). +// +// Copyright (C) 2026 les auteurs d'Ustagammon. +// Licence : GPLv3 ou ultérieure (voir LICENSE). +// +// Ce fichier ne contient que des données : tout ce qu'elles disent vient du +// moteur (analyse du coup, classement des coups, analyse de la décision de +// videau, plateaux décodés). Le calcul est dans domain/analysis/ ; le +// stockage, dans le journal (events.ts, champ advice). Le format est +// durable (ADR 0007) : chaque conseil porte sa version (ADVICE_VERSION). + +import type { CubeAction, CubeEquities, Ply, Skill } from "./engine.js"; +import type { HalfMove, PositionId } from "./position.js"; +import type { Roll } from "./roll.js"; + +/** Version du format d'un conseil enregistré. Un conseil d'une autre + * version est ignoré à la reprise (il peut être recalculé). */ +export const ADVICE_VERSION = 1; + +/** + * Le conseil du maître sur une décision du joueur : un coup ou une décision + * de videau, avec l'appréciation du moteur. + * + * Glossaire : docs/glossaire/conseil-du-maitre.md + */ +export type Advice = MoveAdvice | CubeAdvice; + +/** Un coup, tel que le conseil le retient. */ +export interface AdvisedMove { + /** Notation de gnubg, par exemple « 8/5 6/5 ». */ + readonly notation: string; + readonly halfMoves: readonly HalfMove[]; + /** Équité (avec videau) du point de vue du joueur, et le niveau auquel + * le moteur l'a évaluée. */ + readonly equity: number; + readonly ply: Ply; +} + +/** Ce que l'on lit sur les plateaux décodés par le moteur, avant et après + * un coup : de quoi écrire une phrase sans rien inventer. */ +export interface MoveFacts { + /** Pions adverses envoyés sur la barre. */ + readonly hits: number; + /** Cases (numérotation du joueur) où il n'avait pas deux pions avant + * le coup et en a deux ou plus après : les points qu'il fait. */ + readonly madePoints: readonly number[]; +} + +export interface MoveAdvice { + readonly version: number; + readonly kind: "move"; + /** L'événement MovePlayed du coup. */ + readonly eventId: string; + /** Niveau de l'analyse, en plis. */ + readonly ply: Ply; + /** La position avant le coup, vue du joueur, et son jet. */ + readonly position: PositionId; + readonly roll: Roll; + /** Le premier coup de la partie (après le jet d'ouverture). */ + readonly opening: boolean; + /** Nombre de coups légaux et rang du coup joué (à partir de 1). */ + readonly moveCount: number; + readonly rank: number; + readonly played: AdvisedMove; + readonly best: AdvisedMove; + readonly playedFacts: MoveFacts; + readonly bestFacts: MoveFacts; + /** Les meilleurs coups du classement du moteur, du meilleur au moins + * bon (cinq au plus). */ + readonly ranking: readonly AdvisedMove[]; + /** Équité du coup joué moins celle du meilleur : 0 ou négative. */ + readonly equityLoss: number; + readonly skill: Skill; +} + +export interface CubeAdvice { + readonly version: number; + readonly kind: "cube"; + /** L'événement de la décision : DiceRolled (le joueur lance sans + * doubler), CubeOffered (il double), CubeTaken ou CubeDropped (il + * répond au double de l'ordinateur). */ + readonly eventId: string; + readonly ply: Ply; + /** La décision du joueur. */ + readonly action: CubeAction; + /** La position, vue de celui qui double ou non (l'ordinateur quand le + * joueur prend ou passe). */ + readonly position: PositionId; + /** Équités de celui qui double ou non, pour un videau de 1. */ + readonly equities: CubeEquities; + /** Équité perdue par le joueur : 0 ou négative. */ + readonly equityLoss: number; + readonly skill: Skill; +} + +/** L'appréciation dite au joueur : celle du moteur, « rien à signaler » + * devenant « bon ». */ +export type Verdict = "good" | "doubtful" | "bad" | "veryBad"; + +export function verdictOf(skill: Skill): Verdict { + return skill === "none" ? "good" : skill; +} + +/** L'autre choix d'une décision de videau. */ +export function otherCubeAction(action: CubeAction): CubeAction { + switch (action) { + case "double": + return "noDouble"; + case "noDouble": + return "double"; + case "take": + return "pass"; + case "pass": + return "take"; + } +} + +/** La décision que le maître aurait prise : celle du joueur s'il n'a perdu + * aucune équité, l'autre sinon (une décision de videau n'a que deux + * choix). */ +export function masterCubeAction(advice: CubeAdvice): CubeAction { + return advice.equityLoss < 0 ? otherCubeAction(advice.action) : advice.action; +} diff --git a/app/src/domain/game/engine.ts b/app/src/domain/game/engine.ts index 1a19a7e..9df4fe0 100644 --- a/app/src/domain/game/engine.ts +++ b/app/src/domain/game/engine.ts @@ -80,6 +80,12 @@ export interface Engine { ply: Ply, ): Promise; + /** Les coups légaux d'un jet classés du meilleur au moins bon, au + * niveau ply, comme la commande « hint » de gnubg (les coups écartés + * par ses filtres gardent l'évaluation d'un niveau inférieur, voir leur + * ply). */ + rankMoves(position: PositionId, cube: EngineCube, roll: Roll, ply: Ply): Promise; + /** L'analyse d'une décision de videau, position vue du joueur au trait * (celui qui double ou non ; take et pass sont la réponse de son * adversaire). */ diff --git a/app/src/domain/game/events.ts b/app/src/domain/game/events.ts index f4f44f4..9f66ac4 100644 --- a/app/src/domain/game/events.ts +++ b/app/src/domain/game/events.ts @@ -12,6 +12,7 @@ // Chaque événement contient tout ce qu'il faut pour rejouer la partie sans // le moteur : l'état se recalcule par une simple boucle (state.ts). +import { ADVICE_VERSION, type Advice } from "./advice.js"; import type { Cube } from "./cube.js"; import type { Level, ResultKind } from "./engine.js"; import type { Match, MatchAfterGame } from "./match.js"; @@ -38,7 +39,8 @@ const VERSION_1_LEVEL: Level = "2ply"; * une partie de la version 1 étant une partie libre. Un événement d'une * version inconnue est laissé tel quel (Game.resume le refuse). */ -export function migrateJournal(journal: GameJournal): GameJournal { +export function migrateJournal(saved: GameJournal): GameJournal { + const journal = keepKnownAdvice(saved); if (journal.events.every((event) => event.version === EVENT_VERSION)) { return journal; } @@ -55,7 +57,34 @@ export function migrateJournal(journal: GameJournal): GameJournal { } return Object.freeze({ ...event, version: EVENT_VERSION }); }); - return { events, head: journal.head }; + return { ...journal, events }; +} + +/** + * Les conseils du maître d'un journal enregistré : seuls restent ceux de la + * version courante (ADVICE_VERSION), rattachés à un événement du journal. + * Les autres sont oubliés : ce ne sont que des analyses du moteur, qui + * peuvent être refaites. Un journal sans conseils (enregistré avant l'étape + * 1d) reste tel quel. + */ +function keepKnownAdvice(journal: GameJournal): GameJournal { + const advice = journal.advice; + if (advice === undefined) { + return journal; + } + const ids = new Set(journal.events.map((event) => event.id)); + const kept = Object.entries(advice).filter( + ([id, a]) => + typeof a === "object" && + a !== null && + a.version === ADVICE_VERSION && + a.eventId === id && + ids.has(id), + ); + if (kept.length === Object.keys(advice).length) { + return journal; + } + return { ...journal, advice: Object.fromEntries(kept) }; } interface EventBase { @@ -150,10 +179,14 @@ export type NewGameEvent = WithoutBase; type WithoutBase = E extends unknown ? Omit : never; /** Tous les événements d'une partie, branches comprises, et l'événement - * courant (head) : la partie en est là. */ + * courant (head) : la partie en est là. advice (étape 1d, facultatif) : le + * conseil du maître sur les décisions du joueur, par identifiant de + * l'événement de la décision. Ce n'est pas un événement : il arrive après + * coup, quand l'analyse du moteur est prête, et ne change pas la partie. */ export interface GameJournal { readonly events: readonly GameEvent[]; readonly head: string | null; + readonly advice?: Readonly>; } export const EMPTY_JOURNAL: GameJournal = { events: [], head: null }; @@ -166,7 +199,16 @@ export function appendEvent(journal: GameJournal, event: NewGameEvent): GameJour parent: journal.head, version: EVENT_VERSION, }) as GameEvent; - return { events: [...journal.events, added], head: added.id }; + return { ...journal, events: [...journal.events, added], head: added.id }; +} + +/** Rattache un conseil du maître à son événement (il remplace un conseil + * déjà rattaché). Erreur si l'événement n'est pas dans le journal. */ +export function withAdvice(journal: GameJournal, advice: Advice): GameJournal { + if (!journal.events.some((event) => event.id === advice.eventId)) { + throw new Error(`événement introuvable : ${advice.eventId}`); + } + return { ...journal, advice: { ...journal.advice, [advice.eventId]: advice } }; } /** Les événements du début jusqu'à l'événement courant, dans l'ordre. */ @@ -190,5 +232,5 @@ export function goBackTo(journal: GameJournal, id: string): GameJournal { if (!journal.events.some((event) => event.id === id)) { throw new Error(`événement introuvable : ${id}`); } - return { events: journal.events, head: id }; + return { ...journal, head: id }; } diff --git a/app/src/domain/game/game.ts b/app/src/domain/game/game.ts index 165749e..b5102ce 100644 --- a/app/src/domain/game/game.ts +++ b/app/src/domain/game/game.ts @@ -9,6 +9,7 @@ // l'ordinateur, videau (Crawford compris), fin de partie, points et score du // match. +import type { Advice } from "./advice.js"; import { type Branch, branches, switchToBranch } from "./branches.js"; import { CENTERED_CUBE, type Cube } from "./cube.js"; import type { BoardSide, Engine, EngineCube, Level, Ply, ResultKind } from "./engine.js"; @@ -22,6 +23,7 @@ import { goBackTo, migrateJournal, type NewGameEvent, + withAdvice, } from "./events.js"; import { type Match, nextGameOf } from "./match.js"; import { BAR, type HalfMove, OFF, type PositionId, START_POSITION } from "./position.js"; @@ -216,36 +218,61 @@ export class Game { * (checkHalfMoves, puis board) : aucune règle n'est rejouée ici. */ async lastMoveSteps(): Promise { - const events = this.events; - const index = events.findLastIndex((e) => e.type === "MovePlayed"); - const played = events[index]; - if (played?.type !== "MovePlayed") { - return null; - } - // L'état juste avant le coup : la position vue de celui qui joue, et - // son jet. - const before = replay(events.slice(0, index)); - const roll = rollToPlay(before); + const played = this.events.findLast((e) => e.type === "MovePlayed"); + return played?.type === "MovePlayed" + ? this.stepsBefore(played.id, played.side, played.halfMoves) + : null; + } + + /** + * Un coup de side joué demi-coup par demi-coup à partir de la position + * d'avant l'événement eventId (le coup réellement joué, ou un autre coup + * du même jet : celui du maître). Les plateaux viennent du moteur ; + * erreur s'il refuse un demi-coup. Sans demi-coup, boards ne contient + * que la position d'avant l'événement. + */ + async stepsBefore( + eventId: string, + side: Side, + halfMoves: readonly HalfMove[], + ): Promise { + const line = currentLine({ ...this.#journal, head: eventId }); + // L'état juste avant l'événement : la position vue de celui qui a la + // main, et son jet. + const before = replay(line.slice(0, -1)); const positions = [before.position]; - for (let i = 1; i <= played.halfMoves.length; i++) { + for (let i = 1; i <= halfMoves.length; i++) { const check = await this.#engine.checkHalfMoves( before.position, - roll, - played.halfMoves.slice(0, i), + rollToPlay(before), + halfMoves.slice(0, i), ); if (check.position === null) { - throw new Error("coup enregistré refusé par le moteur"); + throw new Error("coup refusé par le moteur"); } positions.push(check.position); } + const onRoll = before.turn ?? side; const boards = await Promise.all( - positions.map((p) => this.#sideBoards(p, played.side, before)), + positions.map((p) => this.#sideBoards(p, onRoll, before)), ); - const opponent = otherSide(played.side); - const hits = played.halfMoves.map( + const opponent = otherSide(side); + const hits = halfMoves.map( (_, i) => (boards[i + 1]?.[opponent].bar ?? 0) > (boards[i]?.[opponent].bar ?? 0), ); - return { side: played.side, halfMoves: played.halfMoves, boards, hits }; + return { side, halfMoves, boards, hits }; + } + + /** Les conseils du maître déjà rattachés aux décisions du joueur, par + * identifiant d'événement. */ + get advice(): Readonly> { + return this.#journal.advice ?? {}; + } + + /** Rattache le conseil du maître à sa décision dans le journal (il sera + * enregistré avec la partie). Erreur si l'événement n'existe pas. */ + recordAdvice(advice: Advice): void { + this.#journal = withAdvice(this.#journal, advice); } /** diff --git a/app/src/engine/wasm-engine.ts b/app/src/engine/wasm-engine.ts index c7e26fe..b6fbdc2 100644 --- a/app/src/engine/wasm-engine.ts +++ b/app/src/engine/wasm-engine.ts @@ -195,6 +195,14 @@ export function createWasmEngine(run: RunCommand): Engine { }; }, + async rankMoves(position: PositionId, cube: EngineCube, roll: Roll, ply: Ply) { + const args = ["--ply", String(ply), "moves", position, await matchId(cube)]; + const answer = (await run([...args, diceText(roll)])) as { + moves: CliEvaluatedMove[]; + }; + return answer.moves.map(toEvaluatedMove); + }, + async analyseCube( position: PositionId, cube: EngineCube, diff --git a/app/tests/advice.test.ts b/app/tests/advice.test.ts new file mode 100644 index 0000000..e065f37 --- /dev/null +++ b/app/tests/advice.test.ts @@ -0,0 +1,224 @@ +// Le conseil du maître (étape 1d) : analyses du moteur sur des décisions +// connues, phrases rendues sur ces analyses, conseil rattaché au journal et +// gardé à l'enregistrement. +// +// 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 { + LINE_LIMIT, + masterLine, + masterWords, + PHRASE_KEYS, +} from "../src/domain/analysis/phrases.js"; +import { ADVICE_VERSION, type Advice, type MoveAdvice } from "../src/domain/game/advice.js"; +import { Game } from "../src/domain/game/game.js"; +import { createGameLibrary } from "../src/storage/game-library.js"; +import { createKeyValueBackend } from "../src/storage/key-value-backend.js"; +import { memoryStorage } from "../src/storage/open-library.js"; +import { + adviceOn, + loadPhrases, + playerAnswers, + playerDoubles, + playerRollsWithoutDoubling, + playOpening, +} from "./support/advice.js"; +import { loadNodeEngine } from "./support/node-engine.js"; + +const phrases = loadPhrases(); + +test("la source des phrases a toutes ses sections, sans point d'exclamation", () => { + for (const key of PHRASE_KEYS) { + assert.ok((phrases.get(key)?.length ?? 0) > 0, `section ${key} absente ou vide`); + } + for (const [key, templates] of phrases) { + assert.ok(PHRASE_KEYS.includes(key), `section ${key} inconnue`); + for (const template of templates) { + assert.ok(!template.includes("!"), `« ! » dans ${key} : ${template}`); + } + } +}); + +// Des décisions réelles, analysées par le moteur à 2 plis. Les coups +// d'ouverture et leurs écarts viennent du classement de gnubg. +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")), + 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")), + 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")), + 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")), + 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")), + word: "Grosse erreur", + }, + { + name: "double dans une position de double et prise", + 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())), + word: "Erreur", + }, + { + name: "prendre un double juste", + 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)), + word: "Erreur", + }, +]; + +const analysed: Advice[] = []; + +for (const c of cases) { + test(`conseil : ${c.name}`, async () => { + const advice = await c.play(); + analysed.push(advice); + const words = masterWords(phrases, advice); + assert.equal(words.word, c.word, JSON.stringify(advice)); + // Chaque gabarit tourne avec l'identifiant de l'événement : la ligne + // est rendue pour plusieurs identifiants. + for (let id = 1; id <= 40; id++) { + const line = masterLine(masterWords(phrases, { ...advice, eventId: String(id) })); + assert.ok(line.length < LINE_LIMIT, `trop long (${line.length}) : ${line}`); + assert.ok(!line.includes("!"), line); + assert.ok(!/[{}]/.test(line), `repère non rempli : ${line}`); + } + }); +} + +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; + assert.equal(best.kind, "move"); + assert.equal(best.version, ADVICE_VERSION); + assert.equal(best.rank, 1); + assert.equal(best.opening, true); + assert.deepEqual(best.playedFacts, { hits: 0, madePoints: [5] }); + assert.equal(best.ranking[0]?.notation, "8/5 6/5"); + const words = masterWords(phrases, best); + assert.equal(words.title, "C'est aussi le coup que j'aurais joué"); + assert.equal(words.context, "votre 3-1 d'ouverture"); + + const worst = (await adviceOn( + engine, + await playOpening(engine, [3, 1], "24/23 13/10"), + )) as MoveAdvice; + assert.equal(worst.skill, "veryBad"); + assert.equal(worst.best.notation, "8/5 6/5"); + assert.deepEqual(worst.bestFacts.madePoints, [5]); + const said = masterWords(phrases, worst); + assert.equal(said.title, "J'aurais joué 8/5 6/5"); + assert.equal( + said.phrase, + `J'aurais joué 8/5 6/5. Votre 24/23 13/10 était le ${worst.rank}e choix sur 16. Mon coup fait votre point 5.`, + ); +}); + +test("décisions de videau : la décision du maître et le contexte", async () => { + const engine = await loadNodeEngine(); + const missed = await adviceOn(engine, await playerRollsWithoutDoubling(engine)); + const words = masterWords(phrases, missed); + assert.equal(words.phrase, "Ici, j'aurais doublé."); + assert.equal(words.title, "J'aurais doublé"); + assert.equal(words.context, "avant votre jet"); + const passed = masterWords(phrases, await adviceOn(engine, await playerAnswers(engine, false))); + assert.equal(passed.phrase, "Ici, j'aurais pris."); + assert.equal(passed.context, "le double de l'ordinateur"); +}); + +test("tous les gabarits, remplis avec les plus longues notations, restent sous la limite", () => { + const long = "bar/22* 13/9*(2) 6/off"; + const move = (equity: number) => ({ notation: long, halfMoves: [], equity, ply: 2 }); + for (const skill of ["none", "doubtful", "bad", "veryBad"] as const) { + for (const rank of [1, 2, 23]) { + for (const facts of [ + { hits: 0, madePoints: [] }, + { hits: 1, madePoints: [4, 5, 7] }, + { hits: 3, madePoints: [] }, + ]) { + const advice: MoveAdvice = { + version: ADVICE_VERSION, + kind: "move", + eventId: "7", + ply: 2, + position: "4HPwATDgc/ABMA", + roll: [6, 6], + opening: false, + moveCount: 87, + rank, + played: move(-0.2), + best: move(0), + playedFacts: rank === 1 ? facts : { hits: 0, madePoints: [] }, + bestFacts: facts, + ranking: [], + equityLoss: rank === 1 ? 0 : -0.2, + skill: rank === 1 ? "none" : skill, + }; + for (let id = 0; id < 20; id++) { + const line = masterLine(masterWords(phrases, { ...advice, eventId: String(id) })); + assert.ok(line.length < LINE_LIMIT, `trop long (${line.length}) : ${line}`); + } + } + } + } +}); + +test("le conseil est rattaché au journal et gardé à l'enregistrement", async () => { + const engine = await loadNodeEngine(); + const played = await playOpening(engine, [3, 1], "8/5 6/5"); + const advice = await adviceOn(engine, played); + played.game.recordAdvice(advice); + // Le tour de l'ordinateur, puis l'enregistrement : le conseil reste. + await played.game.playComputerTurn(); + assert.deepEqual(played.game.advice[played.eventId], advice); + const library = createGameLibrary(createKeyValueBackend(memoryStorage())); + await library.save("g", played.game.journal, { level: "2ply" }); + const loaded = await library.load("g"); + assert.ok(loaded !== null); + const resumed = Game.resume(engine, loaded.journal); + assert.deepEqual(resumed.advice[played.eventId], advice); + // Revenir en arrière garde le conseil, rattaché à son événement. + resumed.goBackTo(played.eventId); + assert.deepEqual(resumed.advice[played.eventId], advice); +}); + +test("migration : un journal sans conseil, ou avec un conseil d'une autre version", async () => { + const engine = await loadNodeEngine(); + const played = await playOpening(engine, [3, 1], "8/5 6/5"); + const { advice: _, ...withoutAdvice } = played.game.journal; + assert.deepEqual(Game.resume(engine, withoutAdvice).advice, {}); + const advice = await adviceOn(engine, played); + const future = { ...advice, version: ADVICE_VERSION + 1 }; + const orphan = { ...advice, eventId: "999" }; + const resumed = Game.resume(engine, { + ...played.game.journal, + advice: { [played.eventId]: future, "999": orphan }, + }); + assert.deepEqual(resumed.advice, {}); + assert.throws(() => resumed.recordAdvice(orphan), /introuvable/); +}); diff --git a/app/tests/game.test.ts b/app/tests/game.test.ts index a81f676..5a3dab9 100644 --- a/app/tests/game.test.ts +++ b/app/tests/game.test.ts @@ -73,6 +73,7 @@ function fakeEngine(overrides: Partial = {}): Engine { }; }, analyseMove: async () => null, + rankMoves: async () => [], analyseCube: async () => ({ analysed: false, equities: null, diff --git a/app/tests/support/advice.ts b/app/tests/support/advice.ts new file mode 100644 index 0000000..168a340 --- /dev/null +++ b/app/tests/support/advice.ts @@ -0,0 +1,108 @@ +// Des décisions du joueur dans des positions connues, jouées par le domaine +// sur le vrai moteur, puis analysées : de quoi tester le conseil du maître +// (tests unitaires et scénarios). +// +// Copyright (C) 2026 les auteurs d'Ustagammon. +// Licence : GPLv3 ou ultérieure (voir LICENSE). + +import { readFileSync } from "node:fs"; +import { analyseDecision } from "../../src/domain/analysis/analyse.js"; +import { type MasterPhrases, parsePhrases } 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"; + +const root = new URL("../../../../", import.meta.url); + +/** Les phrases du maître, lues dans leur source. */ +export function loadPhrases(): MasterPhrases { + return parsePhrases(readFileSync(new URL("docs/maitre/phrases.md", root), "utf8")); +} + +/** Une position de double et prise (videau au centre), vue de celui qui a + * la main : doubler y est juste, et prendre aussi (tests du moteur, 1a). */ +export const DOUBLE_TAKE_POSITION = "6O5uAAC4uzsAAA"; + +/** Le joueur, au trait à la position de départ, lance dice et joue move ; + * la partie et l'événement du coup. */ +export async function playOpening( + engine: Engine, + dice: readonly [number, number], + move: string, +): Promise<{ game: Game; eventId: string }> { + const game = Game.start(engine, { + setup: { position: START_POSITION, firstTurn: "player" }, + // Le jet du joueur, puis celui de l'ordinateur s'il joue ensuite. + dice: fixedDice([...dice, 6, 5]), + }); + await game.roll(); + for (const halfMove of parseHalfMoves(move)) { + if (!(await game.playHalfMove(halfMove))) { + throw new Error(`demi-coup refusé : ${move}`); + } + } + await game.confirmMove(); + const played = game.events.findLast((e) => e.type === "MovePlayed"); + return { game, eventId: played?.id ?? "" }; +} + +/** Le joueur, au trait dans la position de double et prise, double ; + * l'ordinateur répond. */ +export async function playerDoubles(engine: Engine): Promise<{ game: Game; eventId: string }> { + const game = Game.start(engine, { + setup: { position: DOUBLE_TAKE_POSITION, firstTurn: "player" }, + dice: fixedDice([]), + }); + await game.double(); + const offered = game.events.find((e) => e.type === "CubeOffered"); + return { game, eventId: offered?.id ?? "" }; +} + +/** Le joueur, au trait dans la position de double et prise, lance sans + * doubler. */ +export async function playerRollsWithoutDoubling( + engine: Engine, +): Promise<{ game: Game; eventId: string }> { + const game = Game.start(engine, { + setup: { position: DOUBLE_TAKE_POSITION, firstTurn: "player" }, + dice: fixedDice([6, 5]), + }); + await game.roll(); + const rolled = game.events.findLast((e) => e.type === "DiceRolled"); + return { game, eventId: rolled?.id ?? "" }; +} + +/** L'ordinateur, au trait dans la position de double et prise, double ; le + * joueur prend (take) ou passe. */ +export async function playerAnswers( + engine: Engine, + take: boolean, +): Promise<{ game: Game; eventId: string }> { + const game = Game.start(engine, { + setup: { position: DOUBLE_TAKE_POSITION, firstTurn: "computer" }, + level: "2ply", + dice: fixedDice([]), + }); + await game.playComputerTurn(); + if (take) { + game.take(); + } else { + await game.drop(); + } + const answer = game.events.find((e) => e.type === (take ? "CubeTaken" : "CubeDropped")); + return { game, eventId: answer?.id ?? "" }; +} + +/** Le conseil du moteur sur une décision ; erreur s'il n'y en a pas. */ +export async function adviceOn( + engine: Engine, + played: { game: Game; eventId: string }, +): Promise { + const advice = await analyseDecision(engine, played.game.journal, played.eventId); + if (advice === null) { + throw new Error(`aucun conseil pour l'événement ${played.eventId}`); + } + return advice; +} diff --git a/docs/glossaire/README.md b/docs/glossaire/README.md index 7d46bc5..a4e3e45 100644 --- a/docs/glossaire/README.md +++ b/docs/glossaire/README.md @@ -43,6 +43,7 @@ exportée. `make test` vérifie le lien dans les deux sens : chaque fiche avec | [Camp](camp.md) | Home board | | [Chance](chance.md) | Luck | | [Compte de points](compte-de-points.md) | Pip count | +| [Conseil du maître](conseil-du-maitre.md) | Advice | | [Coup](coup.md) | Move | | [Demi-coup](demi-coup.md) | Half-move | | [Course](course.md) | Race | diff --git a/docs/glossaire/conseil-du-maitre.md b/docs/glossaire/conseil-du-maitre.md new file mode 100644 index 0000000..5db8d32 --- /dev/null +++ b/docs/glossaire/conseil-du-maitre.md @@ -0,0 +1,18 @@ +--- +term: Conseil du maître +english: Advice +code: Advice +--- +# Conseil du maître + +**En bref** : après chacun de vos coups et chacune de vos décisions de videau, une ligne qui donne l'appréciation du moteur et ce que le maître aurait joué. + +Le maître ne parle jamais avant votre coup : il commente ce que vous venez de +jouer, avec un mot (bon coup, imprécision, erreur, grosse erreur) choisi +d'après l'[équité](equite.md) perdue par rapport au meilleur choix (voir +[Appréciation](appreciation.md)). Toucher la ligne ouvre l'explication : votre +coup et celui du maître, joués sur le plateau, et les chiffres du +[moteur](moteur.md) si vous les demandez. Le conseil se coupe dans le menu ou +sur l'écran « Nouvelle partie ». + +Voir aussi : [Appréciation](appreciation.md), [Coup](coup.md), [Videau](videau.md) diff --git a/docs/maitre/phrases.md b/docs/maitre/phrases.md new file mode 100644 index 0000000..2c84abe --- /dev/null +++ b/docs/maitre/phrases.md @@ -0,0 +1,216 @@ +# Les phrases du maître + +Les mots et les phrases du conseil du maître (étape 1d), en une seule source +(ADR 0008) : `make site` copie ce dossier dans le site publié +(`docs/maitre/`), l'application le lit à l'exécution +(`app/src/domain/analysis/phrases.ts`), et `make test` rend chaque gabarit +sur de vraies analyses du moteur (`app/tests/advice.test.ts`). + +Texte sous licence **CC BY-SA 4.0**. Version 1, rédigée par Claude Code +d'après la charte (`docs/identite/charte/ecrans-etape-1.md`, « Les phrases du +maître »), **à relire** par la session « identité » puis par Pierre. + +## Règles + +- Le maître parle à la première personne et vouvoie : « J'aurais joué… », + jamais « Vous auriez dû… ». +- Il ne cite que des faits donnés par le moteur : le coup recommandé, le rang + du coup joué, le nombre de coups possibles, les pions frappés et les points + faits (lus sur les plateaux décodés par le moteur), la décision de videau + qu'il préfère. Aucune explication stratégique. +- La ligne (le mot et la phrase) fait moins de 140 caractères ; pas de point + d'exclamation, pas d'émoji, pas de superlatif. +- Quand une section a plusieurs gabarits, ils tournent d'un coup à l'autre + (l'identifiant de l'événement choisit), pour ne pas lasser. + +## Format + +Une section `## clé` par emploi, suivie d'un gabarit par ligne (`- …`). Les +mots entre accolades sont remplis avec les données du moteur : + +| Repère | Ce qu'il devient | +| --- | --- | +| `{best}` | le coup du maître, en notation de gnubg (« 8/5 6/5 ») | +| `{played}` | votre coup, même notation | +| `{move}` | le coup dont parle le fait (le vôtre ou celui du maître) | +| `{rank}` | le rang de votre coup dans le classement du moteur (2, 3…) | +| `{count}` | le nombre de coups possibles avec ce jet | +| `{points}` | « votre point 5 » ou « vos points 5 et 7 » | +| `{roll}` | le jet, « 3-1 » | +| `{hits}` | le nombre de pions frappés, en chiffres | + +Un fait (sections `fait.…`) s'ajoute à la phrase seulement si la ligne reste +sous 140 caractères. + +Tout ce qui précède la première section `## mot.…` n'est pas lu. + +## mot.coup.bon + +- Bon coup + +## mot.videau.bon + +- Bonne décision + +## mot.douteux + +- Imprécision + +## mot.mauvais + +- Erreur + +## mot.tres-mauvais + +- Grosse erreur + +## coup.meilleur + +- C'est le coup du maître. +- Exactement ce que j'aurais joué. +- Bien vu. +- C'est aussi mon choix. + +## coup.seul + +- C'était le seul coup possible. + +## coup.proche + +- Tout près : j'aurais joué {best}, votre coup était le {rank}e choix. + +## coup.douteux + +- J'aurais joué {best}. Votre coup était le {rank}e choix, tout près. + +## coup.mauvais + +- J'aurais joué {best}. Votre {played} était le {rank}e choix. + +## coup.tres-mauvais + +- J'aurais joué {best}. Votre {played} était le {rank}e choix sur {count}. + +## fait.frappe + +- {move} frappe un pion. + +## fait.frappe-plusieurs + +- {move} frappe {hits} pions. + +## fait.point + +- {move} fait {points}. + +## fait.frappe-et-point + +- {move} frappe un pion et fait {points}. + +## fait.maitre-frappe + +- Mon coup frappe un pion, le vôtre non. + +## fait.maitre-point + +- Mon coup fait {points}. + +## fait.joueur-frappe + +- Votre coup frappe un pion, le mien non. + +## videau.bon.double + +- Doubler était le bon choix. + +## videau.bon.pas-de-double + +- Ne pas doubler était le bon choix. + +## videau.bon.prendre + +- Prendre était le bon choix. + +## videau.bon.passer + +- Passer était le bon choix. + +## videau.proche.double + +- Les deux choix étaient très proches. J'aurais doublé. + +## videau.proche.pas-de-double + +- Les deux choix étaient très proches. Je n'aurais pas doublé. + +## videau.proche.prendre + +- Les deux choix étaient très proches. J'aurais pris. + +## videau.proche.passer + +- Les deux choix étaient très proches. J'aurais passé. + +## videau.autre.double + +- Ici, j'aurais doublé. + +## videau.autre.pas-de-double + +- Ici, je n'aurais pas doublé. + +## videau.autre.prendre + +- Ici, j'aurais pris. + +## videau.autre.passer + +- Ici, j'aurais passé. + +## titre.coup.meme + +- C'est aussi le coup que j'aurais joué + +## titre.coup.autre + +- J'aurais joué {best} + +## titre.videau.meme + +- C'est aussi ce que j'aurais fait + +## titre.videau.double + +- J'aurais doublé + +## titre.videau.pas-de-double + +- Je n'aurais pas doublé + +## titre.videau.prendre + +- J'aurais pris + +## titre.videau.passer + +- J'aurais passé + +## contexte.coup + +- votre {roll} + +## contexte.coup.ouverture + +- votre {roll} d'ouverture + +## contexte.videau.double + +- votre double + +## contexte.videau.pas-de-double + +- avant votre jet + +## contexte.videau.reponse + +- le double de l'ordinateur -- 2.54.0 From 5897925b33a6c8733a5074161759f2ac4b667f0b Mon Sep 17 00:00:00 2001 From: Pierre MARTIN Date: Tue, 10 Nov 2026 04:47:14 +0100 Subject: [PATCH 02/10] =?UTF-8?q?Conseil=20du=20ma=C3=AEtre=20:=20ligne=20?= =?UTF-8?q?du=20ma=C3=AEtre,=20feuille=20d'explication,=20second=20moteur?= =?UTF-8?q?=20pour=20l'analyse?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- Makefile | 7 +- app/src/domain/analysis/analyse.ts | 7 +- app/src/domain/analysis/phrases.ts | 2 +- app/src/domain/game/engine.ts | 7 +- app/src/main.ts | 28 +- app/src/ui/advice-texts.ts | 55 +++ app/src/ui/advice-tracker.ts | 152 +++++++ app/src/ui/advice-view.ts | 399 ++++++++++++++++++ app/src/ui/app.ts | 162 ++++++- app/src/ui/board-view.ts | 88 +++- app/src/ui/help.ts | 1 + app/src/ui/router.ts | 7 + app/tests/advice-tracker.test.ts | 141 +++++++ app/tests/advice.test.ts | 54 ++- app/tests/router.test.ts | 1 + app/tests/steps/comprendre-mes-coups.steps.ts | 145 +++++++ docs/scenarios/README.md | 1 + docs/scenarios/comprendre-mes-coups.feature | 48 +++ web/app.css | 218 ++++++++++ web/index.html | 9 + 20 files changed, 1503 insertions(+), 29 deletions(-) create mode 100644 app/src/ui/advice-texts.ts create mode 100644 app/src/ui/advice-tracker.ts create mode 100644 app/src/ui/advice-view.ts create mode 100644 app/tests/advice-tracker.test.ts create mode 100644 app/tests/steps/comprendre-mes-coups.steps.ts create mode 100644 docs/scenarios/comprendre-mes-coups.feature 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 @@ + + + +