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 new file mode 100644 index 0000000..22c38a3 --- /dev/null +++ b/app/src/domain/analysis/analyse.ts @@ -0,0 +1,166 @@ +// 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..49b77d7 --- /dev/null +++ b/app/src/domain/analysis/phrases.ts @@ -0,0 +1,267 @@ +// 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, + type MoveAdvice, + type MoveFacts, + masterCubeAction, + 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", + "coup.loin", + "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), + 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 { + // Le rang n'est cité que pour un bon coup non meilleur ou une + // imprécision, au 2e ou 3e choix : il mesure mal la gravité d'une + // erreur, et le classement est dans les chiffres du moteur. + const near = advice.rank <= 3; + const key = { + good: near ? "coup.proche" : "coup.loin", + doubtful: near ? "coup.douteux" : "coup.loin", + 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..935f6da 100644 --- a/app/src/domain/game/engine.ts +++ b/app/src/domain/game/engine.ts @@ -80,6 +80,17 @@ 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 b5183b9..c19b1de 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 { firstGameOf, type Match, nextGameOf } from "./match.js"; import { BAR, type HalfMove, OFF, type PositionId, START_POSITION } from "./position.js"; @@ -227,36 +229,59 @@ 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 boards = await Promise.all( - positions.map((p) => this.#sideBoards(p, played.side, before)), - ); - const opponent = otherSide(played.side); - const hits = played.halfMoves.map( + const onRoll = before.turn ?? side; + const boards = await Promise.all(positions.map((p) => this.#sideBoards(p, onRoll, before))); + 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/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..3f43fbf --- /dev/null +++ b/app/src/ui/advice-view.ts @@ -0,0 +1,409 @@ +// 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; + // Le titre dit déjà le coup du maître : la phrase ne le répète pas + // (et disparaît si elle ne disait que lui). + const rest = words.phrase.startsWith(`${words.title}.`) + ? words.phrase.slice(words.title.length + 1).trim() + : words.phrase; + if (rest !== "") { + element(sheet, "p", { id: "explain-phrase", className: "explain-phrase", text: rest }); + } + + 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") { + // Les écarts du classement se lisent par rapport à son premier + // coup ; votre coup, s'il n'y figure pas, avec l'écart de + // l'analyse du coup joué. + const first = advice.ranking[0]?.equity ?? advice.best.equity; + const listed = advice.ranking.map((move, i) => ({ + move, + rank: i + 1, + loss: move.equity - first, + })); + if (!advice.ranking.some((m) => m.notation === advice.played.notation)) { + listed.push({ move: advice.played, rank: advice.rank, loss: advice.equityLoss }); + } + for (const { move, rank, loss } of listed) { + const mine = move.notation === advice.played.notation; + row( + [ + String(rank), + mine ? `${move.notation} ${ADVICE_TEXTS.you}` : move.notation, + formatEquity(loss), + ], + 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 a7cc4d9..4c385ae 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"), @@ -341,6 +361,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")); @@ -434,6 +456,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)"); @@ -543,6 +595,7 @@ export function startApp(options: AppOptions): void { : bandText(text.short, state.match?.crawford === true) : null, controls: landscape ? controls() : null, + advice: landscape && !panelShown ? boardAdvice(shownAdvice()) : null, }); } renderStrips(model.pipCount, state.phase === "over" ? null : state.turn); @@ -550,6 +603,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) { @@ -612,6 +667,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 { @@ -758,6 +876,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)); } @@ -973,11 +1092,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 @@ -1082,7 +1209,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. @@ -1106,6 +1236,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 { @@ -1275,6 +1414,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(); } @@ -1345,6 +1485,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é @@ -1441,6 +1582,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. @@ -1953,6 +2103,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](); @@ -1983,6 +2137,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 61ff735..35bf99a 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; // Une consigne longue (« Partie Crawford · … ») tient dans la pastille : // la police se resserre, sans descendre sous 13 px (charte). Largeur // moyenne d'un caractère d'Alegreya Sans en gras : un peu moins de @@ -529,6 +550,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 new file mode 100644 index 0000000..75b06e1 --- /dev/null +++ b/app/tests/advice.test.ts @@ -0,0 +1,273 @@ +// 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. Mon coup fait votre point 5."); +}); + +test("le rang n'est cité que pour un bon coup non meilleur ou une imprécision, au 2e ou 3e choix", async () => { + const engine = await loadNodeEngine(); + const worst = (await adviceOn( + engine, + await playOpening(engine, [3, 1], "24/23 13/10"), + )) as MoveAdvice; + for (const skill of ["none", "doubtful", "bad", "veryBad"] as const) { + for (const rank of [2, 3, 4, 7, 15]) { + const { phrase } = masterWords(phrases, { ...worst, skill, rank }); + const cited = (skill === "none" || skill === "doubtful") && rank <= 3; + if (cited) { + assert.ok(phrase.includes(`le ${rank}e choix`), `${skill} ${rank} : ${phrase}`); + } else { + assert.ok(!phrase.includes("choix"), `${skill} ${rank} : ${phrase}`); + assert.equal(phrase, "J'aurais joué 8/5 6/5. 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 55c9539..a511981 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/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/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/adr/0007-architecture-et-conventions-de-code.md b/docs/adr/0007-architecture-et-conventions-de-code.md index 337266c..22a7ec9 100644 --- a/docs/adr/0007-architecture-et-conventions-de-code.md +++ b/docs/adr/0007-architecture-et-conventions-de-code.md @@ -128,3 +128,25 @@ ne change pas (version 1) : le résumé d'une partie gagne un champ facultatif `match`, recalculé à chaque enregistrement, et les réglages (`ustagammon.settings`) quatre champs (`gameKind`, `matchLength`, `level`, `masterAdvice`) qui ont une valeur par défaut quand ils manquent. + +### 2026-11-10 — Le conseil du maître rattaché au journal + +Avec le conseil du maître (étape 1d), le journal d'une partie +(`GameJournal`, `app/src/domain/game/events.ts`) gagne un champ facultatif +`advice` : pour chaque décision du joueur analysée (coup, jet sans doubler, +double, prise ou refus d'un double), l'analyse du moteur à 2 plis, rangée +sous l'identifiant de l'événement de la décision +(`app/src/domain/game/advice.ts`). Ce n'est pas un événement : l'analyse +arrive après coup, d'un second moteur, et ne change pas la partie ; les +événements gardent leur version (2). Le bilan de fin de partie (1e) la +relira sans recalcul. + +- Chaque conseil porte sa version (`ADVICE_VERSION`, 1). À la reprise + (`migrateJournal`), un conseil d'une autre version, ou rattaché à un + événement absent, est oublié : ce n'est qu'une analyse, qui peut être + refaite. Un journal enregistré avant l'étape 1d n'a pas de champ `advice` + et se reprend tel quel. Testé dans `app/tests/advice.test.ts`. +- Ajouter un événement, revenir en arrière ou changer de branche garde les + conseils ; un conseil reste attaché à son événement, même hors de la + branche courante. +- Le schéma IndexedDB ne change pas : le journal est enregistré tel quel. diff --git a/docs/adr/0013-position-dans-l-adresse.md b/docs/adr/0013-position-dans-l-adresse.md index 6f80722..1b26b81 100644 --- a/docs/adr/0013-position-dans-l-adresse.md +++ b/docs/adr/0013-position-dans-l-adresse.md @@ -112,3 +112,21 @@ Le menu, l'accueil et « Nouvelle partie » en fin de partie y mènent. « Comme remplace cette adresse par celle de la partie (`#/`) : le retour du téléphone ne rouvre pas l'écran. Le lien d'une position d'une partie de match porte le Match ID du match (longueur, score, partie Crawford), écrit par le moteur. + +### 2026-11-10 — Adresse de la feuille d'explication du maître + +Ajoutée avec le conseil du maître (étape 1d), dans le même schéma : + +| Écran | Adresse | +| --- | --- | +| Feuille d'explication du maître sur une décision de la partie en cours | `#/explication/<événement>` | + +`<événement>` est l'identifiant, dans le journal de la partie en cours, de +l'événement de la décision commentée (le coup, le double, la prise…). La +ligne du maître est un lien vers cette adresse ; la feuille monte au-dessus +de la partie, comme le menu, et « Fermer » (ou le retour du téléphone) +revient à la partie. Rechargée, l'adresse rouvre la même explication : le +conseil est enregistré dans le journal (ADR 0007, amendement du +2026-11-10). Un événement sans conseil donne un message, jamais une page +cassée. L'adresse n'a de sens que sur l'appareil qui a joué la partie : ce +n'est pas un lien à partager (le lien de la position, lui, se partage). diff --git a/docs/etape-1.md b/docs/etape-1.md index 7dd86f3..f506d6b 100644 --- a/docs/etape-1.md +++ b/docs/etape-1.md @@ -20,7 +20,7 @@ Repris du cadrage (modules, écrans clés, IA générative en V1) et des ADR 001 | 1a. Moteur pour l'analyse (fait) | Niveaux de jeu (plis et bruit de GNU Backgammon : débutant à expert), classement des coups d'un jet avec équités, analyse d'un coup joué et d'une décision de videau, chance d'un jet ; exposés par `engine.h`, `engine-cli` et le Worker, avec positions de référence comparées à gnubg de bureau | — | | 1b. Parties enregistrées (fait) | Stockage IndexedDB derrière l'interface de stockage existante : plusieurs parties, reprise automatique, branches (« Rejouer d'ici » crée une branche, l'originale reste intacte et rechargeable), export SGF avec variantes | — | | 1c. Match et réglages (fait) | Partie libre ou match en N points, règle Crawford, règle Jacoby, choix du niveau de l'ordinateur, conseil du maître activable ou non ; écran de nouvelle partie | 1a, 1b | -| 1d. Conseil du maître | Après chaque coup du joueur, jamais avant (ADR 0011) : appréciation du moteur (bon coup, imprécision, erreur, grosse erreur) avec mot, icône et couleur, phrase du maître fondée sur les chiffres du moteur, coup recommandé montré sur demande ; aussi pour les décisions de videau ; calcul en arrière-plan sans retarder le jeu | 1a | +| 1d. Conseil du maître (fait) | Après chaque coup du joueur, jamais avant (ADR 0011) : appréciation du moteur (bon coup, imprécision, erreur, grosse erreur) avec mot, icône et couleur, phrase du maître fondée sur les chiffres du moteur, coup recommandé montré sur demande ; aussi pour les décisions de videau ; calcul en arrière-plan sans retarder le jeu | 1a | | 1e. Fin de partie et analyse | Bilan (ADR 0011) : résultat, coups par appréciation, taux d'erreur comparé à la chance, « le coup à revoir » ; écran d'analyse : graphe d'équité (toucher un point ouvre la position), liste des erreurs filtrable, visualiseur pas à pas coup joué contre meilleur coup, « Rejouer d'ici » | 1a, 1b, 1d | | 1f. Prompts à copier | Gabarits versionnés et testés (`docs/prompts/` ou équivalent, source unique), bouton « Demander à une IA » sur le plateau, le conseil du maître, l'analyse et la fin de partie ; le moteur décide, l'IA explique | 1d, 1e | | 1g. Accueil, tutoriel et leçons | Accueil (reprendre, nouvelle partie, prochaine leçon, progression résumée), tutoriel des règles interactif et illustré (0e.5), cinq premières leçons numérotées (ADR 0011) avec positions et « à vous de jouer », progression locale ; contenus CC BY-SA 4.0 en brouillon, à relire par un joueur fort | 0e.5, 1a | 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..d1f2658 --- /dev/null +++ b/docs/maitre/phrases.md @@ -0,0 +1,233 @@ +# 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 ou 3 seulement) | +| `{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 + +« Bon coup » sans être le meilleur, votre coup étant le 2e ou le 3e choix du +moteur. Le rang n'est cité qu'ici et dans coup.douteux : il mesure mal la +gravité d'une erreur (un 2e choix peut être loin du premier), et le +classement complet est dans « Voir les chiffres du moteur ». + +- Tout près : j'aurais joué {best}, votre coup était le {rank}e choix. + +## coup.douteux + +Imprécision, votre coup étant le 2e ou le 3e choix. + +- J'aurais joué {best}. Votre coup était le {rank}e choix, tout près. + +## coup.loin + +« Bon coup » non meilleur ou imprécision, votre coup étant au-delà du 3e +choix : le rang n'est pas cité. + +- J'aurais joué {best}. + +## coup.mauvais + +Erreur : le rang n'est jamais cité. + +- J'aurais joué {best}. + +## coup.tres-mauvais + +Grosse erreur : le rang n'est jamais cité. + +- J'aurais joué {best}. + +## 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. + +## 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 diff --git a/docs/plans/2026-11-03-conseil-du-maitre.md b/docs/plans/2026-11-03-conseil-du-maitre.md deleted file mode 100644 index 87f121b..0000000 --- a/docs/plans/2026-11-03-conseil-du-maitre.md +++ /dev/null @@ -1,64 +0,0 @@ -# Conseil du maître (1d) - -- **Date** : 2026-11-03 -- **Étape** : 1d (`docs/etape-1.md`) -- **Statut** : prévu (après 1c) -- **Exécutant** : sous-agent general-purpose, dans un worktree - -## Objectif - -Le coach bienveillant, « mis en avant mais discret » (Pierre, 2026-11-03) : -après chaque coup et chaque décision de videau du joueur, jamais avant -(ADR 0011), une appréciation et une phrase du maître fondées sur les chiffres -du moteur. - -## Références - -- ADR 0011 ; maquette « conseil du maître après le coup » du canevas - https://claude.ai/artifact/CQw6vbDpi6XQqVWQa4sANe ; règles de la charte - (`docs/identite/charte/`, dont interaction.md) ; la ligne réservée de 44 px - de la révision UX. -- Moteur : `analyseMove`, `analyseCube`, `luck`, seuils `SKILL_THRESHOLDS` - (gnubg : 0,03 / 0,06 / 0,12). - -## Consignes - -1. **Analyse en arrière-plan** : après la validation d'un coup (ou une - décision de videau), l'analyse à 2 plis part dans le Worker sans retarder le - tour de l'ordinateur ; son résultat est rattaché au coup dans le journal - (événement d'analyse rétrocompatible, ou cache séparé justifié) pour servir - au bilan (1e) sans recalcul. -2. **Ligne du maître** : icône d'appréciation, mot en couleur (« Bon coup. », - « Imprécision. », « Erreur. », « Grosse erreur. » (choix de Pierre du 2026-11-09, plutôt que « gaffe ») : correspondance avec les seuils - de gnubg documentée), phrase courte en italique, chevron. Toucher ouvre la - feuille d'explication : coup joué et meilleur coup montrés sur le plateau - (pions animés, ADR 0011 « comparaison visuelle »), écart d'équité seulement - sur demande, lien vers la fiche du glossaire du concept en jeu si le moteur - permet de le déterminer, sinon rien. -3. **Phrases du maître** : gabarits versionnés en français dans une source - unique sous `docs/` (CC BY-SA 4.0). Référence de ton, approuvée par Pierre - le 2026-11-09 : la section « Les phrases du maître » de - `docs/identite/charte/ecrans-etape-1.md` (1re personne et vouvoiement, - faits du moteur uniquement, moins de 140 caractères, ton sobre sans - exclamation, formules qui tournent pour les bons coups, exemples). Un test - vérifie la longueur et l'absence de « ! » sur chaque gabarit rendu. Les - phrases livrées passent par la session « identité » puis par Pierre pour - la relecture finale. -4. **Discret** : rien n'apparaît pendant que le joueur réfléchit ; la ligne - s'efface à l'action suivante ; désactivable (réglage de 1c et menu). -5. **Videau** : même traitement pour doubler, ne pas doubler, prendre, passer. -6. Scénarios Gherkin (bon coup, erreur avec meilleur coup montré, conseil - désactivé, décision de videau commentée) avec positions et dés imposés ; - tests unitaires des gabarits (chaque gabarit rendu sur des analyses réelles - du moteur). `make lint`, `make test`, `make test-browser`, captures pour la - recette design. - -## Critères de réussite - -Après une erreur volontaire dans une position connue, la ligne du maître dit -« Erreur. », nomme le coup recommandé par le moteur et le montre sur demande ; -le tour de l'ordinateur n'est pas retardé ; les trois commandes passent. - -## Résultat - -À remplir à la clôture. 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..abf72d8 --- /dev/null +++ b/docs/scenarios/comprendre-mes-coups.feature @@ -0,0 +1,52 @@ +# 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. Le maître, c'est GNU + Backgammon : il ne dit que ce que le programme a calculé. + + Scénario: Que dit le maître après un bon coup ? + « Bon coup. », avec une phrase qui change d'une fois à l'autre et, s'il + y en a, ce que votre coup fait sur le plateau : un pion frappé, un point + fait. Avec 3-1 à l'ouverture, 8/5 6/5 est le meilleur coup. + + É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: Comment voir le coup que le maître aurait joué ? + Après une imprécision ou une erreur, la ligne nomme le coup du maître. + Touchez-la : l'explication montre, sur le plateau, votre coup et le coup + du maître joués pion par pion depuis la même position. Les chiffres, + repliés en bas, donnent l'écart entre les coups si vous les voulez. + + É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: Comment faire taire le maître ? + Coupez « Conseil du maître » dans le menu, ou sur l'écran « Nouvelle + partie ». La ligne reste alors vide, et rien d'autre ne bouge. Vos coups + restent analysés pour le bilan de fin de partie. + + É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-t-il le videau ? + Oui : après votre double, votre réponse à un double de l'ordinateur, ou + quand vous avez lancé les dés alors qu'il aurait doublé, le maître dit + si vous avez bien fait. + + É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/tests/browser.sh b/tests/browser.sh index 3674275..beccdd5 100755 --- a/tests/browser.sh +++ b/tests/browser.sh @@ -8,7 +8,10 @@ # partie » et un match en 3 points joué jusqu'au bout (tests/browser/match.mjs), # captures dans build/browser-shots/match/. # -# BROWSERS=chromium et SCRIPTS=accueil (ou play, ou match) restreignent le +# Le conseil du maître (tests/browser/maitre.mjs), captures dans +# build/browser-shots/maitre/. +# +# BROWSERS=chromium et SCRIPTS=accueil (ou play, match, maitre) restreignent le # test. # # Outils : l'image de Playwright (navigateurs) et le paquet playwright, @@ -22,7 +25,7 @@ cd "$(dirname "$0")/.." image="${PLAYWRIGHT_IMAGE:?lancer via make test-browser}" browsers="${BROWSERS:-chromium firefox}" # Les scripts à lancer (tests/browser/.mjs), tous par défaut. -scripts="${SCRIPTS:-play accueil match}" +scripts="${SCRIPTS:-play accueil match maitre}" unset MAKEFLAGS MFLAGS MAKELEVEL make -s package >/dev/null @@ -59,7 +62,8 @@ update_port="$(docker port "$update_container" 80/tcp | head -n1 | sed 's/.*://' rm -rf build/browser-shots mkdir -p build/browser-shots -cp tests/browser/play.mjs tests/browser/accueil.mjs tests/browser/match.mjs tests/browser/recette.mjs "$tools/" +cp tests/browser/play.mjs tests/browser/accueil.mjs tests/browser/match.mjs tests/browser/recette.mjs \ + tests/browser/maitre.mjs "$tools/" status=0 for browser in $browsers; do # Réseau de l'hôte : le site est sur 127.0.0.1. Le service worker @@ -85,5 +89,12 @@ for browser in $browsers; do -v "$PWD/$tools:/tools" -v "$PWD/build/browser-shots:/shots" -w /tools "$image" \ node match.mjs "http://localhost:$port/" "$browser" /shots || status=1 fi + if [[ " $scripts " == *" maitre "* ]]; then + # Conseil du maître : ligne, feuille d'explication, videau, réglage ; + # captures de la recette dans build/browser-shots/maitre/. + docker run --rm --init --ipc=host --network host --user "$(id -u):$(id -g)" -e HOME=/tmp \ + -v "$PWD/$tools:/tools" -v "$PWD/build/browser-shots:/shots" -w /tools "$image" \ + node maitre.mjs "http://localhost:$port/" "$browser" /shots || status=1 + fi done exit "$status" diff --git a/tests/browser/maitre.mjs b/tests/browser/maitre.mjs new file mode 100644 index 0000000..827ae00 --- /dev/null +++ b/tests/browser/maitre.mjs @@ -0,0 +1,297 @@ +// maitre.mjs -- le conseil du maître (étape 1d) joué par l'interface, dans +// un vrai navigateur sans écran (Playwright), sur le site servi par l'image +// de production (étape 1d, docs/etape-1.md ; charte, ecrans-etape-1.md). +// +// Copyright (C) 2026 les auteurs d'Ustagammon. +// Licence : GPLv3 ou ultérieure (voir LICENSE). +// +// Usage : node maitre.mjs +// Lancé par tests/browser.sh. Chaque cas part d'une position connue +// (#/position/..., « Jouer à partir d'ici ») avec des dés imposés : les dés +// de l'application viennent de Math.random, remplacé ici par une file de +// valeurs choisies (window.__dice), puis par un générateur à graine. +// Vérifie la ligne du maître (bon coup, imprécision, erreur, grosse +// erreur), la feuille d'explication et son adresse, une décision de videau +// commentée, le conseil coupé dans le menu, l'absence d'erreur et de +// violation de la politique de sécurité. Captures de la recette design +// (Chromium) dans /maitre/, en 412×915 et 915×412. + +import { mkdirSync } from "node:fs"; +import { chromium, firefox } from "playwright"; + +const [base, browserName, shots] = process.argv.slice(2); +const browserType = { chromium, firefox }[browserName]; +if (!base || !browserType || !shots) { + console.error("usage : node maitre.mjs "); + process.exit(2); +} +const full = browserName === "chromium"; +const dir = `${shots}/maitre`; +mkdirSync(dir, { recursive: true }); +const PORTRAIT = { width: 412, height: 915 }; +const LANDSCAPE = { width: 915, height: 412 }; +const START = "4HPwATDgc/ABMA"; +// Une position de double et prise (videau au centre) : doubler y est juste. +const DOUBLE_TAKE = "6O5uAAC4uzsAAA"; + +const problems = []; +const log = (text) => console.log(`[${browserName}] ${text}`); +const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); +let captures = 0; + +function check(condition, message) { + if (!condition) { + throw new Error(message); + } +} + +const browser = await browserType.launch(); +const context = await browser.newContext({ + viewport: PORTRAIT, + deviceScaleFactor: 1, + locale: "fr-FR", + // Sans animation : les captures montrent les coups posés, avec leurs + // traces. + reducedMotion: "reduce", +}); +await context.addInitScript(() => { + // Les dés imposés d'abord (un dé par valeur), puis une graine fixe. + window.__dice = []; + let state = 11; + Math.random = () => { + const die = window.__dice.shift(); + if (die !== undefined) { + return (die - 0.5) / 6; + } + state = (state * 1103515245 + 12345) % 2147483648; + return state / 2147483648; + }; + window.__csp = []; + document.addEventListener("securitypolicyviolation", (e) => + window.__csp.push(`${e.effectiveDirective} ${e.blockedURI}`), + ); +}); +const page = await context.newPage(); +page.on("pageerror", (error) => problems.push(`erreur de la page : ${error.message}`)); +page.on("console", (message) => { + if (message.type() === "error") { + problems.push(`console : ${message.text()}`); + } +}); + +/** Attend une condition dans la page (sans page.waitForFunction, qui passe + * par eval, interdit par la politique de sécurité du site). */ +async function until(what, fn, arg, ms = 90_000) { + for (let waited = 0; waited < ms; waited += 100) { + if (await page.evaluate(fn, arg)) { + return; + } + await sleep(100); + } + await page.screenshot({ path: `${dir}/${browserName}-bloque.png` }).catch(() => {}); + throw new Error(`attente trop longue : ${what}`); +} + +const idle = () => until("écran libre", () => !document.body.classList.contains("busy")); + +/** Une capture de la recette, dans les deux tailles (Chromium seulement). */ +async function capture(name) { + if (!full) { + return; + } + // Le bandeau « Reprendre l'ancienne » (10 s) ne gêne pas la lecture. + await until("bandeau parti", () => document.getElementById("banner").hidden, null, 15_000); + for (const size of [PORTRAIT, LANDSCAPE]) { + await page.setViewportSize(size); + await sleep(300); + await page.screenshot({ path: `${dir}/${name}-${size.width}x${size.height}.png` }); + captures++; + } + await page.setViewportSize(PORTRAIT); + await sleep(300); +} + +/** Une nouvelle partie à partir d'une position, joueur au trait. */ +async function startFrom(position) { + await page.goto(`${base}#/position/${encodeURIComponent(position)}`); + await until("position lue", () => { + const play = document.getElementById("position-play"); + return !play.hidden && !play.disabled; + }); + await page.click("#position-play"); + await until("partie commencée", () => + location.hash === "#/" && !document.body.classList.contains("busy") && + !document.getElementById("roll").hidden); +} + +/** Lance les dés imposés et joue un coup, demi-coup par demi-coup : le pion + * de départ, puis sa case d'arrivée si le pion ne s'y est pas déjà posé. */ +async function play(dice, halfMoves) { + await page.evaluate((d) => { + window.__dice.push(...d); + }, dice); + await page.click("#roll"); + await idle(); + for (const [from, to] of halfMoves) { + await page.click(`#board .hit[data-place="p${from}"]`); + await idle(); + const land = page.locator(`#board .hit.can-land[data-place="p${to}"]`); + if ((await land.count()) > 0) { + await land.click(); + await idle(); + } + } + check(!(await page.locator("#confirm").isHidden()), `coup incomplet : ${JSON.stringify(halfMoves)}`); + await page.click("#confirm"); +} + +/** La ligne du maître telle qu'elle s'affiche. */ +async function adviceLine() { + return page.evaluate(() => { + const line = document.getElementById("advice-line"); + return { + shown: !line.hidden, + word: line.querySelector(".advice-word")?.textContent ?? "", + phrase: line.querySelector(".advice-phrase")?.textContent ?? "", + href: line.getAttribute("href"), + infoShown: !document.getElementById("info").hidden, + height: line.getBoundingClientRect().height, + chip: document.getElementById("advice-chip") !== null, + }; + }); +} + +async function waitAdvice() { + await until("ligne du maître", () => + !document.getElementById("advice-line").hidden && + !document.body.classList.contains("busy")); + return adviceLine(); +} + +// ------------------------------------------------- Bon coup, et ses voisins + +const cases = [ + { name: "1-bon-coup", dice: [3, 1], moves: [[8, 5], [6, 5]], word: "Bon coup." }, + { name: "2-imprecision", dice: [2, 1], moves: [[13, 11], [11, 10]], word: "Imprécision." }, + { name: "3-erreur", dice: [2, 1], moves: [[24, 22], [6, 5]], word: "Erreur." }, + { name: "4-grosse-erreur", dice: [3, 1], moves: [[24, 23], [13, 10]], word: "Grosse erreur." }, +]; +for (const c of cases) { + await startFrom(START); + // Rien pendant que le joueur réfléchit. + check(!(await adviceLine()).shown, `${c.name} : ligne avant le coup`); + await play(c.dice, c.moves); + const line = await waitAdvice(); + check(line.word === c.word, `${c.name} : mot « ${line.word} », attendu « ${c.word} »`); + check(line.phrase.length > 0 && !line.phrase.includes("!"), `${c.name} : phrase « ${line.phrase} »`); + check(!line.infoShown, `${c.name} : la ligne du dernier coup reste visible`); + check(line.height <= 44, `${c.name} : ligne de ${line.height} px`); + check(/^#\/explication\/\d+$/.test(line.href), `${c.name} : lien ${line.href}`); + if (c.word !== "Bon coup.") { + check(line.phrase.startsWith("J'aurais joué") || line.phrase.startsWith("Tout près"), + `${c.name} : le coup du maître n'est pas nommé : « ${line.phrase} »`); + } + await capture(c.name); + if (full) { + // En paysage, la pastille du mot mène à la même feuille. + await page.setViewportSize(LANDSCAPE); + await sleep(200); + check((await adviceLine()).chip, `${c.name} : pas de pastille en paysage`); + await page.setViewportSize(PORTRAIT); + await sleep(200); + } + log(`${c.name} : « ${line.word} ${line.phrase} »`); +} + +// La ligne s'efface à l'action suivante du joueur. +await page.click("#roll"); +await idle(); +check(!(await adviceLine()).shown, "la ligne reste après le lancer suivant"); + +// ------------------------------------------------- Feuille d'explication + +await startFrom(START); +await play([3, 1], [[24, 23], [13, 10]]); +await waitAdvice(); +await page.click("#advice-line"); +await until("feuille ouverte", () => + /^#\/explication\/\d+$/.test(location.hash) && + !document.getElementById("explain-veil").hidden && + document.getElementById("explain-board") !== null && + document.querySelector("#explain-board .board-frame") !== null); +const sheet = await page.evaluate(() => ({ + verdict: document.getElementById("explain-verdict").textContent, + title: document.getElementById("explain-title").textContent, + mine: document.getElementById("explain-mine")?.getAttribute("aria-checked"), + best: document.getElementById("explain-best")?.getAttribute("aria-checked"), + numbersOpen: document.getElementById("explain-numbers").open, +})); +check(sheet.verdict === "Grosse erreur · votre 3-1 d'ouverture", `feuille : ${JSON.stringify(sheet)}`); +check(sheet.title === "J'aurais joué 8/5 6/5", `titre : ${sheet.title}`); +check(sheet.best === "true" && sheet.mine === "false", "le coup du maître n'est pas montré d'abord"); +check(!sheet.numbersOpen, "les chiffres du moteur ne sont pas repliés"); +await capture("5-feuille-explication"); +await page.click("#explain-mine"); +await sleep(300); +await page.click("#explain-numbers summary"); +const rows = await page.evaluate(() => document.querySelector(".explain-table .strong")?.textContent); +check(rows !== undefined, "votre coup n'est pas en gras dans les chiffres"); +await capture("5b-feuille-votre-coup-chiffres"); +// L'adresse se recharge : la feuille revient, le conseil vient du journal. +await page.reload(); +await until("feuille rouverte après rechargement", () => + !document.getElementById("explain-veil").hidden && + document.getElementById("explain-title")?.textContent === "J'aurais joué 8/5 6/5"); +await page.click("#explain-done"); +await until("feuille fermée", () => + location.hash === "#/" && document.getElementById("explain-veil").hidden); +log("feuille d'explication : mot, coup du maître, votre coup, chiffres repliés, adresse rechargée"); + +// ------------------------------------------------- Décision de videau + +await startFrom(DOUBLE_TAKE); +check(!(await page.locator("#double").isHidden()), "doubler n'est pas proposé"); +await page.click("#double"); +await page.click("#double-confirm"); +const cube = await waitAdvice(); +check(cube.word === "Bonne décision." && cube.phrase === "Doubler était le bon choix.", + `videau : « ${cube.word} ${cube.phrase} »`); +await capture("6-decision-de-videau"); +log(`décision de videau : « ${cube.word} ${cube.phrase} »`); + +// ------------------------------------------------- Conseil coupé + +await page.click("#menu-button"); +await page.waitForSelector("#menu", { state: "visible" }); +check((await page.getAttribute("#menu-advice", "aria-checked")) === "true", "conseil coupé par défaut"); +await page.click("#menu-advice"); +check((await page.getAttribute("#menu-advice", "aria-checked")) === "false", "interrupteur sans effet"); +await page.click("#menu-close"); +await startFrom(START); +await play([3, 1], [[24, 23], [13, 10]]); +await idle(); +// Laisse le temps à l'analyse : la ligne ne doit pas venir. +await sleep(3000); +const off = await adviceLine(); +check(!off.shown && off.infoShown, `conseil coupé : ${JSON.stringify(off)}`); +await capture("7-conseil-desactive"); +// On le rallume : le conseil déjà prêt réapparaît. +await page.click("#menu-button"); +await page.waitForSelector("#menu", { state: "visible" }); +await page.click("#menu-advice"); +await page.click("#menu-close"); +check((await waitAdvice()).word === "Grosse erreur.", "conseil rallumé : la ligne ne revient pas"); +log("conseil coupé dans le menu : la ligne reste vide ; rallumé, elle revient"); + +const csp = await page.evaluate(() => window.__csp); +for (const violation of csp) { + problems.push(`politique de sécurité : ${violation}`); +} +await browser.close(); +if (problems.length > 0) { + for (const problem of problems) { + console.error(`[${browserName}] ${problem}`); + } + process.exit(1); +} +log(`conseil du maître : tout est conforme (${captures} captures)`); diff --git a/web/app.css b/web/app.css index 1b75829..bdd0964 100644 --- a/web/app.css +++ b/web/app.css @@ -2671,3 +2671,224 @@ 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. */ +/* Le plateau tient dans la hauteur, sous le sélecteur et sa légende. */ +html.landscape .explain-board { + width: auto; + max-width: 100%; + height: calc(100vh - 140px); + height: calc(100dvh - 140px); + 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 91d127d..1ca9a8e 100644 --- a/web/index.html +++ b/web/index.html @@ -63,8 +63,12 @@ +

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