Conseil du maître : ligne du maître, feuille d'explication, second moteur pour l'analyse

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Pierre MARTIN
2026-11-10 04:47:14 +01:00
co-authored by Claude Opus 5.5
parent 1330ee6df4
commit 5897925b33
20 changed files with 1503 additions and 29 deletions
+55
View File
@@ -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<Record<CubeAction, { file: string; term: string }>> = {
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;
}
+152
View File
@@ -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<Advice | null>;
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<string, Promise<Advice | null>>();
/** Le conseil d'une décision : déjà rattaché, en cours, ou à demander. */
function adviceOf(game: Game, id: string): Promise<Advice | null> {
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;
}
+399
View File
@@ -0,0 +1,399 @@
// La feuille d'explication du maître (#/explication/<événement>), qui monte
// du bas au-dessus du plateau voilé (charte, 1d « La feuille
// d'explication ») : le mot et le contexte, le coup du maître en titre, sa
// phrase, « Votre coup » et « Le coup du maître » joués pion par pion sur la
// position d'avant, puis les chiffres du moteur, repliés.
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
import { type MasterPhrases, masterWords } from "../domain/analysis/phrases.js";
import {
type Advice,
type CubeAdvice,
type MoveAdvice,
masterCubeAction,
type Verdict,
} from "../domain/game/advice.js";
import type { Game, MoveSteps } from "../domain/game/game.js";
import { ADVICE_TEXTS, CUBE_GLOSSARY, formatEquity } from "./advice-texts.js";
import { boardModel, moveTrace, placeOf } from "./board-model.js";
import { checkerSpot, NO_MARKS, renderBoard, slideChecker } from "./board-view.js";
import { boardGeometry, PORTRAIT_ASPECT } from "./geometry.js";
import { type IconName, icon } from "./icons.js";
import { routeHash } from "./router.js";
const SLIDE_MS = 160;
const PAUSE_MS = 120;
/** Le pictogramme d'une appréciation (interaction.md, pictogrammes). */
export const VERDICT_ICONS: Readonly<Record<Verdict, IconName>> = {
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<MasterPhrases>;
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<void>;
hide(): void;
}
type Shown = "mine" | "best";
function element<K extends keyof HTMLElementTagNameMap>(
parent: HTMLElement,
tag: K,
options: { id?: string; className?: string; text?: string } = {},
): HTMLElementTagNameMap[K] {
const child = document.createElement(tag);
if (options.id !== undefined) {
child.id = options.id;
}
if (options.className !== undefined) {
child.className = options.className;
}
if (options.text !== undefined) {
child.textContent = options.text;
}
parent.append(child);
return child;
}
export function 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<void> {
const id = ++opened;
const game = options.game();
const advice = game?.advice[eventId] ?? null;
sheet.replaceChildren();
veil.hidden = false;
const head = element(sheet, "div", { className: "sheet-head" });
element(head, "span", { className: "grip" }).setAttribute("aria-hidden", "true");
const verdictLine = element(head, "p", {
id: "explain-verdict",
className: "explain-verdict",
});
const close = element(head, "button", { id: "explain-close", className: "icon-button" });
close.type = "button";
close.append(icon("close"));
close.setAttribute("aria-label", ADVICE_TEXTS.close);
close.onclick = () => options.close();
const title = element(sheet, "h2", { id: "explain-title", className: "explain-title" });
if (game === null || advice === null) {
title.textContent = ADVICE_TEXTS.sheetLabel;
element(sheet, "p", { className: "explain-phrase", text: ADVICE_TEXTS.missing });
closeButton();
close.focus();
return;
}
const words = masterWords(await options.phrases(), advice);
if (id !== opened) {
return;
}
verdictLine.dataset.verdict = words.verdict;
verdictLine.append(icon(VERDICT_ICONS[words.verdict]));
element(verdictLine, "span", { text: `${words.word} · ${words.context}` });
title.textContent = words.title;
element(sheet, "p", {
id: "explain-phrase",
className: "explain-phrase",
text: words.phrase,
});
const body = element(sheet, "div", { className: "explain-body" });
const boardBox = element(body, "div", { className: "explain-board-box" });
if (advice.kind === "move") {
await showMoves(id, game, advice, body, boardBox);
} else {
await showCube(id, game, advice, boardBox);
}
if (id !== opened) {
return;
}
numbers(advice);
if (advice.kind === "cube") {
const entry = CUBE_GLOSSARY[masterCubeAction(advice)];
const link = element(sheet, "a", {
id: "explain-glossary",
className: "link-button explain-glossary",
text: ADVICE_TEXTS.glossary(entry.term),
});
link.href = routeHash({ screen: "glossary", term: entry.file });
}
closeButton();
close.focus();
}
function closeButton(): void {
const actions = element(sheet, "div", { className: "explain-actions" });
const done = element(actions, "button", {
id: "explain-done",
className: "secondary",
text: ADVICE_TEXTS.close,
});
done.type = "button";
done.onclick = () => options.close();
}
/** Le plateau de la feuille, dans son cadre. */
function boardSvg(box: HTMLElement): SVGSVGElement {
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
svg.id = "explain-board";
svg.setAttribute("class", "board explain-board");
svg.setAttribute("role", "img");
box.append(svg);
return svg;
}
function draw(svg: SVGSVGElement, steps: MoveSteps, index: number, traced: boolean): void {
const boards = steps.boards[index];
if (boards === undefined) {
return;
}
const width = svg.getBoundingClientRect().width || 358;
renderBoard(
svg,
boardGeometry("portrait", PORTRAIT_ASPECT, width / 640),
boardModel(boards),
{
...NO_MARKS,
dice: [],
cube: null,
band: null,
controls: null,
trace: traced ? moveTrace(steps.side, steps.halfMoves, steps.hits) : null,
},
);
// Les flèches des traces renvoient à leur pointe par identifiant :
// celui du plateau de jeu serait pris à sa place.
for (const marker of svg.querySelectorAll("marker[id]")) {
marker.id = `explain-${marker.id}`;
}
for (const path of svg.querySelectorAll("[marker-end]")) {
const ref = path.getAttribute("marker-end") ?? "";
path.setAttribute("marker-end", ref.replace("url(#", "url(#explain-"));
}
}
/** Joue un coup pion par pion (sans mouvement si le système le
* demande), puis laisse ses traces. */
async function animate(svg: SVGSVGElement, steps: MoveSteps): Promise<void> {
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<void> {
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<Shown, HTMLButtonElement> = {
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<void> {
const steps = await game.stepsBefore(advice.eventId, "player", []);
if (id !== opened) {
return;
}
const svg = boardSvg(boardBox);
svg.setAttribute("aria-label", ADVICE_TEXTS.legendCube);
draw(svg, steps, 0, false);
element(boardBox, "p", { className: "explain-legend", text: ADVICE_TEXTS.legendCube });
}
/** « Voir les chiffres du moteur », replié par défaut. */
function numbers(advice: Advice): void {
const details = element(sheet, "details", {
id: "explain-numbers",
className: "explain-numbers",
});
element(details, "summary", { text: ADVICE_TEXTS.numbers });
const table = element(details, "div", { className: "explain-table" });
const row = (cells: string[], strong: boolean) => {
for (const text of cells) {
element(table, "span", { className: strong ? "strong" : "", text });
}
};
if (advice.kind === "move") {
const best = advice.best.equity;
const listed = advice.ranking.map((move, i) => ({ move, rank: i + 1 }));
if (!advice.ranking.some((m) => m.notation === advice.played.notation)) {
listed.push({ move: advice.played, rank: advice.rank });
}
for (const { move, rank } of listed) {
const mine = move.notation === advice.played.notation;
row(
[
String(rank),
mine ? `${move.notation} ${ADVICE_TEXTS.you}` : move.notation,
formatEquity(move.equity - best),
],
mine,
);
}
element(details, "p", {
className: "explain-note",
text: ADVICE_TEXTS.moveNote(advice.ply),
});
} else {
// Les lignes de votre décision, en gras : doubler mène à l'une des
// deux lignes « Double », selon la réponse de l'ordinateur.
const mine = {
noDouble: ["noDouble"],
double: ["doubleTake", "doublePass"],
take: ["doubleTake"],
pass: ["doublePass"],
}[advice.action];
for (const key of ["noDouble", "doubleTake", "doublePass"] as const) {
const bold = mine.includes(key);
const name = ADVICE_TEXTS.cubeRows[key];
row(
[
"",
bold && mine.length === 1 ? `${name} ${ADVICE_TEXTS.you}` : name,
formatEquity(advice.equities[key]),
],
bold,
);
}
element(details, "p", {
className: "explain-note",
text: `${ADVICE_TEXTS.cubeNote(advice.ply)}${formatEquity(advice.equityLoss)}.`,
});
}
}
return {
show,
hide() {
opened++;
playing++;
if (!veil.hidden) {
veil.hidden = true;
sheet.replaceChildren();
}
},
};
}
+161 -1
View File
@@ -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<HTMLElement>(document.documentElement, ".board-box"),
info: byId("info"),
adviceLine: byId<HTMLAnchorElement>("advice-line"),
explainVeil: byId("explain-veil"),
menuAdvice: byId<HTMLButtonElement>("menu-advice"),
prompt: byId("prompt"),
strips: { computer: stripOf("strip-computer"), player: stripOf("strip-player") },
bubble: byId("pip-bubble"),
@@ -340,6 +360,8 @@ export function startApp(options: AppOptions): void {
label(ui.menuHelp, TEXTS.helpAndGlossary, "help");
ui.menuTheme.prepend(icon("theme"));
child(ui.menuTheme, ".item-label").textContent = TEXTS.theme;
ui.menuAdvice.prepend(icon("verdictGood"));
child(ui.menuAdvice, ".item-label").textContent = ADVICE_TEXTS.menuSwitch;
ui.menuVibrations.prepend(icon("vibrate"));
child(ui.menuVibrations, ".item-label").textContent = TEXTS.vibrations;
ui.menuScreenOn.prepend(icon("screenOn"));
@@ -433,6 +455,36 @@ export function startApp(options: AppOptions): void {
// Les parties jouées et gagnées, lues dans « Mes parties » pour
// l'accueil.
let homeProgress: { played: number; won: number } | null = null;
// Le conseil du maître : ses phrases (lues une fois), les analyses en
// cours et la ligne à montrer.
let phrases: MasterPhrases | null = null;
const phrasesLoading: Promise<MasterPhrases> = fetch(PHRASES_SOURCE)
.then((response) => {
if (!response.ok) {
throw new Error(`phrases du maître : ${response.status}`);
}
return response.text();
})
.then((text) => {
phrases = parsePhrases(text);
render();
return phrases;
});
phrasesLoading.catch(() => undefined);
const adviceTracker = createAdviceTracker({
analyse: options.analyse,
// Le conseil rattaché au journal est enregistré avec la partie, si
// elle est toujours à l'écran.
recorded: (g) => {
if (g === game) {
save();
}
},
changed: () => render(),
});
// La ligne du maître dessinée (identifiant de sa décision) : elle n'est
// reconstruite, en fondu, que si elle change.
let adviceDrawn: string | null = null;
const landscapeQuery = window.matchMedia("(orientation: landscape) and (max-height: 40rem)");
const portraitQuery = window.matchMedia("(orientation: portrait)");
@@ -538,6 +590,7 @@ export function startApp(options: AppOptions): void {
band:
landscape && !panelShown ? (text === null ? TEXTS.gameOver : text.short) : null,
controls: landscape ? controls() : null,
advice: landscape && !panelShown ? boardAdvice(shownAdvice()) : null,
});
}
renderStrips(model.pipCount, state.phase === "over" ? null : state.turn);
@@ -545,6 +598,8 @@ export function startApp(options: AppOptions): void {
// Ligne d'information : un jet sans coup possible reste annoncé
// jusqu'à l'action suivante, même après un rechargement de la page.
const advice = shownAdvice();
renderAdviceLine(notice === null && !stuck ? advice : null);
if (notice !== null) {
ui.info.textContent = notice;
} else if (stuck && state.roll !== null) {
@@ -607,6 +662,69 @@ export function startApp(options: AppOptions): void {
void keepScreenOn(isPlaying(game) || state.phase === "doubled");
}
/** Le conseil du maître à montrer : le réglage le permet, ses phrases
* sont lues, et l'analyse attendue est prête. */
function shownAdvice(): {
advice: Advice;
line: string;
words: ReturnType<typeof masterWords>;
} | 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<typeof shownAdvice>): 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<typeof shownAdvice>): BoardAdvice | null {
if (shown === null) {
return null;
}
return {
href: routeHash({ screen: "explanation", event: shown.advice.eventId }),
verdict: shown.words.verdict,
icon: VERDICT_ICONS[shown.words.verdict],
word: shown.words.word,
label: ADVICE_TEXTS.chipLabel(shown.line),
};
}
/** Avant que le moteur réponde : le dernier plateau affiché (ou la
* position de départ), non jouable, et les zones à leur place. */
function renderLoading(g: Geometry): void {
@@ -751,6 +869,7 @@ export function startApp(options: AppOptions): void {
);
child(ui.menuTheme, ".item-value").textContent = TEXTS.themes[settings.theme];
ui.menuVibrations.setAttribute("aria-checked", String(settings.vibrations));
ui.menuAdvice.setAttribute("aria-checked", String(settings.masterAdvice));
ui.menuScreenOn.setAttribute("aria-checked", String(settings.keepScreenOn));
}
@@ -966,11 +1085,19 @@ export function startApp(options: AppOptions): void {
bubbleSide = null;
notice = null;
ui.bubble.hidden = true;
// L'action suivante du joueur efface la ligne du maître.
adviceTracker.clear();
render();
const before = game;
const lineBefore = game.events;
const wasOver = game.state.phase === "over";
try {
await action(game);
// Ses décisions partent à l'analyse, sans être attendues : le
// tour de l'ordinateur suit tout de suite.
if (game === before) {
adviceTracker.track(game, lineBefore);
}
save();
await computerTurns();
// Une partie vient de se terminer (pas une partie finie
@@ -1078,7 +1205,10 @@ export function startApp(options: AppOptions): void {
// L'adresse de la page dit quel écran montrer (applyRoute, plus bas).
const navigation = startNavigation((route) => applyRoute(route));
const isSheet = (route: Route) =>
route.screen === "menu" || route.screen === "games" || route.screen === "install";
route.screen === "menu" ||
route.screen === "games" ||
route.screen === "install" ||
route.screen === "explanation";
// « Mes parties » : reprendre une partie (ou une branche) en change à
// l'écran, sans recharger la page.
@@ -1102,6 +1232,15 @@ export function startApp(options: AppOptions): void {
const helpView = createHelpView(ui.helpContent, ui.helpTabs);
// La feuille d'explication du maître (#/explication/<événement>).
const adviceView = createAdviceView({
veil: ui.explainVeil,
game: () => game,
phrases: () => phrasesLoading,
reducedMotion,
close: () => leaveSheet(),
});
/** Joue un demi-coup du joueur ; le coup attend ensuite sa suite ou sa
* validation. Le pion qui se pose vibre (deux fois s'il frappe). */
function playHalfMove(halfMove: HalfMove): void {
@@ -1271,6 +1410,7 @@ export function startApp(options: AppOptions): void {
ui.menuVeil.hidden = true;
ui.installVeil.hidden = true;
gamesView?.hide();
adviceView.hide();
if (menuWasOpen) {
(menuReturnFocus ?? ui.menuButton).focus();
}
@@ -1334,6 +1474,7 @@ export function startApp(options: AppOptions): void {
ui.menuFullscreen.onclick = actions.fullscreen;
ui.menuTheme.onclick = () => saveSettings({ theme: nextTheme(settings.theme) });
ui.menuVibrations.onclick = () => saveSettings({ vibrations: !settings.vibrations });
ui.menuAdvice.onclick = () => saveSettings({ masterAdvice: !settings.masterAdvice });
ui.menuScreenOn.onclick = () => saveSettings({ keepScreenOn: !settings.keepScreenOn });
ui.fullscreenButton.onclick = actions.fullscreen;
// « Retour » : à l'écran d'avant l'aide (la partie, si l'aide a été
@@ -1430,6 +1571,15 @@ export function startApp(options: AppOptions): void {
(menuReturnFocus ?? ui.menuButton).focus();
}
}
// La feuille d'explication du maître, sur une décision de la partie
// à l'écran.
if (route.screen === "explanation") {
closeTransient();
ui.menuVeil.hidden = true;
void adviceView.show(route.event);
} else {
adviceView.hide();
}
if (route.screen === "games") {
const expanded = route.game;
// La liste vient du stockage, ouvert avec le moteur.
@@ -1942,6 +2092,10 @@ export function startApp(options: AppOptions): void {
if (ui.game.hidden && name !== null && !["close", "help", "menu"].includes(name)) {
name = null;
}
// La feuille d'explication ouverte : le jeu attend qu'on la ferme.
if (!ui.explainVeil.hidden && name !== null && name !== "close") {
name = null;
}
if (name !== null) {
event.preventDefault();
actions[name]();
@@ -1972,6 +2126,12 @@ export function startApp(options: AppOptions): void {
announcedEnd = game.journal.head;
}
}
// Ouverte par son adresse avant que la partie soit là : la
// feuille d'explication la retrouve maintenant.
const route = navigation.route;
if (route.screen === "explanation") {
void adviceView.show(route.event);
}
// Comme après une action du joueur : l'ordinateur joue si c'est
// à lui (partie nouvelle ou reprise pendant son tour). Une partie
// reprise juste après son coup le montre, sans l'animer.
+83 -5
View File
@@ -83,6 +83,21 @@ export interface BoardDecor {
readonly band: string | null;
/** Paysage : la colonne de commandes (null : pas de colonne). */
readonly controls: BoardControls | null;
/** Paysage : le conseil du maître, une pastille avant la consigne dans
* la bande centrale (absent ou null : rien). */
readonly advice?: BoardAdvice | null;
}
/** Le conseil du maître en paysage : son mot, sur une pastille qui mène à
* la feuille d'explication. */
export interface BoardAdvice {
readonly href: string;
/** Appréciation : la couleur et le pictogramme. */
readonly verdict: "good" | "doubtful" | "bad" | "veryBad";
readonly icon: IconName;
readonly word: string;
/** Ce que lit le lecteur d'écran : le mot, la phrase, l'invitation. */
readonly label: string;
}
/** Ce qui ne s'ajoute pas aux pions pendant l'animation d'un coup. */
@@ -188,8 +203,10 @@ export function renderBoard(
}
svg.append(pieces);
drawLandings(svg, g, board, decor.targets);
const advice = decor.band !== null ? (decor.advice ?? null) : null;
const chip = advice === null ? 0 : adviceChipWidth(g, advice) + 8;
if (decor.band !== null) {
drawBand(svg, g, decor.band);
drawBand(svg, g, decor.band, chip);
}
drawDice(svg, g, decor.dice);
const cube = decor.cube === null ? null : drawCube(svg, g, decor.cube);
@@ -215,6 +232,9 @@ export function renderBoard(
if (decor.controls !== null) {
drawControls(svg, g, hits, decor.controls);
}
if (advice !== null) {
drawAdviceChip(svg, g, hits, advice);
}
// Les zones à toucher passent devant tout le reste.
svg.append(hits);
}
@@ -496,12 +516,13 @@ function addRing(
}
/** Paysage : la consigne courte, dans la moitié gauche de la bande
* centrale, sur une pastille accent-soft. */
function drawBand(svg: SVGSVGElement, g: Geometry, text: string): void {
* centrale, sur une pastille accent-soft ; elle laisse la place before à
* gauche (la pastille du conseil du maître). */
function drawBand(svg: SVGSVGElement, g: Geometry, text: string, before = 0): void {
const middle = g.height / 2;
const width = 6 * g.pointWidth - 24;
const width = 6 * g.pointWidth - 24 - before;
const height = Math.max(44, g.bandFont + 20);
const x = g.x0 + 12;
const x = g.x0 + 12 + before;
add(svg, "rect", {
class: "band-pill",
x,
@@ -524,6 +545,63 @@ function drawBand(svg: SVGSVGElement, g: Geometry, text: string): void {
);
}
/** Largeur de la pastille du conseil : pictogramme, mot (estimé à 0,55 em
* par lettre, police Alegreya Sans) et marges. */
function adviceChipWidth(g: Geometry, advice: BoardAdvice): number {
return 16 + g.bandFont + 6 + advice.word.length * g.bandFont * 0.55 + 16;
}
/**
* Paysage : le conseil du maître, une pastille au début de la bande
* centrale (pictogramme et mot en couleur d'appréciation), lien vers la
* feuille d'explication (charte 1d, adaptée au paysage : la phrase est dans
* la feuille). Posée parmi les zones à toucher, devant celle des dés.
*/
function drawAdviceChip(svg: SVGSVGElement, g: Geometry, hits: Element, advice: BoardAdvice): void {
const middle = g.height / 2;
const height = Math.max(44, g.bandFont + 20);
const width = adviceChipWidth(g, advice);
const x = g.x0 + 12;
const group = add(svg, "g", { class: `advice-chip verdict-${advice.verdict}` });
add(group, "rect", {
class: "advice-chip-pill",
x,
y: middle - height / 2,
width,
height,
rx: height / 2,
});
const size = g.bandFont;
const icon = add(group, "g", {
transform: `translate(${x + 16},${middle - size / 2}) scale(${size / 24})`,
});
add(icon, "path", { class: "advice-chip-icon", d: ICON_PATHS[advice.icon] });
add(
group,
"text",
{
class: "advice-chip-text",
x: x + 16 + size + 6,
y: middle + g.bandFont * 0.35,
"font-size": g.bandFont,
},
advice.word,
);
const link = add(hits, "a", {
href: advice.href,
id: "advice-chip",
role: "link",
"aria-label": advice.label,
});
add(link, "rect", {
class: "hit action",
x,
y: middle - Math.max(height, g.target) / 2,
width,
height: Math.max(height, g.target),
});
}
/**
* Paysage : le score du match, dans une case sous le menu, dans l'ordre des
* camps à l'écran : l'ordinateur en haut, « sur N », vous en bas
+1
View File
@@ -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",
+7
View File
@@ -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/<Position ID>/<Match ID> (Match ID facultatif). */
| { readonly screen: "position"; readonly position: string; readonly match: string | null }
/** Une adresse que l'application ne connaît pas. */
@@ -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