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:
co-authored by
Claude Opus 5.5
parent
1330ee6df4
commit
5897925b33
@@ -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
|
||||
|
||||
@@ -10,12 +10,7 @@
|
||||
// points faits. L'application lance ces analyses sur un second moteur, en
|
||||
// arrière-plan : elles ne retardent jamais la partie.
|
||||
|
||||
import {
|
||||
ADVICE_VERSION,
|
||||
type Advice,
|
||||
type AdvisedMove,
|
||||
type MoveFacts,
|
||||
} from "../game/advice.js";
|
||||
import { ADVICE_VERSION, type Advice, type AdvisedMove, type MoveFacts } from "../game/advice.js";
|
||||
import type { BoardSide, CubeAction, Engine, EvaluatedMove, Ply } from "../game/engine.js";
|
||||
import { currentLine, type GameEvent, type GameJournal } from "../game/events.js";
|
||||
import { engineCube, STANDARD_PLY } from "../game/game.js";
|
||||
|
||||
@@ -9,9 +9,9 @@
|
||||
import {
|
||||
type Advice,
|
||||
type CubeAdvice,
|
||||
masterCubeAction,
|
||||
type MoveAdvice,
|
||||
type MoveFacts,
|
||||
masterCubeAction,
|
||||
type Verdict,
|
||||
verdictOf,
|
||||
} from "../game/advice.js";
|
||||
|
||||
@@ -84,7 +84,12 @@ export interface Engine {
|
||||
* niveau ply, comme la commande « hint » de gnubg (les coups écartés
|
||||
* par ses filtres gardent l'évaluation d'un niveau inférieur, voir leur
|
||||
* ply). */
|
||||
rankMoves(position: PositionId, cube: EngineCube, roll: Roll, ply: Ply): Promise<EvaluatedMove[]>;
|
||||
rankMoves(
|
||||
position: PositionId,
|
||||
cube: EngineCube,
|
||||
roll: Roll,
|
||||
ply: Ply,
|
||||
): Promise<EvaluatedMove[]>;
|
||||
|
||||
/** 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
|
||||
|
||||
+27
-1
@@ -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<EngineWorker> | null = null;
|
||||
function startAnalysisWorker(): Promise<EngineWorker> {
|
||||
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),
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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,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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<void> }[] = [];
|
||||
const analyse = (journal: GameJournal, eventId: string) =>
|
||||
new Promise<Advice | null>((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<ReturnType<typeof loadNodeEngine>>;
|
||||
|
||||
async function opening(dice: number[]): Promise<Game> {
|
||||
engine = await loadNodeEngine();
|
||||
return Game.start(engine, {
|
||||
setup: { position: START_POSITION, firstTurn: "player" },
|
||||
dice: fixedDice(dice),
|
||||
});
|
||||
}
|
||||
|
||||
async function play(game: Game, move: string): Promise<void> {
|
||||
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");
|
||||
});
|
||||
+43
-11
@@ -47,47 +47,74 @@ test("la source des phrases a toutes ses sections, sans point d'exclamation", ()
|
||||
const cases: { name: string; play: () => Promise<Advice>; word: string }[] = [
|
||||
{
|
||||
name: "3-1 joué 8/5 6/5 : le meilleur coup",
|
||||
play: async () => adviceOn(await loadNodeEngine(), await playOpening(await loadNodeEngine(), [3, 1], "8/5 6/5")),
|
||||
play: async () =>
|
||||
adviceOn(
|
||||
await loadNodeEngine(),
|
||||
await playOpening(await loadNodeEngine(), [3, 1], "8/5 6/5"),
|
||||
),
|
||||
word: "Bon coup",
|
||||
},
|
||||
{
|
||||
name: "2-1 joué 13/11 6/5 : tout près du meilleur",
|
||||
play: async () => adviceOn(await loadNodeEngine(), await playOpening(await loadNodeEngine(), [2, 1], "13/11 6/5")),
|
||||
play: async () =>
|
||||
adviceOn(
|
||||
await loadNodeEngine(),
|
||||
await playOpening(await loadNodeEngine(), [2, 1], "13/11 6/5"),
|
||||
),
|
||||
word: "Bon coup",
|
||||
},
|
||||
{
|
||||
name: "2-1 joué 13/10 : imprécision",
|
||||
play: async () => adviceOn(await loadNodeEngine(), await playOpening(await loadNodeEngine(), [2, 1], "13/11 11/10")),
|
||||
play: async () =>
|
||||
adviceOn(
|
||||
await loadNodeEngine(),
|
||||
await playOpening(await loadNodeEngine(), [2, 1], "13/11 11/10"),
|
||||
),
|
||||
word: "Imprécision",
|
||||
},
|
||||
{
|
||||
name: "2-1 joué 24/22 6/5 : erreur",
|
||||
play: async () => adviceOn(await loadNodeEngine(), await playOpening(await loadNodeEngine(), [2, 1], "24/22 6/5")),
|
||||
play: async () =>
|
||||
adviceOn(
|
||||
await loadNodeEngine(),
|
||||
await playOpening(await loadNodeEngine(), [2, 1], "24/22 6/5"),
|
||||
),
|
||||
word: "Erreur",
|
||||
},
|
||||
{
|
||||
name: "3-1 joué 24/23 13/10 : grosse erreur",
|
||||
play: async () => adviceOn(await loadNodeEngine(), await playOpening(await loadNodeEngine(), [3, 1], "24/23 13/10")),
|
||||
play: async () =>
|
||||
adviceOn(
|
||||
await loadNodeEngine(),
|
||||
await playOpening(await loadNodeEngine(), [3, 1], "24/23 13/10"),
|
||||
),
|
||||
word: "Grosse erreur",
|
||||
},
|
||||
{
|
||||
name: "double dans une position de double et prise",
|
||||
play: async () => adviceOn(await loadNodeEngine(), await playerDoubles(await loadNodeEngine())),
|
||||
play: async () =>
|
||||
adviceOn(await loadNodeEngine(), await playerDoubles(await loadNodeEngine())),
|
||||
word: "Bonne décision",
|
||||
},
|
||||
{
|
||||
name: "lancer sans doubler dans une position de double",
|
||||
play: async () => adviceOn(await loadNodeEngine(), await playerRollsWithoutDoubling(await loadNodeEngine())),
|
||||
play: async () =>
|
||||
adviceOn(
|
||||
await loadNodeEngine(),
|
||||
await playerRollsWithoutDoubling(await loadNodeEngine()),
|
||||
),
|
||||
word: "Erreur",
|
||||
},
|
||||
{
|
||||
name: "prendre un double juste",
|
||||
play: async () => adviceOn(await loadNodeEngine(), await playerAnswers(await loadNodeEngine(), true)),
|
||||
play: async () =>
|
||||
adviceOn(await loadNodeEngine(), await playerAnswers(await loadNodeEngine(), true)),
|
||||
word: "Bonne décision",
|
||||
},
|
||||
{
|
||||
name: "passer un double qu'il fallait prendre",
|
||||
play: async () => adviceOn(await loadNodeEngine(), await playerAnswers(await loadNodeEngine(), false)),
|
||||
play: async () =>
|
||||
adviceOn(await loadNodeEngine(), await playerAnswers(await loadNodeEngine(), false)),
|
||||
word: "Erreur",
|
||||
},
|
||||
];
|
||||
@@ -113,7 +140,10 @@ for (const c of cases) {
|
||||
|
||||
test("le coup du maître est nommé, et le fait qui distingue les coups vient du plateau", async () => {
|
||||
const engine = await loadNodeEngine();
|
||||
const best = (await adviceOn(engine, await playOpening(engine, [3, 1], "8/5 6/5"))) as MoveAdvice;
|
||||
const best = (await adviceOn(
|
||||
engine,
|
||||
await playOpening(engine, [3, 1], "8/5 6/5"),
|
||||
)) as MoveAdvice;
|
||||
assert.equal(best.kind, "move");
|
||||
assert.equal(best.version, ADVICE_VERSION);
|
||||
assert.equal(best.rank, 1);
|
||||
@@ -180,7 +210,9 @@ test("tous les gabarits, remplis avec les plus longues notations, restent sous l
|
||||
skill: rank === 1 ? "none" : skill,
|
||||
};
|
||||
for (let id = 0; id < 20; id++) {
|
||||
const line = masterLine(masterWords(phrases, { ...advice, eventId: String(id) }));
|
||||
const line = masterLine(
|
||||
masterWords(phrases, { ...advice, eventId: String(id) }),
|
||||
);
|
||||
assert.ok(line.length < LINE_LIMIT, `trop long (${line.length}) : ${line}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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<typeof createAdviceTracker>;
|
||||
let ready: Promise<void>;
|
||||
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> | void): Promise<void> {
|
||||
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());
|
||||
});
|
||||
@@ -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 |
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
# language: fr
|
||||
Fonctionnalité: Comprendre mes coups avec le conseil du maître
|
||||
|
||||
Après chacun de vos coups, et après chacune de vos décisions de videau,
|
||||
le maître dit en une ligne ce qu'il en pense : « Bon coup. »,
|
||||
« Imprécision. », « Erreur. » ou « Grosse erreur. », puis une phrase,
|
||||
par exemple le coup qu'il aurait joué. Il ne parle jamais avant que vous
|
||||
ayez joué, et la ligne s'efface dès que vous lancez les dés ou touchez un
|
||||
pion. Touchez-la pour ouvrir l'explication : votre coup et celui du
|
||||
maître, joués pion par pion sur le plateau, et, si vous le demandez, les
|
||||
chiffres du moteur. Le maître, c'est GNU Backgammon : il ne dit que ce
|
||||
que le moteur a calculé.
|
||||
|
||||
Scénario: Le maître approuve un bon coup
|
||||
Avec 3-1 à l'ouverture, 8/5 6/5 est le coup que le moteur préfère.
|
||||
|
||||
Étant donné que vous avez 3-1 à jouer à la position de départ
|
||||
Quand vous validez le coup 8/5 6/5
|
||||
Alors le maître dit « Bon coup. »
|
||||
Et sa phrase dit que 8/5 6/5 fait votre point 5
|
||||
|
||||
Scénario: Le maître montre le coup qu'il aurait joué
|
||||
Après une erreur, la ligne nomme le coup du maître. L'explication le
|
||||
joue sur le plateau, à côté du vôtre.
|
||||
|
||||
Étant donné que vous avez 3-1 à jouer à la position de départ
|
||||
Quand vous validez le coup 24/23 13/10
|
||||
Alors le maître dit « Grosse erreur. »
|
||||
Et sa phrase commence par « J'aurais joué 8/5 6/5. »
|
||||
Et l'explication peut montrer votre coup et le coup du maître sur le plateau
|
||||
|
||||
Scénario: Le conseil du maître est coupé
|
||||
Le conseil se coupe dans le menu, ou sur l'écran « Nouvelle partie ».
|
||||
La ligne reste alors vide, et le plateau ne bouge pas.
|
||||
|
||||
Étant donné que vous avez coupé le conseil du maître
|
||||
Et que vous avez 3-1 à jouer à la position de départ
|
||||
Quand vous validez le coup 24/23 13/10
|
||||
Alors le maître ne dit rien
|
||||
|
||||
Scénario: Le maître commente une décision de videau
|
||||
Après un double de l'ordinateur, le maître dit si vous avez bien fait
|
||||
de prendre ou de passer.
|
||||
|
||||
Étant donné que l'ordinateur vous propose de doubler dans une position où il faut prendre
|
||||
Quand vous passez son double
|
||||
Alors le maître dit « Erreur. »
|
||||
Et sa phrase dit « Ici, j'aurais pris. »
|
||||
+218
@@ -2549,3 +2549,221 @@ html.landscape .position-view.with-board .position-actions {
|
||||
html.landscape .position-view.with-board .position-actions > * {
|
||||
padding-inline: 16px;
|
||||
}
|
||||
|
||||
/* ------------------------------------------- Conseil du maître (1d) */
|
||||
|
||||
/* La ligne du maître : à la place du dernier coup, dans la ligne réservée
|
||||
* de 44 px (elle ne pousse rien). Pictogramme et mot en couleur
|
||||
* d'appréciation, phrase en italique tronquée, chevron. */
|
||||
.advice-line {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
gap: var(--space-2);
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
height: 44px;
|
||||
font-size: 15px;
|
||||
line-height: 20px;
|
||||
color: var(--text);
|
||||
text-decoration: none;
|
||||
border-radius: 12px;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
.advice-line.appear {
|
||||
animation: fade-in var(--duration-fast) var(--easing-standard);
|
||||
}
|
||||
|
||||
.advice-line .icon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.advice-line .icon-chevron {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.advice-text {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.advice-word {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
[data-verdict="good"] .icon:not(.icon-chevron),
|
||||
[data-verdict="good"] .advice-word { color: var(--verdict-good); }
|
||||
[data-verdict="doubtful"] .icon:not(.icon-chevron),
|
||||
[data-verdict="doubtful"] .advice-word { color: var(--verdict-doubtful); }
|
||||
[data-verdict="bad"] .icon:not(.icon-chevron),
|
||||
[data-verdict="bad"] .advice-word,
|
||||
[data-verdict="veryBad"] .icon:not(.icon-chevron),
|
||||
[data-verdict="veryBad"] .advice-word { color: var(--verdict-bad); }
|
||||
|
||||
/* Paysage : la pastille du mot, au début de la bande centrale. */
|
||||
.advice-chip-pill {
|
||||
fill: var(--surface);
|
||||
stroke: currentColor;
|
||||
stroke-width: 2;
|
||||
}
|
||||
|
||||
.advice-chip-icon {
|
||||
fill: none;
|
||||
stroke: currentColor;
|
||||
stroke-width: 2px;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
vector-effect: non-scaling-stroke;
|
||||
}
|
||||
|
||||
.advice-chip-text {
|
||||
fill: currentColor;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.advice-chip.verdict-good { color: var(--verdict-good); }
|
||||
.advice-chip.verdict-doubtful { color: var(--verdict-doubtful); }
|
||||
.advice-chip.verdict-bad,
|
||||
.advice-chip.verdict-veryBad { color: var(--verdict-bad); }
|
||||
|
||||
/* La feuille d'explication : elle monte du bas, au-dessus du plateau
|
||||
* voilé. */
|
||||
.explain-sheet {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.explain-verdict {
|
||||
display: inline-flex;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
line-height: 20px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.explain-verdict .icon {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
.explain-verdict[data-verdict="good"] { color: var(--verdict-good); }
|
||||
.explain-verdict[data-verdict="doubtful"] { color: var(--verdict-doubtful); }
|
||||
.explain-verdict[data-verdict="bad"],
|
||||
.explain-verdict[data-verdict="veryBad"] { color: var(--verdict-bad); }
|
||||
|
||||
.explain-title {
|
||||
margin: 0;
|
||||
font-size: 24px;
|
||||
line-height: 30px;
|
||||
}
|
||||
|
||||
.explain-phrase {
|
||||
margin: 0;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.explain-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.explain-body .segment {
|
||||
min-height: 44px;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
/* Le coup montré : le vôtre en couleur du texte, celui du maître en
|
||||
* couleur du bon coup (charte 1d). */
|
||||
.explain-board[data-move="mine"] { --last-move: var(--text); }
|
||||
.explain-board[data-move="best"] { --last-move: var(--verdict-good); }
|
||||
|
||||
.explain-legend {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
line-height: 18px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.explain-numbers {
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.explain-numbers summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-height: 44px;
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.explain-table {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr auto;
|
||||
gap: 4px 12px;
|
||||
font-size: 15px;
|
||||
line-height: 20px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.explain-table .strong {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.explain-note {
|
||||
margin: var(--space-2) 0 0;
|
||||
font-size: 13px;
|
||||
line-height: 18px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.explain-glossary {
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
.explain-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.explain-actions button {
|
||||
min-width: 140px;
|
||||
}
|
||||
|
||||
/* En paysage : la feuille prend la hauteur de l'écran ; le plateau à
|
||||
* gauche, le texte à droite. */
|
||||
html.landscape .explain-board {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
aspect-ratio: 640 / 520;
|
||||
}
|
||||
|
||||
html.landscape .explain-sheet {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||
grid-auto-flow: dense;
|
||||
column-gap: var(--space-4);
|
||||
align-content: start;
|
||||
width: min(100%, 60rem);
|
||||
height: 100%;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
html.landscape .explain-sheet > * {
|
||||
grid-column: 2;
|
||||
}
|
||||
|
||||
html.landscape .explain-sheet > .explain-body {
|
||||
grid-column: 1;
|
||||
grid-row: 1 / span 8;
|
||||
}
|
||||
|
||||
@@ -63,8 +63,12 @@
|
||||
<span class="match-score" hidden></span>
|
||||
<button type="button" class="count"></button>
|
||||
</div>
|
||||
<!-- Ligne du maître ou du dernier coup : après une décision du joueur,
|
||||
le conseil du maître (un lien vers sa feuille d'explication) prend la
|
||||
place du texte du dernier coup. -->
|
||||
<div class="infoline">
|
||||
<p id="info" class="info" aria-live="polite"></p>
|
||||
<a id="advice-line" class="advice-line" href="#/" hidden></a>
|
||||
<button id="replay" type="button" class="icon-button replay" hidden></button>
|
||||
<button id="copy-link" type="button" class="icon-button copy-link" hidden></button>
|
||||
</div>
|
||||
@@ -143,12 +147,17 @@
|
||||
<a id="menu-help" href="#/aide" class="item"></a>
|
||||
<button id="menu-install" type="button" class="item"></button>
|
||||
<button id="menu-theme" type="button" class="item"><span class="item-label"></span><span class="item-value"></span></button>
|
||||
<button id="menu-advice" type="button" class="item" role="switch"><span class="item-label"></span><span class="switch" aria-hidden="true"></span></button>
|
||||
<button id="menu-vibrations" type="button" class="item" role="switch"><span class="item-label"></span><span class="switch" aria-hidden="true"></span></button>
|
||||
<button id="menu-screen-on" type="button" class="item" role="switch"><span class="item-label"></span><span class="switch" aria-hidden="true"></span></button>
|
||||
<button id="menu-update" type="button" class="item"></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- L'explication du maître (#/explication/<événement>) : une feuille,
|
||||
construite par app/src/ui/advice-view.ts. -->
|
||||
<div id="explain-veil" class="veil sheet-veil" hidden></div>
|
||||
|
||||
<!-- La marche à suivre pour installer (#/installer) : une feuille. -->
|
||||
<div id="install-veil" class="veil sheet-veil" hidden>
|
||||
<div id="install-sheet" class="sheet" role="dialog" aria-modal="true" aria-labelledby="install-title">
|
||||
|
||||
Reference in New Issue
Block a user