Conseil du maître : une ligne après chaque coup, l'explication sur demande (1d) #51
@@ -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
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
// L'analyse des décisions du joueur par le moteur, pour le conseil du
|
||||
// maître (étape 1d, ADR 0011) et le bilan de fin de partie (1e).
|
||||
//
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
//
|
||||
// Tout vient du moteur : l'analyse du coup joué (rang, meilleur coup, équité
|
||||
// perdue, appréciation), le classement des coups, l'analyse de la décision
|
||||
// de videau, et les plateaux décodés, où se lisent les pions frappés et les
|
||||
// points faits. L'application lance ces analyses sur un second moteur, en
|
||||
// arrière-plan : elles ne retardent jamais la partie.
|
||||
|
||||
import { ADVICE_VERSION, type Advice, type AdvisedMove, type MoveFacts } from "../game/advice.js";
|
||||
import type { BoardSide, CubeAction, Engine, EvaluatedMove, Ply } from "../game/engine.js";
|
||||
import { currentLine, type GameEvent, type GameJournal } from "../game/events.js";
|
||||
import { engineCube, STANDARD_PLY } from "../game/game.js";
|
||||
import { replay } from "../game/state.js";
|
||||
|
||||
/** Niveau de l'analyse : 2 plis, comme l'analyse de partie de gnubg par
|
||||
* défaut, et le niveau « standard » (ADR 0009). */
|
||||
export const ANALYSIS_PLY: Ply = STANDARD_PLY;
|
||||
|
||||
/** Nombre de coups gardés du classement du moteur, pour « Voir les
|
||||
* chiffres du moteur ». */
|
||||
export const RANKING_SIZE = 5;
|
||||
|
||||
/**
|
||||
* Les décisions du joueur à analyser parmi des événements : ses coups (pas
|
||||
* un tour passé faute de coup possible), ses jets (il aurait pu doubler
|
||||
* avant), ses doubles et ses réponses aux doubles de l'ordinateur.
|
||||
*/
|
||||
export function playerDecisions(events: readonly GameEvent[]): GameEvent[] {
|
||||
return events.filter((event) => {
|
||||
switch (event.type) {
|
||||
case "MovePlayed":
|
||||
return event.side === "player" && event.halfMoves.length > 0;
|
||||
case "DiceRolled":
|
||||
case "CubeOffered":
|
||||
case "CubeTaken":
|
||||
case "CubeDropped":
|
||||
return event.side === "player";
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Le conseil du moteur sur la décision du joueur eventId, au niveau ply ;
|
||||
* null s'il n'y a rien à analyser (pas une décision du joueur, aucun coup
|
||||
* possible, videau inaccessible ou position de départ pour gnubg).
|
||||
*/
|
||||
export async function analyseDecision(
|
||||
engine: Engine,
|
||||
journal: GameJournal,
|
||||
eventId: string,
|
||||
ply: Ply = ANALYSIS_PLY,
|
||||
): Promise<Advice | null> {
|
||||
const line = currentLine({ ...journal, head: eventId });
|
||||
const event = line.at(-1);
|
||||
if (event === undefined || playerDecisions([event]).length === 0) {
|
||||
return null;
|
||||
}
|
||||
const earlier = line.slice(0, -1);
|
||||
const before = replay(earlier);
|
||||
if (event.type === "MovePlayed") {
|
||||
if (before.roll === null) {
|
||||
return null;
|
||||
}
|
||||
const cube = engineCube(before, "player");
|
||||
// Le coup joué est enregistré vu de l'ordinateur : le moteur donne la
|
||||
// même position vue du joueur.
|
||||
const after = await engine.board(event.position, engineCube(before, "computer"));
|
||||
const analysis = await engine.analyseMove(
|
||||
before.position,
|
||||
cube,
|
||||
before.roll,
|
||||
after.swappedPosition,
|
||||
ply,
|
||||
);
|
||||
if (analysis === null) {
|
||||
return null;
|
||||
}
|
||||
const ranking = await engine.rankMoves(before.position, cube, before.roll, ply);
|
||||
const start = await engine.board(before.position, cube);
|
||||
const facts = async (move: EvaluatedMove) =>
|
||||
factsOf(start.onRoll, start.opponent, await engine.board(move.position, cube));
|
||||
return {
|
||||
version: ADVICE_VERSION,
|
||||
kind: "move",
|
||||
eventId,
|
||||
ply,
|
||||
position: before.position,
|
||||
roll: before.roll,
|
||||
opening: !earlier.some((e) => e.type === "MovePlayed"),
|
||||
moveCount: analysis.moveCount,
|
||||
rank: analysis.rank,
|
||||
played: advised(analysis.played),
|
||||
best: advised(analysis.best),
|
||||
playedFacts: await facts(analysis.played),
|
||||
bestFacts: await facts(analysis.best),
|
||||
ranking: ranking.slice(0, RANKING_SIZE).map(advised),
|
||||
equityLoss: analysis.equityLoss,
|
||||
skill: analysis.skill,
|
||||
};
|
||||
}
|
||||
let action: CubeAction;
|
||||
let decider: "player" | "computer" = "player";
|
||||
if (event.type === "DiceRolled") {
|
||||
if (before.phase !== "rolling") {
|
||||
return null;
|
||||
}
|
||||
action = "noDouble";
|
||||
} else if (event.type === "CubeOffered") {
|
||||
action = "double";
|
||||
} else {
|
||||
action = event.type === "CubeTaken" ? "take" : "pass";
|
||||
decider = "computer";
|
||||
}
|
||||
const analysis = await engine.analyseCube(
|
||||
before.position,
|
||||
engineCube(before, decider),
|
||||
action,
|
||||
ply,
|
||||
);
|
||||
if (!analysis.analysed || analysis.equities === null) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
version: ADVICE_VERSION,
|
||||
kind: "cube",
|
||||
eventId,
|
||||
ply,
|
||||
action,
|
||||
position: before.position,
|
||||
equities: analysis.equities,
|
||||
equityLoss: analysis.equityLoss,
|
||||
skill: analysis.skill,
|
||||
};
|
||||
}
|
||||
|
||||
function advised(move: EvaluatedMove): AdvisedMove {
|
||||
return {
|
||||
notation: move.notation,
|
||||
halfMoves: move.halfMoves.map((h) => ({ from: h.from, to: h.to })),
|
||||
equity: move.equity,
|
||||
ply: move.ply,
|
||||
};
|
||||
}
|
||||
|
||||
/** Ce que le coup a changé sur le plateau, lu sur les plateaux du moteur
|
||||
* (vus du joueur) : pions adverses envoyés sur la barre, cases où le
|
||||
* joueur a désormais deux pions ou plus. */
|
||||
function factsOf(
|
||||
mine: BoardSide,
|
||||
theirs: BoardSide,
|
||||
after: { readonly onRoll: BoardSide; readonly opponent: BoardSide },
|
||||
): MoveFacts {
|
||||
const madePoints: number[] = [];
|
||||
for (let i = 0; i < 24; i++) {
|
||||
if ((mine.points[i] ?? 0) < 2 && (after.onRoll.points[i] ?? 0) >= 2) {
|
||||
madePoints.push(i + 1);
|
||||
}
|
||||
}
|
||||
return { hits: Math.max(0, after.opponent.bar - theirs.bar), madePoints };
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
// Les mots et les phrases du maître : lecture de leur source
|
||||
// (docs/maitre/phrases.md, ADR 0008 : une seule source) et remplissage des
|
||||
// gabarits avec un conseil du moteur. Fonctions pures, testées sous Node
|
||||
// sur de vraies analyses (app/tests/advice.test.ts).
|
||||
//
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
|
||||
import {
|
||||
type Advice,
|
||||
type CubeAdvice,
|
||||
type MoveAdvice,
|
||||
type MoveFacts,
|
||||
masterCubeAction,
|
||||
type Verdict,
|
||||
verdictOf,
|
||||
} from "../game/advice.js";
|
||||
import type { CubeAction } from "../game/engine.js";
|
||||
|
||||
/** Les gabarits, par clé de section (« coup.meilleur »…). */
|
||||
export type MasterPhrases = ReadonlyMap<string, readonly string[]>;
|
||||
|
||||
/** Longueur maximale de la ligne du maître (mot et phrase), en caractères
|
||||
* (charte, « Les phrases du maître ») : elle doit rester en dessous. */
|
||||
export const LINE_LIMIT = 140;
|
||||
|
||||
/** Ce que le maître dit d'une décision. */
|
||||
export interface MasterWords {
|
||||
readonly verdict: Verdict;
|
||||
/** Le mot d'appréciation, sans point : « Bon coup », « Erreur »… */
|
||||
readonly word: string;
|
||||
/** La phrase du maître. */
|
||||
readonly phrase: string;
|
||||
/** Le titre de la feuille d'explication. */
|
||||
readonly title: string;
|
||||
/** Le contexte de la décision : « votre 3-1 d'ouverture »… */
|
||||
readonly context: string;
|
||||
}
|
||||
|
||||
/** Lit la source des phrases : les sections « ## clé » à partir de la
|
||||
* première « ## mot.… », un gabarit par ligne « - … ». */
|
||||
export function parsePhrases(text: string): MasterPhrases {
|
||||
const phrases = new Map<string, string[]>();
|
||||
let key: string | null = null;
|
||||
let started = false;
|
||||
for (const raw of text.split("\n")) {
|
||||
const line = raw.trim();
|
||||
const heading = /^## (\S+)$/.exec(line);
|
||||
if (heading !== null) {
|
||||
const name = heading[1] as string;
|
||||
started ||= name.startsWith("mot.");
|
||||
key = started ? name : null;
|
||||
if (key !== null) {
|
||||
phrases.set(key, []);
|
||||
}
|
||||
} else if (key !== null && line.startsWith("- ")) {
|
||||
phrases.get(key)?.push(line.slice(2).trim());
|
||||
}
|
||||
}
|
||||
return phrases;
|
||||
}
|
||||
|
||||
/** Les clés que la source doit contenir (vérifié par les tests). */
|
||||
export const PHRASE_KEYS: readonly string[] = [
|
||||
"mot.coup.bon",
|
||||
"mot.videau.bon",
|
||||
"mot.douteux",
|
||||
"mot.mauvais",
|
||||
"mot.tres-mauvais",
|
||||
"coup.meilleur",
|
||||
"coup.seul",
|
||||
"coup.proche",
|
||||
"coup.douteux",
|
||||
"coup.mauvais",
|
||||
"coup.tres-mauvais",
|
||||
"coup.loin",
|
||||
"fait.frappe",
|
||||
"fait.frappe-plusieurs",
|
||||
"fait.point",
|
||||
"fait.frappe-et-point",
|
||||
"fait.maitre-frappe",
|
||||
"fait.maitre-point",
|
||||
"fait.joueur-frappe",
|
||||
...["bon", "proche", "autre"].flatMap((group) =>
|
||||
["double", "pas-de-double", "prendre", "passer"].map((a) => `videau.${group}.${a}`),
|
||||
),
|
||||
"titre.coup.meme",
|
||||
"titre.coup.autre",
|
||||
"titre.videau.meme",
|
||||
...["double", "pas-de-double", "prendre", "passer"].map((a) => `titre.videau.${a}`),
|
||||
"contexte.coup",
|
||||
"contexte.coup.ouverture",
|
||||
"contexte.videau.double",
|
||||
"contexte.videau.pas-de-double",
|
||||
"contexte.videau.reponse",
|
||||
];
|
||||
|
||||
/** Ce que le maître dit d'un conseil du moteur. */
|
||||
export function masterWords(phrases: MasterPhrases, advice: Advice): MasterWords {
|
||||
return advice.kind === "move" ? moveWords(phrases, advice) : cubeWords(phrases, advice);
|
||||
}
|
||||
|
||||
/** La ligne du maître telle qu'elle s'affiche : « Erreur. J'aurais… ». */
|
||||
export function masterLine(words: MasterWords): string {
|
||||
return `${words.word}. ${words.phrase}`;
|
||||
}
|
||||
|
||||
const ACTION_KEYS: Readonly<Record<CubeAction, string>> = {
|
||||
double: "double",
|
||||
noDouble: "pas-de-double",
|
||||
take: "prendre",
|
||||
pass: "passer",
|
||||
};
|
||||
|
||||
function wordKey(verdict: Verdict, kind: Advice["kind"]): string {
|
||||
switch (verdict) {
|
||||
case "good":
|
||||
return kind === "move" ? "mot.coup.bon" : "mot.videau.bon";
|
||||
case "doubtful":
|
||||
return "mot.douteux";
|
||||
case "bad":
|
||||
return "mot.mauvais";
|
||||
case "veryBad":
|
||||
return "mot.tres-mauvais";
|
||||
}
|
||||
}
|
||||
|
||||
function moveWords(phrases: MasterPhrases, advice: MoveAdvice): MasterWords {
|
||||
const verdict = verdictOf(advice.skill);
|
||||
const word = pick(phrases, wordKey(verdict, "move"), advice.eventId);
|
||||
const values = {
|
||||
best: advice.best.notation,
|
||||
played: advice.played.notation,
|
||||
rank: String(advice.rank),
|
||||
roll: `${advice.roll[0]}-${advice.roll[1]}`,
|
||||
};
|
||||
const fill = (key: string, extra: Readonly<Record<string, string>> = {}) =>
|
||||
fillIn(pick(phrases, key, advice.eventId), { ...values, ...extra });
|
||||
let base: string;
|
||||
let fact: string | null;
|
||||
if (advice.moveCount <= 1) {
|
||||
base = fill("coup.seul");
|
||||
fact = null;
|
||||
} else if (advice.rank === 1) {
|
||||
base = fill("coup.meilleur");
|
||||
fact = selfFact(phrases, advice.eventId, advice.played.notation, advice.playedFacts);
|
||||
} else {
|
||||
// Le rang n'est cité que pour un bon coup non meilleur ou une
|
||||
// imprécision, au 2e ou 3e choix : il mesure mal la gravité d'une
|
||||
// erreur, et le classement est dans les chiffres du moteur.
|
||||
const near = advice.rank <= 3;
|
||||
const key = {
|
||||
good: near ? "coup.proche" : "coup.loin",
|
||||
doubtful: near ? "coup.douteux" : "coup.loin",
|
||||
bad: "coup.mauvais",
|
||||
veryBad: "coup.tres-mauvais",
|
||||
}[verdict];
|
||||
base = fill(key);
|
||||
fact = differenceFact(phrases, advice);
|
||||
}
|
||||
const withFact = fact === null ? base : `${base} ${fact}`;
|
||||
const phrase = `${word}. ${withFact}`.length < LINE_LIMIT ? withFact : base;
|
||||
return {
|
||||
verdict,
|
||||
word,
|
||||
phrase,
|
||||
title: fill(advice.rank === 1 ? "titre.coup.meme" : "titre.coup.autre"),
|
||||
context: fill(advice.opening ? "contexte.coup.ouverture" : "contexte.coup"),
|
||||
};
|
||||
}
|
||||
|
||||
/** Ce que fait le coup lui-même : pions frappés, points faits. */
|
||||
function selfFact(
|
||||
phrases: MasterPhrases,
|
||||
id: string,
|
||||
move: string,
|
||||
facts: MoveFacts,
|
||||
): string | null {
|
||||
const values = { move, points: pointsText(facts.madePoints), hits: String(facts.hits) };
|
||||
const made = facts.madePoints.length > 0;
|
||||
if (facts.hits === 1 && made) {
|
||||
return fillIn(pick(phrases, "fait.frappe-et-point", id), values);
|
||||
}
|
||||
if (facts.hits > 1) {
|
||||
return fillIn(pick(phrases, "fait.frappe-plusieurs", id), values);
|
||||
}
|
||||
if (facts.hits === 1) {
|
||||
return fillIn(pick(phrases, "fait.frappe", id), values);
|
||||
}
|
||||
return made ? fillIn(pick(phrases, "fait.point", id), values) : null;
|
||||
}
|
||||
|
||||
/** Un fait qui distingue le coup du maître du coup joué, s'il y en a un. */
|
||||
function differenceFact(phrases: MasterPhrases, advice: MoveAdvice): string | null {
|
||||
const best = advice.bestFacts;
|
||||
const played = advice.playedFacts;
|
||||
if (best.hits > 0 && played.hits === 0) {
|
||||
return pick(phrases, "fait.maitre-frappe", advice.eventId);
|
||||
}
|
||||
const onlyBest = best.madePoints.filter((p) => !played.madePoints.includes(p));
|
||||
if (onlyBest.length > 0) {
|
||||
return fillIn(pick(phrases, "fait.maitre-point", advice.eventId), {
|
||||
points: pointsText(onlyBest),
|
||||
});
|
||||
}
|
||||
if (played.hits > 0 && best.hits === 0) {
|
||||
return pick(phrases, "fait.joueur-frappe", advice.eventId);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function cubeWords(phrases: MasterPhrases, advice: CubeAdvice): MasterWords {
|
||||
const verdict = verdictOf(advice.skill);
|
||||
const master = masterCubeAction(advice);
|
||||
const same = master === advice.action;
|
||||
const group = verdict !== "good" ? "autre" : same ? "bon" : "proche";
|
||||
const shown = group === "bon" ? advice.action : master;
|
||||
const context =
|
||||
advice.action === "take" || advice.action === "pass"
|
||||
? "contexte.videau.reponse"
|
||||
: `contexte.videau.${ACTION_KEYS[advice.action]}`;
|
||||
return {
|
||||
verdict,
|
||||
word: pick(phrases, wordKey(verdict, "cube"), advice.eventId),
|
||||
phrase: pick(phrases, `videau.${group}.${ACTION_KEYS[shown]}`, advice.eventId),
|
||||
title: pick(
|
||||
phrases,
|
||||
same ? "titre.videau.meme" : `titre.videau.${ACTION_KEYS[master]}`,
|
||||
advice.eventId,
|
||||
),
|
||||
context: pick(phrases, context, advice.eventId),
|
||||
};
|
||||
}
|
||||
|
||||
/** « votre point 5 », « vos points 5 et 7 », « vos points 4, 5 et 7 ». */
|
||||
function pointsText(points: readonly number[]): string {
|
||||
const sorted = [...points].sort((a, b) => a - b).map(String);
|
||||
if (sorted.length <= 1) {
|
||||
return `votre point ${sorted[0] ?? ""}`;
|
||||
}
|
||||
return `vos points ${sorted.slice(0, -1).join(", ")} et ${sorted.at(-1)}`;
|
||||
}
|
||||
|
||||
/** Un gabarit de la section key ; plusieurs tournent selon l'identifiant
|
||||
* de l'événement. Erreur si la section manque. */
|
||||
function pick(phrases: MasterPhrases, key: string, id: string): string {
|
||||
const choices = phrases.get(key) ?? [];
|
||||
if (choices.length === 0) {
|
||||
throw new Error(`phrases du maître : section « ${key} » absente`);
|
||||
}
|
||||
const n = Number.parseInt(id, 10);
|
||||
// Les identifiants des coups du joueur vont souvent de quatre en quatre :
|
||||
// un brassage (hachage de Knuth, bits de poids fort) les fait tourner.
|
||||
const index = (Math.imul(Number.isNaN(n) ? 0 : n, 2654435761) >>> 16) % choices.length;
|
||||
return choices[index] as string;
|
||||
}
|
||||
|
||||
/** Remplace les {repères} ; erreur sur un repère inconnu. */
|
||||
function fillIn(template: string, values: Readonly<Record<string, string>>): string {
|
||||
return template.replace(/\{([a-z]+)\}/g, (_, name: string) => {
|
||||
const value = values[name];
|
||||
if (value === undefined) {
|
||||
throw new Error(`phrases du maître : repère {${name}} inconnu`);
|
||||
}
|
||||
return value;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
// Le conseil du maître sur une décision du joueur : ce que le moteur en dit,
|
||||
// rattaché à l'événement de la décision dans le journal (étape 1d).
|
||||
//
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
//
|
||||
// Ce fichier ne contient que des données : tout ce qu'elles disent vient du
|
||||
// moteur (analyse du coup, classement des coups, analyse de la décision de
|
||||
// videau, plateaux décodés). Le calcul est dans domain/analysis/ ; le
|
||||
// stockage, dans le journal (events.ts, champ advice). Le format est
|
||||
// durable (ADR 0007) : chaque conseil porte sa version (ADVICE_VERSION).
|
||||
|
||||
import type { CubeAction, CubeEquities, Ply, Skill } from "./engine.js";
|
||||
import type { HalfMove, PositionId } from "./position.js";
|
||||
import type { Roll } from "./roll.js";
|
||||
|
||||
/** Version du format d'un conseil enregistré. Un conseil d'une autre
|
||||
* version est ignoré à la reprise (il peut être recalculé). */
|
||||
export const ADVICE_VERSION = 1;
|
||||
|
||||
/**
|
||||
* Le conseil du maître sur une décision du joueur : un coup ou une décision
|
||||
* de videau, avec l'appréciation du moteur.
|
||||
*
|
||||
* Glossaire : docs/glossaire/conseil-du-maitre.md
|
||||
*/
|
||||
export type Advice = MoveAdvice | CubeAdvice;
|
||||
|
||||
/** Un coup, tel que le conseil le retient. */
|
||||
export interface AdvisedMove {
|
||||
/** Notation de gnubg, par exemple « 8/5 6/5 ». */
|
||||
readonly notation: string;
|
||||
readonly halfMoves: readonly HalfMove[];
|
||||
/** Équité (avec videau) du point de vue du joueur, et le niveau auquel
|
||||
* le moteur l'a évaluée. */
|
||||
readonly equity: number;
|
||||
readonly ply: Ply;
|
||||
}
|
||||
|
||||
/** Ce que l'on lit sur les plateaux décodés par le moteur, avant et après
|
||||
* un coup : de quoi écrire une phrase sans rien inventer. */
|
||||
export interface MoveFacts {
|
||||
/** Pions adverses envoyés sur la barre. */
|
||||
readonly hits: number;
|
||||
/** Cases (numérotation du joueur) où il n'avait pas deux pions avant
|
||||
* le coup et en a deux ou plus après : les points qu'il fait. */
|
||||
readonly madePoints: readonly number[];
|
||||
}
|
||||
|
||||
export interface MoveAdvice {
|
||||
readonly version: number;
|
||||
readonly kind: "move";
|
||||
/** L'événement MovePlayed du coup. */
|
||||
readonly eventId: string;
|
||||
/** Niveau de l'analyse, en plis. */
|
||||
readonly ply: Ply;
|
||||
/** La position avant le coup, vue du joueur, et son jet. */
|
||||
readonly position: PositionId;
|
||||
readonly roll: Roll;
|
||||
/** Le premier coup de la partie (après le jet d'ouverture). */
|
||||
readonly opening: boolean;
|
||||
/** Nombre de coups légaux et rang du coup joué (à partir de 1). */
|
||||
readonly moveCount: number;
|
||||
readonly rank: number;
|
||||
readonly played: AdvisedMove;
|
||||
readonly best: AdvisedMove;
|
||||
readonly playedFacts: MoveFacts;
|
||||
readonly bestFacts: MoveFacts;
|
||||
/** Les meilleurs coups du classement du moteur, du meilleur au moins
|
||||
* bon (cinq au plus). */
|
||||
readonly ranking: readonly AdvisedMove[];
|
||||
/** Équité du coup joué moins celle du meilleur : 0 ou négative. */
|
||||
readonly equityLoss: number;
|
||||
readonly skill: Skill;
|
||||
}
|
||||
|
||||
export interface CubeAdvice {
|
||||
readonly version: number;
|
||||
readonly kind: "cube";
|
||||
/** L'événement de la décision : DiceRolled (le joueur lance sans
|
||||
* doubler), CubeOffered (il double), CubeTaken ou CubeDropped (il
|
||||
* répond au double de l'ordinateur). */
|
||||
readonly eventId: string;
|
||||
readonly ply: Ply;
|
||||
/** La décision du joueur. */
|
||||
readonly action: CubeAction;
|
||||
/** La position, vue de celui qui double ou non (l'ordinateur quand le
|
||||
* joueur prend ou passe). */
|
||||
readonly position: PositionId;
|
||||
/** Équités de celui qui double ou non, pour un videau de 1. */
|
||||
readonly equities: CubeEquities;
|
||||
/** Équité perdue par le joueur : 0 ou négative. */
|
||||
readonly equityLoss: number;
|
||||
readonly skill: Skill;
|
||||
}
|
||||
|
||||
/** L'appréciation dite au joueur : celle du moteur, « rien à signaler »
|
||||
* devenant « bon ». */
|
||||
export type Verdict = "good" | "doubtful" | "bad" | "veryBad";
|
||||
|
||||
export function verdictOf(skill: Skill): Verdict {
|
||||
return skill === "none" ? "good" : skill;
|
||||
}
|
||||
|
||||
/** L'autre choix d'une décision de videau. */
|
||||
export function otherCubeAction(action: CubeAction): CubeAction {
|
||||
switch (action) {
|
||||
case "double":
|
||||
return "noDouble";
|
||||
case "noDouble":
|
||||
return "double";
|
||||
case "take":
|
||||
return "pass";
|
||||
case "pass":
|
||||
return "take";
|
||||
}
|
||||
}
|
||||
|
||||
/** La décision que le maître aurait prise : celle du joueur s'il n'a perdu
|
||||
* aucune équité, l'autre sinon (une décision de videau n'a que deux
|
||||
* choix). */
|
||||
export function masterCubeAction(advice: CubeAdvice): CubeAction {
|
||||
return advice.equityLoss < 0 ? otherCubeAction(advice.action) : advice.action;
|
||||
}
|
||||
@@ -80,6 +80,17 @@ export interface Engine {
|
||||
ply: Ply,
|
||||
): Promise<MoveAnalysis | null>;
|
||||
|
||||
/** Les coups légaux d'un jet classés du meilleur au moins bon, au
|
||||
* niveau ply, comme la commande « hint » de gnubg (les coups écartés
|
||||
* par ses filtres gardent l'évaluation d'un niveau inférieur, voir leur
|
||||
* ply). */
|
||||
rankMoves(
|
||||
position: PositionId,
|
||||
cube: EngineCube,
|
||||
roll: Roll,
|
||||
ply: Ply,
|
||||
): Promise<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
|
||||
* adversaire). */
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
// Chaque événement contient tout ce qu'il faut pour rejouer la partie sans
|
||||
// le moteur : l'état se recalcule par une simple boucle (state.ts).
|
||||
|
||||
import { ADVICE_VERSION, type Advice } from "./advice.js";
|
||||
import type { Cube } from "./cube.js";
|
||||
import type { Level, ResultKind } from "./engine.js";
|
||||
import type { Match, MatchAfterGame } from "./match.js";
|
||||
@@ -38,7 +39,8 @@ const VERSION_1_LEVEL: Level = "2ply";
|
||||
* une partie de la version 1 étant une partie libre. Un événement d'une
|
||||
* version inconnue est laissé tel quel (Game.resume le refuse).
|
||||
*/
|
||||
export function migrateJournal(journal: GameJournal): GameJournal {
|
||||
export function migrateJournal(saved: GameJournal): GameJournal {
|
||||
const journal = keepKnownAdvice(saved);
|
||||
if (journal.events.every((event) => event.version === EVENT_VERSION)) {
|
||||
return journal;
|
||||
}
|
||||
@@ -55,7 +57,34 @@ export function migrateJournal(journal: GameJournal): GameJournal {
|
||||
}
|
||||
return Object.freeze({ ...event, version: EVENT_VERSION });
|
||||
});
|
||||
return { events, head: journal.head };
|
||||
return { ...journal, events };
|
||||
}
|
||||
|
||||
/**
|
||||
* Les conseils du maître d'un journal enregistré : seuls restent ceux de la
|
||||
* version courante (ADVICE_VERSION), rattachés à un événement du journal.
|
||||
* Les autres sont oubliés : ce ne sont que des analyses du moteur, qui
|
||||
* peuvent être refaites. Un journal sans conseils (enregistré avant l'étape
|
||||
* 1d) reste tel quel.
|
||||
*/
|
||||
function keepKnownAdvice(journal: GameJournal): GameJournal {
|
||||
const advice = journal.advice;
|
||||
if (advice === undefined) {
|
||||
return journal;
|
||||
}
|
||||
const ids = new Set(journal.events.map((event) => event.id));
|
||||
const kept = Object.entries(advice).filter(
|
||||
([id, a]) =>
|
||||
typeof a === "object" &&
|
||||
a !== null &&
|
||||
a.version === ADVICE_VERSION &&
|
||||
a.eventId === id &&
|
||||
ids.has(id),
|
||||
);
|
||||
if (kept.length === Object.keys(advice).length) {
|
||||
return journal;
|
||||
}
|
||||
return { ...journal, advice: Object.fromEntries(kept) };
|
||||
}
|
||||
|
||||
interface EventBase {
|
||||
@@ -150,10 +179,14 @@ export type NewGameEvent = WithoutBase<GameEvent>;
|
||||
type WithoutBase<E> = E extends unknown ? Omit<E, keyof EventBase> : never;
|
||||
|
||||
/** Tous les événements d'une partie, branches comprises, et l'événement
|
||||
* courant (head) : la partie en est là. */
|
||||
* courant (head) : la partie en est là. advice (étape 1d, facultatif) : le
|
||||
* conseil du maître sur les décisions du joueur, par identifiant de
|
||||
* l'événement de la décision. Ce n'est pas un événement : il arrive après
|
||||
* coup, quand l'analyse du moteur est prête, et ne change pas la partie. */
|
||||
export interface GameJournal {
|
||||
readonly events: readonly GameEvent[];
|
||||
readonly head: string | null;
|
||||
readonly advice?: Readonly<Record<string, Advice>>;
|
||||
}
|
||||
|
||||
export const EMPTY_JOURNAL: GameJournal = { events: [], head: null };
|
||||
@@ -166,7 +199,16 @@ export function appendEvent(journal: GameJournal, event: NewGameEvent): GameJour
|
||||
parent: journal.head,
|
||||
version: EVENT_VERSION,
|
||||
}) as GameEvent;
|
||||
return { events: [...journal.events, added], head: added.id };
|
||||
return { ...journal, events: [...journal.events, added], head: added.id };
|
||||
}
|
||||
|
||||
/** Rattache un conseil du maître à son événement (il remplace un conseil
|
||||
* déjà rattaché). Erreur si l'événement n'est pas dans le journal. */
|
||||
export function withAdvice(journal: GameJournal, advice: Advice): GameJournal {
|
||||
if (!journal.events.some((event) => event.id === advice.eventId)) {
|
||||
throw new Error(`événement introuvable : ${advice.eventId}`);
|
||||
}
|
||||
return { ...journal, advice: { ...journal.advice, [advice.eventId]: advice } };
|
||||
}
|
||||
|
||||
/** Les événements du début jusqu'à l'événement courant, dans l'ordre. */
|
||||
@@ -190,5 +232,5 @@ export function goBackTo(journal: GameJournal, id: string): GameJournal {
|
||||
if (!journal.events.some((event) => event.id === id)) {
|
||||
throw new Error(`événement introuvable : ${id}`);
|
||||
}
|
||||
return { events: journal.events, head: id };
|
||||
return { ...journal, head: id };
|
||||
}
|
||||
|
||||
+45
-20
@@ -9,6 +9,7 @@
|
||||
// l'ordinateur, videau (Crawford compris), fin de partie, points et score du
|
||||
// match.
|
||||
|
||||
import type { Advice } from "./advice.js";
|
||||
import { type Branch, branches, switchToBranch } from "./branches.js";
|
||||
import { CENTERED_CUBE, type Cube } from "./cube.js";
|
||||
import type { BoardSide, Engine, EngineCube, Level, Ply, ResultKind } from "./engine.js";
|
||||
@@ -22,6 +23,7 @@ import {
|
||||
goBackTo,
|
||||
migrateJournal,
|
||||
type NewGameEvent,
|
||||
withAdvice,
|
||||
} from "./events.js";
|
||||
import { firstGameOf, type Match, nextGameOf } from "./match.js";
|
||||
import { BAR, type HalfMove, OFF, type PositionId, START_POSITION } from "./position.js";
|
||||
@@ -227,36 +229,59 @@ export class Game {
|
||||
* (checkHalfMoves, puis board) : aucune règle n'est rejouée ici.
|
||||
*/
|
||||
async lastMoveSteps(): Promise<MoveSteps | null> {
|
||||
const events = this.events;
|
||||
const index = events.findLastIndex((e) => e.type === "MovePlayed");
|
||||
const played = events[index];
|
||||
if (played?.type !== "MovePlayed") {
|
||||
return null;
|
||||
}
|
||||
// L'état juste avant le coup : la position vue de celui qui joue, et
|
||||
// son jet.
|
||||
const before = replay(events.slice(0, index));
|
||||
const roll = rollToPlay(before);
|
||||
const played = this.events.findLast((e) => e.type === "MovePlayed");
|
||||
return played?.type === "MovePlayed"
|
||||
? this.stepsBefore(played.id, played.side, played.halfMoves)
|
||||
: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Un coup de side joué demi-coup par demi-coup à partir de la position
|
||||
* d'avant l'événement eventId (le coup réellement joué, ou un autre coup
|
||||
* du même jet : celui du maître). Les plateaux viennent du moteur ;
|
||||
* erreur s'il refuse un demi-coup. Sans demi-coup, boards ne contient
|
||||
* que la position d'avant l'événement.
|
||||
*/
|
||||
async stepsBefore(
|
||||
eventId: string,
|
||||
side: Side,
|
||||
halfMoves: readonly HalfMove[],
|
||||
): Promise<MoveSteps> {
|
||||
const line = currentLine({ ...this.#journal, head: eventId });
|
||||
// L'état juste avant l'événement : la position vue de celui qui a la
|
||||
// main, et son jet.
|
||||
const before = replay(line.slice(0, -1));
|
||||
const positions = [before.position];
|
||||
for (let i = 1; i <= played.halfMoves.length; i++) {
|
||||
for (let i = 1; i <= halfMoves.length; i++) {
|
||||
const check = await this.#engine.checkHalfMoves(
|
||||
before.position,
|
||||
roll,
|
||||
played.halfMoves.slice(0, i),
|
||||
rollToPlay(before),
|
||||
halfMoves.slice(0, i),
|
||||
);
|
||||
if (check.position === null) {
|
||||
throw new Error("coup enregistré refusé par le moteur");
|
||||
throw new Error("coup refusé par le moteur");
|
||||
}
|
||||
positions.push(check.position);
|
||||
}
|
||||
const boards = await Promise.all(
|
||||
positions.map((p) => this.#sideBoards(p, played.side, before)),
|
||||
);
|
||||
const opponent = otherSide(played.side);
|
||||
const hits = played.halfMoves.map(
|
||||
const onRoll = before.turn ?? side;
|
||||
const boards = await Promise.all(positions.map((p) => this.#sideBoards(p, onRoll, before)));
|
||||
const opponent = otherSide(side);
|
||||
const hits = halfMoves.map(
|
||||
(_, i) => (boards[i + 1]?.[opponent].bar ?? 0) > (boards[i]?.[opponent].bar ?? 0),
|
||||
);
|
||||
return { side: played.side, halfMoves: played.halfMoves, boards, hits };
|
||||
return { side, halfMoves, boards, hits };
|
||||
}
|
||||
|
||||
/** Les conseils du maître déjà rattachés aux décisions du joueur, par
|
||||
* identifiant d'événement. */
|
||||
get advice(): Readonly<Record<string, Advice>> {
|
||||
return this.#journal.advice ?? {};
|
||||
}
|
||||
|
||||
/** Rattache le conseil du maître à sa décision dans le journal (il sera
|
||||
* enregistré avec la partie). Erreur si l'événement n'existe pas. */
|
||||
recordAdvice(advice: Advice): void {
|
||||
this.#journal = withAdvice(this.#journal, advice);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -195,6 +195,14 @@ export function createWasmEngine(run: RunCommand): Engine {
|
||||
};
|
||||
},
|
||||
|
||||
async rankMoves(position: PositionId, cube: EngineCube, roll: Roll, ply: Ply) {
|
||||
const args = ["--ply", String(ply), "moves", position, await matchId(cube)];
|
||||
const answer = (await run([...args, diceText(roll)])) as {
|
||||
moves: CliEvaluatedMove[];
|
||||
};
|
||||
return answer.moves.map(toEvaluatedMove);
|
||||
},
|
||||
|
||||
async analyseCube(
|
||||
position: PositionId,
|
||||
cube: EngineCube,
|
||||
|
||||
+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,409 @@
|
||||
// La feuille d'explication du maître (#/explication/<événement>), qui monte
|
||||
// du bas au-dessus du plateau voilé (charte, 1d « La feuille
|
||||
// d'explication ») : le mot et le contexte, le coup du maître en titre, sa
|
||||
// phrase, « Votre coup » et « Le coup du maître » joués pion par pion sur la
|
||||
// position d'avant, puis les chiffres du moteur, repliés.
|
||||
//
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
|
||||
import { type MasterPhrases, masterWords } from "../domain/analysis/phrases.js";
|
||||
import {
|
||||
type Advice,
|
||||
type CubeAdvice,
|
||||
type MoveAdvice,
|
||||
masterCubeAction,
|
||||
type Verdict,
|
||||
} from "../domain/game/advice.js";
|
||||
import type { Game, MoveSteps } from "../domain/game/game.js";
|
||||
import { ADVICE_TEXTS, CUBE_GLOSSARY, formatEquity } from "./advice-texts.js";
|
||||
import { boardModel, moveTrace, placeOf } from "./board-model.js";
|
||||
import { checkerSpot, NO_MARKS, renderBoard, slideChecker } from "./board-view.js";
|
||||
import { boardGeometry, PORTRAIT_ASPECT } from "./geometry.js";
|
||||
import { type IconName, icon } from "./icons.js";
|
||||
import { routeHash } from "./router.js";
|
||||
|
||||
const SLIDE_MS = 160;
|
||||
const PAUSE_MS = 120;
|
||||
|
||||
/** Le pictogramme d'une appréciation (interaction.md, pictogrammes). */
|
||||
export const VERDICT_ICONS: Readonly<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;
|
||||
// Le titre dit déjà le coup du maître : la phrase ne le répète pas
|
||||
// (et disparaît si elle ne disait que lui).
|
||||
const rest = words.phrase.startsWith(`${words.title}.`)
|
||||
? words.phrase.slice(words.title.length + 1).trim()
|
||||
: words.phrase;
|
||||
if (rest !== "") {
|
||||
element(sheet, "p", { id: "explain-phrase", className: "explain-phrase", text: rest });
|
||||
}
|
||||
|
||||
const body = element(sheet, "div", { className: "explain-body" });
|
||||
const boardBox = element(body, "div", { className: "explain-board-box" });
|
||||
if (advice.kind === "move") {
|
||||
await showMoves(id, game, advice, body, boardBox);
|
||||
} else {
|
||||
await showCube(id, game, advice, boardBox);
|
||||
}
|
||||
if (id !== opened) {
|
||||
return;
|
||||
}
|
||||
numbers(advice);
|
||||
if (advice.kind === "cube") {
|
||||
const entry = CUBE_GLOSSARY[masterCubeAction(advice)];
|
||||
const link = element(sheet, "a", {
|
||||
id: "explain-glossary",
|
||||
className: "link-button explain-glossary",
|
||||
text: ADVICE_TEXTS.glossary(entry.term),
|
||||
});
|
||||
link.href = routeHash({ screen: "glossary", term: entry.file });
|
||||
}
|
||||
closeButton();
|
||||
close.focus();
|
||||
}
|
||||
|
||||
function closeButton(): void {
|
||||
const actions = element(sheet, "div", { className: "explain-actions" });
|
||||
const done = element(actions, "button", {
|
||||
id: "explain-done",
|
||||
className: "secondary",
|
||||
text: ADVICE_TEXTS.close,
|
||||
});
|
||||
done.type = "button";
|
||||
done.onclick = () => options.close();
|
||||
}
|
||||
|
||||
/** Le plateau de la feuille, dans son cadre. */
|
||||
function boardSvg(box: HTMLElement): SVGSVGElement {
|
||||
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
|
||||
svg.id = "explain-board";
|
||||
svg.setAttribute("class", "board explain-board");
|
||||
svg.setAttribute("role", "img");
|
||||
box.append(svg);
|
||||
return svg;
|
||||
}
|
||||
|
||||
function draw(svg: SVGSVGElement, steps: MoveSteps, index: number, traced: boolean): void {
|
||||
const boards = steps.boards[index];
|
||||
if (boards === undefined) {
|
||||
return;
|
||||
}
|
||||
const width = svg.getBoundingClientRect().width || 358;
|
||||
renderBoard(
|
||||
svg,
|
||||
boardGeometry("portrait", PORTRAIT_ASPECT, width / 640),
|
||||
boardModel(boards),
|
||||
{
|
||||
...NO_MARKS,
|
||||
dice: [],
|
||||
cube: null,
|
||||
band: null,
|
||||
controls: null,
|
||||
trace: traced ? moveTrace(steps.side, steps.halfMoves, steps.hits) : null,
|
||||
},
|
||||
);
|
||||
// Les flèches des traces renvoient à leur pointe par identifiant :
|
||||
// celui du plateau de jeu serait pris à sa place.
|
||||
for (const marker of svg.querySelectorAll("marker[id]")) {
|
||||
marker.id = `explain-${marker.id}`;
|
||||
}
|
||||
for (const path of svg.querySelectorAll("[marker-end]")) {
|
||||
const ref = path.getAttribute("marker-end") ?? "";
|
||||
path.setAttribute("marker-end", ref.replace("url(#", "url(#explain-"));
|
||||
}
|
||||
}
|
||||
|
||||
/** Joue un coup pion par pion (sans mouvement si le système le
|
||||
* demande), puis laisse ses traces. */
|
||||
async function animate(svg: SVGSVGElement, steps: MoveSteps): Promise<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") {
|
||||
// Les écarts du classement se lisent par rapport à son premier
|
||||
// coup ; votre coup, s'il n'y figure pas, avec l'écart de
|
||||
// l'analyse du coup joué.
|
||||
const first = advice.ranking[0]?.equity ?? advice.best.equity;
|
||||
const listed = advice.ranking.map((move, i) => ({
|
||||
move,
|
||||
rank: i + 1,
|
||||
loss: move.equity - first,
|
||||
}));
|
||||
if (!advice.ranking.some((m) => m.notation === advice.played.notation)) {
|
||||
listed.push({ move: advice.played, rank: advice.rank, loss: advice.equityLoss });
|
||||
}
|
||||
for (const { move, rank, loss } of listed) {
|
||||
const mine = move.notation === advice.played.notation;
|
||||
row(
|
||||
[
|
||||
String(rank),
|
||||
mine ? `${move.notation} ${ADVICE_TEXTS.you}` : move.notation,
|
||||
formatEquity(loss),
|
||||
],
|
||||
mine,
|
||||
);
|
||||
}
|
||||
element(details, "p", {
|
||||
className: "explain-note",
|
||||
text: ADVICE_TEXTS.moveNote(advice.ply),
|
||||
});
|
||||
} else {
|
||||
// Les lignes de votre décision, en gras : doubler mène à l'une des
|
||||
// deux lignes « Double », selon la réponse de l'ordinateur.
|
||||
const mine = {
|
||||
noDouble: ["noDouble"],
|
||||
double: ["doubleTake", "doublePass"],
|
||||
take: ["doubleTake"],
|
||||
pass: ["doublePass"],
|
||||
}[advice.action];
|
||||
for (const key of ["noDouble", "doubleTake", "doublePass"] as const) {
|
||||
const bold = mine.includes(key);
|
||||
const name = ADVICE_TEXTS.cubeRows[key];
|
||||
row(
|
||||
[
|
||||
"",
|
||||
bold && mine.length === 1 ? `${name} ${ADVICE_TEXTS.you}` : name,
|
||||
formatEquity(advice.equities[key]),
|
||||
],
|
||||
bold,
|
||||
);
|
||||
}
|
||||
element(details, "p", {
|
||||
className: "explain-note",
|
||||
text: `${ADVICE_TEXTS.cubeNote(advice.ply)}${formatEquity(advice.equityLoss)}.`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
show,
|
||||
hide() {
|
||||
opened++;
|
||||
playing++;
|
||||
if (!veil.hidden) {
|
||||
veil.hidden = true;
|
||||
sheet.replaceChildren();
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
+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"),
|
||||
@@ -341,6 +361,8 @@ export function startApp(options: AppOptions): void {
|
||||
label(ui.menuHelp, TEXTS.helpAndGlossary, "help");
|
||||
ui.menuTheme.prepend(icon("theme"));
|
||||
child(ui.menuTheme, ".item-label").textContent = TEXTS.theme;
|
||||
ui.menuAdvice.prepend(icon("verdictGood"));
|
||||
child(ui.menuAdvice, ".item-label").textContent = ADVICE_TEXTS.menuSwitch;
|
||||
ui.menuVibrations.prepend(icon("vibrate"));
|
||||
child(ui.menuVibrations, ".item-label").textContent = TEXTS.vibrations;
|
||||
ui.menuScreenOn.prepend(icon("screenOn"));
|
||||
@@ -434,6 +456,36 @@ export function startApp(options: AppOptions): void {
|
||||
// Les parties jouées et gagnées, lues dans « Mes parties » pour
|
||||
// l'accueil.
|
||||
let homeProgress: { played: number; won: number } | null = null;
|
||||
// Le conseil du maître : ses phrases (lues une fois), les analyses en
|
||||
// cours et la ligne à montrer.
|
||||
let phrases: MasterPhrases | null = null;
|
||||
const phrasesLoading: Promise<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)");
|
||||
@@ -543,6 +595,7 @@ export function startApp(options: AppOptions): void {
|
||||
: bandText(text.short, state.match?.crawford === true)
|
||||
: null,
|
||||
controls: landscape ? controls() : null,
|
||||
advice: landscape && !panelShown ? boardAdvice(shownAdvice()) : null,
|
||||
});
|
||||
}
|
||||
renderStrips(model.pipCount, state.phase === "over" ? null : state.turn);
|
||||
@@ -550,6 +603,8 @@ export function startApp(options: AppOptions): void {
|
||||
|
||||
// Ligne d'information : un jet sans coup possible reste annoncé
|
||||
// jusqu'à l'action suivante, même après un rechargement de la page.
|
||||
const advice = shownAdvice();
|
||||
renderAdviceLine(notice === null && !stuck ? advice : null);
|
||||
if (notice !== null) {
|
||||
ui.info.textContent = notice;
|
||||
} else if (stuck && state.roll !== null) {
|
||||
@@ -612,6 +667,69 @@ export function startApp(options: AppOptions): void {
|
||||
void keepScreenOn(isPlaying(game) || state.phase === "doubled");
|
||||
}
|
||||
|
||||
/** Le conseil du maître à montrer : le réglage le permet, ses phrases
|
||||
* sont lues, et l'analyse attendue est prête. */
|
||||
function shownAdvice(): {
|
||||
advice: Advice;
|
||||
line: string;
|
||||
words: ReturnType<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 {
|
||||
@@ -758,6 +876,7 @@ export function startApp(options: AppOptions): void {
|
||||
);
|
||||
child(ui.menuTheme, ".item-value").textContent = TEXTS.themes[settings.theme];
|
||||
ui.menuVibrations.setAttribute("aria-checked", String(settings.vibrations));
|
||||
ui.menuAdvice.setAttribute("aria-checked", String(settings.masterAdvice));
|
||||
ui.menuScreenOn.setAttribute("aria-checked", String(settings.keepScreenOn));
|
||||
}
|
||||
|
||||
@@ -973,11 +1092,19 @@ export function startApp(options: AppOptions): void {
|
||||
bubbleSide = null;
|
||||
notice = null;
|
||||
ui.bubble.hidden = true;
|
||||
// L'action suivante du joueur efface la ligne du maître.
|
||||
adviceTracker.clear();
|
||||
render();
|
||||
const before = game;
|
||||
const lineBefore = game.events;
|
||||
const wasOver = game.state.phase === "over";
|
||||
try {
|
||||
await action(game);
|
||||
// Ses décisions partent à l'analyse, sans être attendues : le
|
||||
// tour de l'ordinateur suit tout de suite.
|
||||
if (game === before) {
|
||||
adviceTracker.track(game, lineBefore);
|
||||
}
|
||||
save();
|
||||
await computerTurns();
|
||||
// Une partie vient de se terminer (pas une partie finie
|
||||
@@ -1082,7 +1209,10 @@ export function startApp(options: AppOptions): void {
|
||||
// L'adresse de la page dit quel écran montrer (applyRoute, plus bas).
|
||||
const navigation = startNavigation((route) => applyRoute(route));
|
||||
const isSheet = (route: Route) =>
|
||||
route.screen === "menu" || route.screen === "games" || route.screen === "install";
|
||||
route.screen === "menu" ||
|
||||
route.screen === "games" ||
|
||||
route.screen === "install" ||
|
||||
route.screen === "explanation";
|
||||
|
||||
// « Mes parties » : reprendre une partie (ou une branche) en change à
|
||||
// l'écran, sans recharger la page.
|
||||
@@ -1106,6 +1236,15 @@ export function startApp(options: AppOptions): void {
|
||||
|
||||
const helpView = createHelpView(ui.helpContent, ui.helpTabs);
|
||||
|
||||
// La feuille d'explication du maître (#/explication/<événement>).
|
||||
const adviceView = createAdviceView({
|
||||
veil: ui.explainVeil,
|
||||
game: () => game,
|
||||
phrases: () => phrasesLoading,
|
||||
reducedMotion,
|
||||
close: () => leaveSheet(),
|
||||
});
|
||||
|
||||
/** Joue un demi-coup du joueur ; le coup attend ensuite sa suite ou sa
|
||||
* validation. Le pion qui se pose vibre (deux fois s'il frappe). */
|
||||
function playHalfMove(halfMove: HalfMove): void {
|
||||
@@ -1275,6 +1414,7 @@ export function startApp(options: AppOptions): void {
|
||||
ui.menuVeil.hidden = true;
|
||||
ui.installVeil.hidden = true;
|
||||
gamesView?.hide();
|
||||
adviceView.hide();
|
||||
if (menuWasOpen) {
|
||||
(menuReturnFocus ?? ui.menuButton).focus();
|
||||
}
|
||||
@@ -1345,6 +1485,7 @@ export function startApp(options: AppOptions): void {
|
||||
ui.menuFullscreen.onclick = actions.fullscreen;
|
||||
ui.menuTheme.onclick = () => saveSettings({ theme: nextTheme(settings.theme) });
|
||||
ui.menuVibrations.onclick = () => saveSettings({ vibrations: !settings.vibrations });
|
||||
ui.menuAdvice.onclick = () => saveSettings({ masterAdvice: !settings.masterAdvice });
|
||||
ui.menuScreenOn.onclick = () => saveSettings({ keepScreenOn: !settings.keepScreenOn });
|
||||
ui.fullscreenButton.onclick = actions.fullscreen;
|
||||
// « Retour » : à l'écran d'avant l'aide (la partie, si l'aide a été
|
||||
@@ -1441,6 +1582,15 @@ export function startApp(options: AppOptions): void {
|
||||
(menuReturnFocus ?? ui.menuButton).focus();
|
||||
}
|
||||
}
|
||||
// La feuille d'explication du maître, sur une décision de la partie
|
||||
// à l'écran.
|
||||
if (route.screen === "explanation") {
|
||||
closeTransient();
|
||||
ui.menuVeil.hidden = true;
|
||||
void adviceView.show(route.event);
|
||||
} else {
|
||||
adviceView.hide();
|
||||
}
|
||||
if (route.screen === "games") {
|
||||
const expanded = route.game;
|
||||
// La liste vient du stockage, ouvert avec le moteur.
|
||||
@@ -1953,6 +2103,10 @@ export function startApp(options: AppOptions): void {
|
||||
if (ui.game.hidden && name !== null && !["close", "help", "menu"].includes(name)) {
|
||||
name = null;
|
||||
}
|
||||
// La feuille d'explication ouverte : le jeu attend qu'on la ferme.
|
||||
if (!ui.explainVeil.hidden && name !== null && name !== "close") {
|
||||
name = null;
|
||||
}
|
||||
if (name !== null) {
|
||||
event.preventDefault();
|
||||
actions[name]();
|
||||
@@ -1983,6 +2137,12 @@ export function startApp(options: AppOptions): void {
|
||||
announcedEnd = game.journal.head;
|
||||
}
|
||||
}
|
||||
// Ouverte par son adresse avant que la partie soit là : la
|
||||
// feuille d'explication la retrouve maintenant.
|
||||
const route = navigation.route;
|
||||
if (route.screen === "explanation") {
|
||||
void adviceView.show(route.event);
|
||||
}
|
||||
// Comme après une action du joueur : l'ordinateur joue si c'est
|
||||
// à lui (partie nouvelle ou reprise pendant son tour). Une partie
|
||||
// reprise juste après son coup le montre, sans l'animer.
|
||||
|
||||
@@ -83,6 +83,21 @@ export interface BoardDecor {
|
||||
readonly band: string | null;
|
||||
/** Paysage : la colonne de commandes (null : pas de colonne). */
|
||||
readonly controls: BoardControls | null;
|
||||
/** Paysage : le conseil du maître, une pastille avant la consigne dans
|
||||
* la bande centrale (absent ou null : rien). */
|
||||
readonly advice?: BoardAdvice | null;
|
||||
}
|
||||
|
||||
/** Le conseil du maître en paysage : son mot, sur une pastille qui mène à
|
||||
* la feuille d'explication. */
|
||||
export interface BoardAdvice {
|
||||
readonly href: string;
|
||||
/** Appréciation : la couleur et le pictogramme. */
|
||||
readonly verdict: "good" | "doubtful" | "bad" | "veryBad";
|
||||
readonly icon: IconName;
|
||||
readonly word: string;
|
||||
/** Ce que lit le lecteur d'écran : le mot, la phrase, l'invitation. */
|
||||
readonly label: string;
|
||||
}
|
||||
|
||||
/** Ce qui ne s'ajoute pas aux pions pendant l'animation d'un coup. */
|
||||
@@ -188,8 +203,10 @@ export function renderBoard(
|
||||
}
|
||||
svg.append(pieces);
|
||||
drawLandings(svg, g, board, decor.targets);
|
||||
const advice = decor.band !== null ? (decor.advice ?? null) : null;
|
||||
const chip = advice === null ? 0 : adviceChipWidth(g, advice) + 8;
|
||||
if (decor.band !== null) {
|
||||
drawBand(svg, g, decor.band);
|
||||
drawBand(svg, g, decor.band, chip);
|
||||
}
|
||||
drawDice(svg, g, decor.dice);
|
||||
const cube = decor.cube === null ? null : drawCube(svg, g, decor.cube);
|
||||
@@ -215,6 +232,9 @@ export function renderBoard(
|
||||
if (decor.controls !== null) {
|
||||
drawControls(svg, g, hits, decor.controls);
|
||||
}
|
||||
if (advice !== null) {
|
||||
drawAdviceChip(svg, g, hits, advice);
|
||||
}
|
||||
// Les zones à toucher passent devant tout le reste.
|
||||
svg.append(hits);
|
||||
}
|
||||
@@ -496,12 +516,13 @@ function addRing(
|
||||
}
|
||||
|
||||
/** Paysage : la consigne courte, dans la moitié gauche de la bande
|
||||
* centrale, sur une pastille accent-soft. */
|
||||
function drawBand(svg: SVGSVGElement, g: Geometry, text: string): void {
|
||||
* centrale, sur une pastille accent-soft ; elle laisse la place before à
|
||||
* gauche (la pastille du conseil du maître). */
|
||||
function drawBand(svg: SVGSVGElement, g: Geometry, text: string, before = 0): void {
|
||||
const middle = g.height / 2;
|
||||
const width = 6 * g.pointWidth - 24;
|
||||
const width = 6 * g.pointWidth - 24 - before;
|
||||
const height = Math.max(44, g.bandFont + 20);
|
||||
const x = g.x0 + 12;
|
||||
const x = g.x0 + 12 + before;
|
||||
// Une consigne longue (« Partie Crawford · … ») tient dans la pastille :
|
||||
// la police se resserre, sans descendre sous 13 px (charte). Largeur
|
||||
// moyenne d'un caractère d'Alegreya Sans en gras : un peu moins de
|
||||
@@ -529,6 +550,63 @@ function drawBand(svg: SVGSVGElement, g: Geometry, text: string): void {
|
||||
);
|
||||
}
|
||||
|
||||
/** Largeur de la pastille du conseil : pictogramme, mot (estimé à 0,55 em
|
||||
* par lettre, police Alegreya Sans) et marges. */
|
||||
function adviceChipWidth(g: Geometry, advice: BoardAdvice): number {
|
||||
return 16 + g.bandFont + 6 + advice.word.length * g.bandFont * 0.55 + 16;
|
||||
}
|
||||
|
||||
/**
|
||||
* Paysage : le conseil du maître, une pastille au début de la bande
|
||||
* centrale (pictogramme et mot en couleur d'appréciation), lien vers la
|
||||
* feuille d'explication (charte 1d, adaptée au paysage : la phrase est dans
|
||||
* la feuille). Posée parmi les zones à toucher, devant celle des dés.
|
||||
*/
|
||||
function drawAdviceChip(svg: SVGSVGElement, g: Geometry, hits: Element, advice: BoardAdvice): void {
|
||||
const middle = g.height / 2;
|
||||
const height = Math.max(44, g.bandFont + 20);
|
||||
const width = adviceChipWidth(g, advice);
|
||||
const x = g.x0 + 12;
|
||||
const group = add(svg, "g", { class: `advice-chip verdict-${advice.verdict}` });
|
||||
add(group, "rect", {
|
||||
class: "advice-chip-pill",
|
||||
x,
|
||||
y: middle - height / 2,
|
||||
width,
|
||||
height,
|
||||
rx: height / 2,
|
||||
});
|
||||
const size = g.bandFont;
|
||||
const icon = add(group, "g", {
|
||||
transform: `translate(${x + 16},${middle - size / 2}) scale(${size / 24})`,
|
||||
});
|
||||
add(icon, "path", { class: "advice-chip-icon", d: ICON_PATHS[advice.icon] });
|
||||
add(
|
||||
group,
|
||||
"text",
|
||||
{
|
||||
class: "advice-chip-text",
|
||||
x: x + 16 + size + 6,
|
||||
y: middle + g.bandFont * 0.35,
|
||||
"font-size": g.bandFont,
|
||||
},
|
||||
advice.word,
|
||||
);
|
||||
const link = add(hits, "a", {
|
||||
href: advice.href,
|
||||
id: "advice-chip",
|
||||
role: "link",
|
||||
"aria-label": advice.label,
|
||||
});
|
||||
add(link, "rect", {
|
||||
class: "hit action",
|
||||
x,
|
||||
y: middle - Math.max(height, g.target) / 2,
|
||||
width,
|
||||
height: Math.max(height, g.target),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Paysage : le score du match, dans une case sous le menu, dans l'ordre des
|
||||
* camps à l'écran : l'ordinateur en haut, « sur N », vous en bas
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
@@ -0,0 +1,273 @@
|
||||
// Le conseil du maître (étape 1d) : analyses du moteur sur des décisions
|
||||
// connues, phrases rendues sur ces analyses, conseil rattaché au journal et
|
||||
// gardé à l'enregistrement.
|
||||
//
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
import {
|
||||
LINE_LIMIT,
|
||||
masterLine,
|
||||
masterWords,
|
||||
PHRASE_KEYS,
|
||||
} from "../src/domain/analysis/phrases.js";
|
||||
import { ADVICE_VERSION, type Advice, type MoveAdvice } from "../src/domain/game/advice.js";
|
||||
import { Game } from "../src/domain/game/game.js";
|
||||
import { createGameLibrary } from "../src/storage/game-library.js";
|
||||
import { createKeyValueBackend } from "../src/storage/key-value-backend.js";
|
||||
import { memoryStorage } from "../src/storage/open-library.js";
|
||||
import {
|
||||
adviceOn,
|
||||
loadPhrases,
|
||||
playerAnswers,
|
||||
playerDoubles,
|
||||
playerRollsWithoutDoubling,
|
||||
playOpening,
|
||||
} from "./support/advice.js";
|
||||
import { loadNodeEngine } from "./support/node-engine.js";
|
||||
|
||||
const phrases = loadPhrases();
|
||||
|
||||
test("la source des phrases a toutes ses sections, sans point d'exclamation", () => {
|
||||
for (const key of PHRASE_KEYS) {
|
||||
assert.ok((phrases.get(key)?.length ?? 0) > 0, `section ${key} absente ou vide`);
|
||||
}
|
||||
for (const [key, templates] of phrases) {
|
||||
assert.ok(PHRASE_KEYS.includes(key), `section ${key} inconnue`);
|
||||
for (const template of templates) {
|
||||
assert.ok(!template.includes("!"), `« ! » dans ${key} : ${template}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Des décisions réelles, analysées par le moteur à 2 plis. Les coups
|
||||
// d'ouverture et leurs écarts viennent du classement de gnubg.
|
||||
const cases: { name: string; play: () => Promise<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"),
|
||||
),
|
||||
word: "Bon coup",
|
||||
},
|
||||
{
|
||||
name: "2-1 joué 13/11 6/5 : tout près du meilleur",
|
||||
play: async () =>
|
||||
adviceOn(
|
||||
await loadNodeEngine(),
|
||||
await playOpening(await loadNodeEngine(), [2, 1], "13/11 6/5"),
|
||||
),
|
||||
word: "Bon coup",
|
||||
},
|
||||
{
|
||||
name: "2-1 joué 13/10 : imprécision",
|
||||
play: async () =>
|
||||
adviceOn(
|
||||
await loadNodeEngine(),
|
||||
await playOpening(await loadNodeEngine(), [2, 1], "13/11 11/10"),
|
||||
),
|
||||
word: "Imprécision",
|
||||
},
|
||||
{
|
||||
name: "2-1 joué 24/22 6/5 : erreur",
|
||||
play: async () =>
|
||||
adviceOn(
|
||||
await loadNodeEngine(),
|
||||
await playOpening(await loadNodeEngine(), [2, 1], "24/22 6/5"),
|
||||
),
|
||||
word: "Erreur",
|
||||
},
|
||||
{
|
||||
name: "3-1 joué 24/23 13/10 : grosse erreur",
|
||||
play: async () =>
|
||||
adviceOn(
|
||||
await loadNodeEngine(),
|
||||
await playOpening(await loadNodeEngine(), [3, 1], "24/23 13/10"),
|
||||
),
|
||||
word: "Grosse erreur",
|
||||
},
|
||||
{
|
||||
name: "double dans une position de double et prise",
|
||||
play: async () =>
|
||||
adviceOn(await loadNodeEngine(), await playerDoubles(await loadNodeEngine())),
|
||||
word: "Bonne décision",
|
||||
},
|
||||
{
|
||||
name: "lancer sans doubler dans une position de double",
|
||||
play: async () =>
|
||||
adviceOn(
|
||||
await loadNodeEngine(),
|
||||
await playerRollsWithoutDoubling(await loadNodeEngine()),
|
||||
),
|
||||
word: "Erreur",
|
||||
},
|
||||
{
|
||||
name: "prendre un double juste",
|
||||
play: async () =>
|
||||
adviceOn(await loadNodeEngine(), await playerAnswers(await loadNodeEngine(), true)),
|
||||
word: "Bonne décision",
|
||||
},
|
||||
{
|
||||
name: "passer un double qu'il fallait prendre",
|
||||
play: async () =>
|
||||
adviceOn(await loadNodeEngine(), await playerAnswers(await loadNodeEngine(), false)),
|
||||
word: "Erreur",
|
||||
},
|
||||
];
|
||||
|
||||
const analysed: Advice[] = [];
|
||||
|
||||
for (const c of cases) {
|
||||
test(`conseil : ${c.name}`, async () => {
|
||||
const advice = await c.play();
|
||||
analysed.push(advice);
|
||||
const words = masterWords(phrases, advice);
|
||||
assert.equal(words.word, c.word, JSON.stringify(advice));
|
||||
// Chaque gabarit tourne avec l'identifiant de l'événement : la ligne
|
||||
// est rendue pour plusieurs identifiants.
|
||||
for (let id = 1; id <= 40; id++) {
|
||||
const line = masterLine(masterWords(phrases, { ...advice, eventId: String(id) }));
|
||||
assert.ok(line.length < LINE_LIMIT, `trop long (${line.length}) : ${line}`);
|
||||
assert.ok(!line.includes("!"), line);
|
||||
assert.ok(!/[{}]/.test(line), `repère non rempli : ${line}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test("le coup du maître est nommé, et le fait qui distingue les coups vient du plateau", async () => {
|
||||
const engine = await loadNodeEngine();
|
||||
const best = (await adviceOn(
|
||||
engine,
|
||||
await playOpening(engine, [3, 1], "8/5 6/5"),
|
||||
)) as MoveAdvice;
|
||||
assert.equal(best.kind, "move");
|
||||
assert.equal(best.version, ADVICE_VERSION);
|
||||
assert.equal(best.rank, 1);
|
||||
assert.equal(best.opening, true);
|
||||
assert.deepEqual(best.playedFacts, { hits: 0, madePoints: [5] });
|
||||
assert.equal(best.ranking[0]?.notation, "8/5 6/5");
|
||||
const words = masterWords(phrases, best);
|
||||
assert.equal(words.title, "C'est aussi le coup que j'aurais joué");
|
||||
assert.equal(words.context, "votre 3-1 d'ouverture");
|
||||
|
||||
const worst = (await adviceOn(
|
||||
engine,
|
||||
await playOpening(engine, [3, 1], "24/23 13/10"),
|
||||
)) as MoveAdvice;
|
||||
assert.equal(worst.skill, "veryBad");
|
||||
assert.equal(worst.best.notation, "8/5 6/5");
|
||||
assert.deepEqual(worst.bestFacts.madePoints, [5]);
|
||||
const said = masterWords(phrases, worst);
|
||||
assert.equal(said.title, "J'aurais joué 8/5 6/5");
|
||||
assert.equal(said.phrase, "J'aurais joué 8/5 6/5. Mon coup fait votre point 5.");
|
||||
});
|
||||
|
||||
test("le rang n'est cité que pour un bon coup non meilleur ou une imprécision, au 2e ou 3e choix", async () => {
|
||||
const engine = await loadNodeEngine();
|
||||
const worst = (await adviceOn(
|
||||
engine,
|
||||
await playOpening(engine, [3, 1], "24/23 13/10"),
|
||||
)) as MoveAdvice;
|
||||
for (const skill of ["none", "doubtful", "bad", "veryBad"] as const) {
|
||||
for (const rank of [2, 3, 4, 7, 15]) {
|
||||
const { phrase } = masterWords(phrases, { ...worst, skill, rank });
|
||||
const cited = (skill === "none" || skill === "doubtful") && rank <= 3;
|
||||
if (cited) {
|
||||
assert.ok(phrase.includes(`le ${rank}e choix`), `${skill} ${rank} : ${phrase}`);
|
||||
} else {
|
||||
assert.ok(!phrase.includes("choix"), `${skill} ${rank} : ${phrase}`);
|
||||
assert.equal(phrase, "J'aurais joué 8/5 6/5. Mon coup fait votre point 5.");
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("décisions de videau : la décision du maître et le contexte", async () => {
|
||||
const engine = await loadNodeEngine();
|
||||
const missed = await adviceOn(engine, await playerRollsWithoutDoubling(engine));
|
||||
const words = masterWords(phrases, missed);
|
||||
assert.equal(words.phrase, "Ici, j'aurais doublé.");
|
||||
assert.equal(words.title, "J'aurais doublé");
|
||||
assert.equal(words.context, "avant votre jet");
|
||||
const passed = masterWords(phrases, await adviceOn(engine, await playerAnswers(engine, false)));
|
||||
assert.equal(passed.phrase, "Ici, j'aurais pris.");
|
||||
assert.equal(passed.context, "le double de l'ordinateur");
|
||||
});
|
||||
|
||||
test("tous les gabarits, remplis avec les plus longues notations, restent sous la limite", () => {
|
||||
const long = "bar/22* 13/9*(2) 6/off";
|
||||
const move = (equity: number) => ({ notation: long, halfMoves: [], equity, ply: 2 });
|
||||
for (const skill of ["none", "doubtful", "bad", "veryBad"] as const) {
|
||||
for (const rank of [1, 2, 23]) {
|
||||
for (const facts of [
|
||||
{ hits: 0, madePoints: [] },
|
||||
{ hits: 1, madePoints: [4, 5, 7] },
|
||||
{ hits: 3, madePoints: [] },
|
||||
]) {
|
||||
const advice: MoveAdvice = {
|
||||
version: ADVICE_VERSION,
|
||||
kind: "move",
|
||||
eventId: "7",
|
||||
ply: 2,
|
||||
position: "4HPwATDgc/ABMA",
|
||||
roll: [6, 6],
|
||||
opening: false,
|
||||
moveCount: 87,
|
||||
rank,
|
||||
played: move(-0.2),
|
||||
best: move(0),
|
||||
playedFacts: rank === 1 ? facts : { hits: 0, madePoints: [] },
|
||||
bestFacts: facts,
|
||||
ranking: [],
|
||||
equityLoss: rank === 1 ? 0 : -0.2,
|
||||
skill: rank === 1 ? "none" : skill,
|
||||
};
|
||||
for (let id = 0; id < 20; id++) {
|
||||
const line = masterLine(
|
||||
masterWords(phrases, { ...advice, eventId: String(id) }),
|
||||
);
|
||||
assert.ok(line.length < LINE_LIMIT, `trop long (${line.length}) : ${line}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("le conseil est rattaché au journal et gardé à l'enregistrement", async () => {
|
||||
const engine = await loadNodeEngine();
|
||||
const played = await playOpening(engine, [3, 1], "8/5 6/5");
|
||||
const advice = await adviceOn(engine, played);
|
||||
played.game.recordAdvice(advice);
|
||||
// Le tour de l'ordinateur, puis l'enregistrement : le conseil reste.
|
||||
await played.game.playComputerTurn();
|
||||
assert.deepEqual(played.game.advice[played.eventId], advice);
|
||||
const library = createGameLibrary(createKeyValueBackend(memoryStorage()));
|
||||
await library.save("g", played.game.journal, { level: "2ply" });
|
||||
const loaded = await library.load("g");
|
||||
assert.ok(loaded !== null);
|
||||
const resumed = Game.resume(engine, loaded.journal);
|
||||
assert.deepEqual(resumed.advice[played.eventId], advice);
|
||||
// Revenir en arrière garde le conseil, rattaché à son événement.
|
||||
resumed.goBackTo(played.eventId);
|
||||
assert.deepEqual(resumed.advice[played.eventId], advice);
|
||||
});
|
||||
|
||||
test("migration : un journal sans conseil, ou avec un conseil d'une autre version", async () => {
|
||||
const engine = await loadNodeEngine();
|
||||
const played = await playOpening(engine, [3, 1], "8/5 6/5");
|
||||
const { advice: _, ...withoutAdvice } = played.game.journal;
|
||||
assert.deepEqual(Game.resume(engine, withoutAdvice).advice, {});
|
||||
const advice = await adviceOn(engine, played);
|
||||
const future = { ...advice, version: ADVICE_VERSION + 1 };
|
||||
const orphan = { ...advice, eventId: "999" };
|
||||
const resumed = Game.resume(engine, {
|
||||
...played.game.journal,
|
||||
advice: { [played.eventId]: future, "999": orphan },
|
||||
});
|
||||
assert.deepEqual(resumed.advice, {});
|
||||
assert.throws(() => resumed.recordAdvice(orphan), /introuvable/);
|
||||
});
|
||||
@@ -73,6 +73,7 @@ function fakeEngine(overrides: Partial<Engine> = {}): Engine {
|
||||
};
|
||||
},
|
||||
analyseMove: async () => null,
|
||||
rankMoves: async () => [],
|
||||
analyseCube: async () => ({
|
||||
analysed: false,
|
||||
equities: null,
|
||||
|
||||
@@ -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());
|
||||
});
|
||||
@@ -0,0 +1,108 @@
|
||||
// Des décisions du joueur dans des positions connues, jouées par le domaine
|
||||
// sur le vrai moteur, puis analysées : de quoi tester le conseil du maître
|
||||
// (tests unitaires et scénarios).
|
||||
//
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
|
||||
import { readFileSync } from "node:fs";
|
||||
import { analyseDecision } from "../../src/domain/analysis/analyse.js";
|
||||
import { type MasterPhrases, parsePhrases } from "../../src/domain/analysis/phrases.js";
|
||||
import type { Advice } from "../../src/domain/game/advice.js";
|
||||
import type { Engine } from "../../src/domain/game/engine.js";
|
||||
import { Game } from "../../src/domain/game/game.js";
|
||||
import { parseHalfMoves, START_POSITION } from "../../src/domain/game/position.js";
|
||||
import { fixedDice } from "../../src/domain/game/roll.js";
|
||||
|
||||
const root = new URL("../../../../", import.meta.url);
|
||||
|
||||
/** Les phrases du maître, lues dans leur source. */
|
||||
export function loadPhrases(): MasterPhrases {
|
||||
return parsePhrases(readFileSync(new URL("docs/maitre/phrases.md", root), "utf8"));
|
||||
}
|
||||
|
||||
/** Une position de double et prise (videau au centre), vue de celui qui a
|
||||
* la main : doubler y est juste, et prendre aussi (tests du moteur, 1a). */
|
||||
export const DOUBLE_TAKE_POSITION = "6O5uAAC4uzsAAA";
|
||||
|
||||
/** Le joueur, au trait à la position de départ, lance dice et joue move ;
|
||||
* la partie et l'événement du coup. */
|
||||
export async function playOpening(
|
||||
engine: Engine,
|
||||
dice: readonly [number, number],
|
||||
move: string,
|
||||
): Promise<{ game: Game; eventId: string }> {
|
||||
const game = Game.start(engine, {
|
||||
setup: { position: START_POSITION, firstTurn: "player" },
|
||||
// Le jet du joueur, puis celui de l'ordinateur s'il joue ensuite.
|
||||
dice: fixedDice([...dice, 6, 5]),
|
||||
});
|
||||
await game.roll();
|
||||
for (const halfMove of parseHalfMoves(move)) {
|
||||
if (!(await game.playHalfMove(halfMove))) {
|
||||
throw new Error(`demi-coup refusé : ${move}`);
|
||||
}
|
||||
}
|
||||
await game.confirmMove();
|
||||
const played = game.events.findLast((e) => e.type === "MovePlayed");
|
||||
return { game, eventId: played?.id ?? "" };
|
||||
}
|
||||
|
||||
/** Le joueur, au trait dans la position de double et prise, double ;
|
||||
* l'ordinateur répond. */
|
||||
export async function playerDoubles(engine: Engine): Promise<{ game: Game; eventId: string }> {
|
||||
const game = Game.start(engine, {
|
||||
setup: { position: DOUBLE_TAKE_POSITION, firstTurn: "player" },
|
||||
dice: fixedDice([]),
|
||||
});
|
||||
await game.double();
|
||||
const offered = game.events.find((e) => e.type === "CubeOffered");
|
||||
return { game, eventId: offered?.id ?? "" };
|
||||
}
|
||||
|
||||
/** Le joueur, au trait dans la position de double et prise, lance sans
|
||||
* doubler. */
|
||||
export async function playerRollsWithoutDoubling(
|
||||
engine: Engine,
|
||||
): Promise<{ game: Game; eventId: string }> {
|
||||
const game = Game.start(engine, {
|
||||
setup: { position: DOUBLE_TAKE_POSITION, firstTurn: "player" },
|
||||
dice: fixedDice([6, 5]),
|
||||
});
|
||||
await game.roll();
|
||||
const rolled = game.events.findLast((e) => e.type === "DiceRolled");
|
||||
return { game, eventId: rolled?.id ?? "" };
|
||||
}
|
||||
|
||||
/** L'ordinateur, au trait dans la position de double et prise, double ; le
|
||||
* joueur prend (take) ou passe. */
|
||||
export async function playerAnswers(
|
||||
engine: Engine,
|
||||
take: boolean,
|
||||
): Promise<{ game: Game; eventId: string }> {
|
||||
const game = Game.start(engine, {
|
||||
setup: { position: DOUBLE_TAKE_POSITION, firstTurn: "computer" },
|
||||
level: "2ply",
|
||||
dice: fixedDice([]),
|
||||
});
|
||||
await game.playComputerTurn();
|
||||
if (take) {
|
||||
game.take();
|
||||
} else {
|
||||
await game.drop();
|
||||
}
|
||||
const answer = game.events.find((e) => e.type === (take ? "CubeTaken" : "CubeDropped"));
|
||||
return { game, eventId: answer?.id ?? "" };
|
||||
}
|
||||
|
||||
/** Le conseil du moteur sur une décision ; erreur s'il n'y en a pas. */
|
||||
export async function adviceOn(
|
||||
engine: Engine,
|
||||
played: { game: Game; eventId: string },
|
||||
): Promise<Advice> {
|
||||
const advice = await analyseDecision(engine, played.game.journal, played.eventId);
|
||||
if (advice === null) {
|
||||
throw new Error(`aucun conseil pour l'événement ${played.eventId}`);
|
||||
}
|
||||
return advice;
|
||||
}
|
||||
@@ -128,3 +128,25 @@ ne change pas (version 1) : le résumé d'une partie gagne un champ facultatif
|
||||
`match`, recalculé à chaque enregistrement, et les réglages
|
||||
(`ustagammon.settings`) quatre champs (`gameKind`, `matchLength`, `level`,
|
||||
`masterAdvice`) qui ont une valeur par défaut quand ils manquent.
|
||||
|
||||
### 2026-11-10 — Le conseil du maître rattaché au journal
|
||||
|
||||
Avec le conseil du maître (étape 1d), le journal d'une partie
|
||||
(`GameJournal`, `app/src/domain/game/events.ts`) gagne un champ facultatif
|
||||
`advice` : pour chaque décision du joueur analysée (coup, jet sans doubler,
|
||||
double, prise ou refus d'un double), l'analyse du moteur à 2 plis, rangée
|
||||
sous l'identifiant de l'événement de la décision
|
||||
(`app/src/domain/game/advice.ts`). Ce n'est pas un événement : l'analyse
|
||||
arrive après coup, d'un second moteur, et ne change pas la partie ; les
|
||||
événements gardent leur version (2). Le bilan de fin de partie (1e) la
|
||||
relira sans recalcul.
|
||||
|
||||
- Chaque conseil porte sa version (`ADVICE_VERSION`, 1). À la reprise
|
||||
(`migrateJournal`), un conseil d'une autre version, ou rattaché à un
|
||||
événement absent, est oublié : ce n'est qu'une analyse, qui peut être
|
||||
refaite. Un journal enregistré avant l'étape 1d n'a pas de champ `advice`
|
||||
et se reprend tel quel. Testé dans `app/tests/advice.test.ts`.
|
||||
- Ajouter un événement, revenir en arrière ou changer de branche garde les
|
||||
conseils ; un conseil reste attaché à son événement, même hors de la
|
||||
branche courante.
|
||||
- Le schéma IndexedDB ne change pas : le journal est enregistré tel quel.
|
||||
|
||||
@@ -112,3 +112,21 @@ Le menu, l'accueil et « Nouvelle partie » en fin de partie y mènent. « Comme
|
||||
remplace cette adresse par celle de la partie (`#/`) : le retour du téléphone
|
||||
ne rouvre pas l'écran. Le lien d'une position d'une partie de match porte le
|
||||
Match ID du match (longueur, score, partie Crawford), écrit par le moteur.
|
||||
|
||||
### 2026-11-10 — Adresse de la feuille d'explication du maître
|
||||
|
||||
Ajoutée avec le conseil du maître (étape 1d), dans le même schéma :
|
||||
|
||||
| Écran | Adresse |
|
||||
| --- | --- |
|
||||
| Feuille d'explication du maître sur une décision de la partie en cours | `#/explication/<événement>` |
|
||||
|
||||
`<événement>` est l'identifiant, dans le journal de la partie en cours, de
|
||||
l'événement de la décision commentée (le coup, le double, la prise…). La
|
||||
ligne du maître est un lien vers cette adresse ; la feuille monte au-dessus
|
||||
de la partie, comme le menu, et « Fermer » (ou le retour du téléphone)
|
||||
revient à la partie. Rechargée, l'adresse rouvre la même explication : le
|
||||
conseil est enregistré dans le journal (ADR 0007, amendement du
|
||||
2026-11-10). Un événement sans conseil donne un message, jamais une page
|
||||
cassée. L'adresse n'a de sens que sur l'appareil qui a joué la partie : ce
|
||||
n'est pas un lien à partager (le lien de la position, lui, se partage).
|
||||
|
||||
+1
-1
@@ -20,7 +20,7 @@ Repris du cadrage (modules, écrans clés, IA générative en V1) et des ADR 001
|
||||
| 1a. Moteur pour l'analyse (fait) | Niveaux de jeu (plis et bruit de GNU Backgammon : débutant à expert), classement des coups d'un jet avec équités, analyse d'un coup joué et d'une décision de videau, chance d'un jet ; exposés par `engine.h`, `engine-cli` et le Worker, avec positions de référence comparées à gnubg de bureau | — |
|
||||
| 1b. Parties enregistrées (fait) | Stockage IndexedDB derrière l'interface de stockage existante : plusieurs parties, reprise automatique, branches (« Rejouer d'ici » crée une branche, l'originale reste intacte et rechargeable), export SGF avec variantes | — |
|
||||
| 1c. Match et réglages (fait) | Partie libre ou match en N points, règle Crawford, règle Jacoby, choix du niveau de l'ordinateur, conseil du maître activable ou non ; écran de nouvelle partie | 1a, 1b |
|
||||
| 1d. Conseil du maître | Après chaque coup du joueur, jamais avant (ADR 0011) : appréciation du moteur (bon coup, imprécision, erreur, grosse erreur) avec mot, icône et couleur, phrase du maître fondée sur les chiffres du moteur, coup recommandé montré sur demande ; aussi pour les décisions de videau ; calcul en arrière-plan sans retarder le jeu | 1a |
|
||||
| 1d. Conseil du maître (fait) | Après chaque coup du joueur, jamais avant (ADR 0011) : appréciation du moteur (bon coup, imprécision, erreur, grosse erreur) avec mot, icône et couleur, phrase du maître fondée sur les chiffres du moteur, coup recommandé montré sur demande ; aussi pour les décisions de videau ; calcul en arrière-plan sans retarder le jeu | 1a |
|
||||
| 1e. Fin de partie et analyse | Bilan (ADR 0011) : résultat, coups par appréciation, taux d'erreur comparé à la chance, « le coup à revoir » ; écran d'analyse : graphe d'équité (toucher un point ouvre la position), liste des erreurs filtrable, visualiseur pas à pas coup joué contre meilleur coup, « Rejouer d'ici » | 1a, 1b, 1d |
|
||||
| 1f. Prompts à copier | Gabarits versionnés et testés (`docs/prompts/` ou équivalent, source unique), bouton « Demander à une IA » sur le plateau, le conseil du maître, l'analyse et la fin de partie ; le moteur décide, l'IA explique | 1d, 1e |
|
||||
| 1g. Accueil, tutoriel et leçons | Accueil (reprendre, nouvelle partie, prochaine leçon, progression résumée), tutoriel des règles interactif et illustré (0e.5), cinq premières leçons numérotées (ADR 0011) avec positions et « à vous de jouer », progression locale ; contenus CC BY-SA 4.0 en brouillon, à relire par un joueur fort | 0e.5, 1a |
|
||||
|
||||
@@ -43,6 +43,7 @@ exportée. `make test` vérifie le lien dans les deux sens : chaque fiche avec
|
||||
| [Camp](camp.md) | Home board |
|
||||
| [Chance](chance.md) | Luck |
|
||||
| [Compte de points](compte-de-points.md) | Pip count |
|
||||
| [Conseil du maître](conseil-du-maitre.md) | Advice |
|
||||
| [Coup](coup.md) | Move |
|
||||
| [Demi-coup](demi-coup.md) | Half-move |
|
||||
| [Course](course.md) | Race |
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
---
|
||||
term: Conseil du maître
|
||||
english: Advice
|
||||
code: Advice
|
||||
---
|
||||
# Conseil du maître
|
||||
|
||||
**En bref** : après chacun de vos coups et chacune de vos décisions de videau, une ligne qui donne l'appréciation du moteur et ce que le maître aurait joué.
|
||||
|
||||
Le maître ne parle jamais avant votre coup : il commente ce que vous venez de
|
||||
jouer, avec un mot (bon coup, imprécision, erreur, grosse erreur) choisi
|
||||
d'après l'[équité](equite.md) perdue par rapport au meilleur choix (voir
|
||||
[Appréciation](appreciation.md)). Toucher la ligne ouvre l'explication : votre
|
||||
coup et celui du maître, joués sur le plateau, et les chiffres du
|
||||
[moteur](moteur.md) si vous les demandez. Le conseil se coupe dans le menu ou
|
||||
sur l'écran « Nouvelle partie ».
|
||||
|
||||
Voir aussi : [Appréciation](appreciation.md), [Coup](coup.md), [Videau](videau.md)
|
||||
@@ -0,0 +1,233 @@
|
||||
# Les phrases du maître
|
||||
|
||||
Les mots et les phrases du conseil du maître (étape 1d), en une seule source
|
||||
(ADR 0008) : `make site` copie ce dossier dans le site publié
|
||||
(`docs/maitre/`), l'application le lit à l'exécution
|
||||
(`app/src/domain/analysis/phrases.ts`), et `make test` rend chaque gabarit
|
||||
sur de vraies analyses du moteur (`app/tests/advice.test.ts`).
|
||||
|
||||
Texte sous licence **CC BY-SA 4.0**. Version 1, rédigée par Claude Code
|
||||
d'après la charte (`docs/identite/charte/ecrans-etape-1.md`, « Les phrases du
|
||||
maître »), **à relire** par la session « identité » puis par Pierre.
|
||||
|
||||
## Règles
|
||||
|
||||
- Le maître parle à la première personne et vouvoie : « J'aurais joué… »,
|
||||
jamais « Vous auriez dû… ».
|
||||
- Il ne cite que des faits donnés par le moteur : le coup recommandé, le rang
|
||||
du coup joué, le nombre de coups possibles, les pions frappés et les points
|
||||
faits (lus sur les plateaux décodés par le moteur), la décision de videau
|
||||
qu'il préfère. Aucune explication stratégique.
|
||||
- La ligne (le mot et la phrase) fait moins de 140 caractères ; pas de point
|
||||
d'exclamation, pas d'émoji, pas de superlatif.
|
||||
- Quand une section a plusieurs gabarits, ils tournent d'un coup à l'autre
|
||||
(l'identifiant de l'événement choisit), pour ne pas lasser.
|
||||
|
||||
## Format
|
||||
|
||||
Une section `## clé` par emploi, suivie d'un gabarit par ligne (`- …`). Les
|
||||
mots entre accolades sont remplis avec les données du moteur :
|
||||
|
||||
| Repère | Ce qu'il devient |
|
||||
| --- | --- |
|
||||
| `{best}` | le coup du maître, en notation de gnubg (« 8/5 6/5 ») |
|
||||
| `{played}` | votre coup, même notation |
|
||||
| `{move}` | le coup dont parle le fait (le vôtre ou celui du maître) |
|
||||
| `{rank}` | le rang de votre coup dans le classement du moteur (2 ou 3 seulement) |
|
||||
| `{points}` | « votre point 5 » ou « vos points 5 et 7 » |
|
||||
| `{roll}` | le jet, « 3-1 » |
|
||||
| `{hits}` | le nombre de pions frappés, en chiffres |
|
||||
|
||||
Un fait (sections `fait.…`) s'ajoute à la phrase seulement si la ligne reste
|
||||
sous 140 caractères.
|
||||
|
||||
Tout ce qui précède la première section `## mot.…` n'est pas lu.
|
||||
|
||||
## mot.coup.bon
|
||||
|
||||
- Bon coup
|
||||
|
||||
## mot.videau.bon
|
||||
|
||||
- Bonne décision
|
||||
|
||||
## mot.douteux
|
||||
|
||||
- Imprécision
|
||||
|
||||
## mot.mauvais
|
||||
|
||||
- Erreur
|
||||
|
||||
## mot.tres-mauvais
|
||||
|
||||
- Grosse erreur
|
||||
|
||||
## coup.meilleur
|
||||
|
||||
- C'est le coup du maître.
|
||||
- Exactement ce que j'aurais joué.
|
||||
- Bien vu.
|
||||
- C'est aussi mon choix.
|
||||
|
||||
## coup.seul
|
||||
|
||||
- C'était le seul coup possible.
|
||||
|
||||
## coup.proche
|
||||
|
||||
« Bon coup » sans être le meilleur, votre coup étant le 2e ou le 3e choix du
|
||||
moteur. Le rang n'est cité qu'ici et dans coup.douteux : il mesure mal la
|
||||
gravité d'une erreur (un 2e choix peut être loin du premier), et le
|
||||
classement complet est dans « Voir les chiffres du moteur ».
|
||||
|
||||
- Tout près : j'aurais joué {best}, votre coup était le {rank}e choix.
|
||||
|
||||
## coup.douteux
|
||||
|
||||
Imprécision, votre coup étant le 2e ou le 3e choix.
|
||||
|
||||
- J'aurais joué {best}. Votre coup était le {rank}e choix, tout près.
|
||||
|
||||
## coup.loin
|
||||
|
||||
« Bon coup » non meilleur ou imprécision, votre coup étant au-delà du 3e
|
||||
choix : le rang n'est pas cité.
|
||||
|
||||
- J'aurais joué {best}.
|
||||
|
||||
## coup.mauvais
|
||||
|
||||
Erreur : le rang n'est jamais cité.
|
||||
|
||||
- J'aurais joué {best}.
|
||||
|
||||
## coup.tres-mauvais
|
||||
|
||||
Grosse erreur : le rang n'est jamais cité.
|
||||
|
||||
- J'aurais joué {best}.
|
||||
|
||||
## fait.frappe
|
||||
|
||||
- {move} frappe un pion.
|
||||
|
||||
## fait.frappe-plusieurs
|
||||
|
||||
- {move} frappe {hits} pions.
|
||||
|
||||
## fait.point
|
||||
|
||||
- {move} fait {points}.
|
||||
|
||||
## fait.frappe-et-point
|
||||
|
||||
- {move} frappe un pion et fait {points}.
|
||||
|
||||
## fait.maitre-frappe
|
||||
|
||||
- Mon coup frappe un pion.
|
||||
|
||||
## fait.maitre-point
|
||||
|
||||
- Mon coup fait {points}.
|
||||
|
||||
## fait.joueur-frappe
|
||||
|
||||
- Votre coup frappe un pion, le mien non.
|
||||
|
||||
## videau.bon.double
|
||||
|
||||
- Doubler était le bon choix.
|
||||
|
||||
## videau.bon.pas-de-double
|
||||
|
||||
- Ne pas doubler était le bon choix.
|
||||
|
||||
## videau.bon.prendre
|
||||
|
||||
- Prendre était le bon choix.
|
||||
|
||||
## videau.bon.passer
|
||||
|
||||
- Passer était le bon choix.
|
||||
|
||||
## videau.proche.double
|
||||
|
||||
- Les deux choix étaient très proches. J'aurais doublé.
|
||||
|
||||
## videau.proche.pas-de-double
|
||||
|
||||
- Les deux choix étaient très proches. Je n'aurais pas doublé.
|
||||
|
||||
## videau.proche.prendre
|
||||
|
||||
- Les deux choix étaient très proches. J'aurais pris.
|
||||
|
||||
## videau.proche.passer
|
||||
|
||||
- Les deux choix étaient très proches. J'aurais passé.
|
||||
|
||||
## videau.autre.double
|
||||
|
||||
- Ici, j'aurais doublé.
|
||||
|
||||
## videau.autre.pas-de-double
|
||||
|
||||
- Ici, je n'aurais pas doublé.
|
||||
|
||||
## videau.autre.prendre
|
||||
|
||||
- Ici, j'aurais pris.
|
||||
|
||||
## videau.autre.passer
|
||||
|
||||
- Ici, j'aurais passé.
|
||||
|
||||
## titre.coup.meme
|
||||
|
||||
- C'est aussi le coup que j'aurais joué
|
||||
|
||||
## titre.coup.autre
|
||||
|
||||
- J'aurais joué {best}
|
||||
|
||||
## titre.videau.meme
|
||||
|
||||
- C'est aussi ce que j'aurais fait
|
||||
|
||||
## titre.videau.double
|
||||
|
||||
- J'aurais doublé
|
||||
|
||||
## titre.videau.pas-de-double
|
||||
|
||||
- Je n'aurais pas doublé
|
||||
|
||||
## titre.videau.prendre
|
||||
|
||||
- J'aurais pris
|
||||
|
||||
## titre.videau.passer
|
||||
|
||||
- J'aurais passé
|
||||
|
||||
## contexte.coup
|
||||
|
||||
- votre {roll}
|
||||
|
||||
## contexte.coup.ouverture
|
||||
|
||||
- votre {roll} d'ouverture
|
||||
|
||||
## contexte.videau.double
|
||||
|
||||
- votre double
|
||||
|
||||
## contexte.videau.pas-de-double
|
||||
|
||||
- avant votre jet
|
||||
|
||||
## contexte.videau.reponse
|
||||
|
||||
- le double de l'ordinateur
|
||||
@@ -1,64 +0,0 @@
|
||||
# Conseil du maître (1d)
|
||||
|
||||
- **Date** : 2026-11-03
|
||||
- **Étape** : 1d (`docs/etape-1.md`)
|
||||
- **Statut** : prévu (après 1c)
|
||||
- **Exécutant** : sous-agent general-purpose, dans un worktree
|
||||
|
||||
## Objectif
|
||||
|
||||
Le coach bienveillant, « mis en avant mais discret » (Pierre, 2026-11-03) :
|
||||
après chaque coup et chaque décision de videau du joueur, jamais avant
|
||||
(ADR 0011), une appréciation et une phrase du maître fondées sur les chiffres
|
||||
du moteur.
|
||||
|
||||
## Références
|
||||
|
||||
- ADR 0011 ; maquette « conseil du maître après le coup » du canevas
|
||||
https://claude.ai/artifact/CQw6vbDpi6XQqVWQa4sANe ; règles de la charte
|
||||
(`docs/identite/charte/`, dont interaction.md) ; la ligne réservée de 44 px
|
||||
de la révision UX.
|
||||
- Moteur : `analyseMove`, `analyseCube`, `luck`, seuils `SKILL_THRESHOLDS`
|
||||
(gnubg : 0,03 / 0,06 / 0,12).
|
||||
|
||||
## Consignes
|
||||
|
||||
1. **Analyse en arrière-plan** : après la validation d'un coup (ou une
|
||||
décision de videau), l'analyse à 2 plis part dans le Worker sans retarder le
|
||||
tour de l'ordinateur ; son résultat est rattaché au coup dans le journal
|
||||
(événement d'analyse rétrocompatible, ou cache séparé justifié) pour servir
|
||||
au bilan (1e) sans recalcul.
|
||||
2. **Ligne du maître** : icône d'appréciation, mot en couleur (« Bon coup. »,
|
||||
« Imprécision. », « Erreur. », « Grosse erreur. » (choix de Pierre du 2026-11-09, plutôt que « gaffe ») : correspondance avec les seuils
|
||||
de gnubg documentée), phrase courte en italique, chevron. Toucher ouvre la
|
||||
feuille d'explication : coup joué et meilleur coup montrés sur le plateau
|
||||
(pions animés, ADR 0011 « comparaison visuelle »), écart d'équité seulement
|
||||
sur demande, lien vers la fiche du glossaire du concept en jeu si le moteur
|
||||
permet de le déterminer, sinon rien.
|
||||
3. **Phrases du maître** : gabarits versionnés en français dans une source
|
||||
unique sous `docs/` (CC BY-SA 4.0). Référence de ton, approuvée par Pierre
|
||||
le 2026-11-09 : la section « Les phrases du maître » de
|
||||
`docs/identite/charte/ecrans-etape-1.md` (1re personne et vouvoiement,
|
||||
faits du moteur uniquement, moins de 140 caractères, ton sobre sans
|
||||
exclamation, formules qui tournent pour les bons coups, exemples). Un test
|
||||
vérifie la longueur et l'absence de « ! » sur chaque gabarit rendu. Les
|
||||
phrases livrées passent par la session « identité » puis par Pierre pour
|
||||
la relecture finale.
|
||||
4. **Discret** : rien n'apparaît pendant que le joueur réfléchit ; la ligne
|
||||
s'efface à l'action suivante ; désactivable (réglage de 1c et menu).
|
||||
5. **Videau** : même traitement pour doubler, ne pas doubler, prendre, passer.
|
||||
6. Scénarios Gherkin (bon coup, erreur avec meilleur coup montré, conseil
|
||||
désactivé, décision de videau commentée) avec positions et dés imposés ;
|
||||
tests unitaires des gabarits (chaque gabarit rendu sur des analyses réelles
|
||||
du moteur). `make lint`, `make test`, `make test-browser`, captures pour la
|
||||
recette design.
|
||||
|
||||
## Critères de réussite
|
||||
|
||||
Après une erreur volontaire dans une position connue, la ligne du maître dit
|
||||
« Erreur. », nomme le coup recommandé par le moteur et le montre sur demande ;
|
||||
le tour de l'ordinateur n'est pas retardé ; les trois commandes passent.
|
||||
|
||||
## Résultat
|
||||
|
||||
À remplir à la clôture.
|
||||
@@ -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,52 @@
|
||||
# language: fr
|
||||
Fonctionnalité: Comprendre mes coups avec le conseil du maître
|
||||
|
||||
Après chacun de vos coups, et après chacune de vos décisions de videau,
|
||||
le maître dit en une ligne ce qu'il en pense : « Bon coup. »,
|
||||
« Imprécision. », « Erreur. » ou « Grosse erreur. », puis une phrase,
|
||||
par exemple le coup qu'il aurait joué. Il ne parle jamais avant que vous
|
||||
ayez joué, et la ligne s'efface dès que vous lancez les dés ou touchez un
|
||||
pion. Touchez-la pour ouvrir l'explication. Le maître, c'est GNU
|
||||
Backgammon : il ne dit que ce que le programme a calculé.
|
||||
|
||||
Scénario: Que dit le maître après un bon coup ?
|
||||
« Bon coup. », avec une phrase qui change d'une fois à l'autre et, s'il
|
||||
y en a, ce que votre coup fait sur le plateau : un pion frappé, un point
|
||||
fait. Avec 3-1 à l'ouverture, 8/5 6/5 est le meilleur coup.
|
||||
|
||||
Étant donné que vous avez 3-1 à jouer à la position de départ
|
||||
Quand vous validez le coup 8/5 6/5
|
||||
Alors le maître dit « Bon coup. »
|
||||
Et sa phrase dit que 8/5 6/5 fait votre point 5
|
||||
|
||||
Scénario: Comment voir le coup que le maître aurait joué ?
|
||||
Après une imprécision ou une erreur, la ligne nomme le coup du maître.
|
||||
Touchez-la : l'explication montre, sur le plateau, votre coup et le coup
|
||||
du maître joués pion par pion depuis la même position. Les chiffres,
|
||||
repliés en bas, donnent l'écart entre les coups si vous les voulez.
|
||||
|
||||
Étant donné que vous avez 3-1 à jouer à la position de départ
|
||||
Quand vous validez le coup 24/23 13/10
|
||||
Alors le maître dit « Grosse erreur. »
|
||||
Et sa phrase commence par « J'aurais joué 8/5 6/5. »
|
||||
Et l'explication peut montrer votre coup et le coup du maître sur le plateau
|
||||
|
||||
Scénario: Comment faire taire le maître ?
|
||||
Coupez « Conseil du maître » dans le menu, ou sur l'écran « Nouvelle
|
||||
partie ». La ligne reste alors vide, et rien d'autre ne bouge. Vos coups
|
||||
restent analysés pour le bilan de fin de partie.
|
||||
|
||||
Étant donné que vous avez coupé le conseil du maître
|
||||
Et que vous avez 3-1 à jouer à la position de départ
|
||||
Quand vous validez le coup 24/23 13/10
|
||||
Alors le maître ne dit rien
|
||||
|
||||
Scénario: Le maître commente-t-il le videau ?
|
||||
Oui : après votre double, votre réponse à un double de l'ordinateur, ou
|
||||
quand vous avez lancé les dés alors qu'il aurait doublé, le maître dit
|
||||
si vous avez bien fait.
|
||||
|
||||
Étant donné que l'ordinateur vous propose de doubler dans une position où il faut prendre
|
||||
Quand vous passez son double
|
||||
Alors le maître dit « Erreur. »
|
||||
Et sa phrase dit « Ici, j'aurais pris. »
|
||||
+14
-3
@@ -8,7 +8,10 @@
|
||||
# partie » et un match en 3 points joué jusqu'au bout (tests/browser/match.mjs),
|
||||
# captures dans build/browser-shots/match/.
|
||||
#
|
||||
# BROWSERS=chromium et SCRIPTS=accueil (ou play, ou match) restreignent le
|
||||
# Le conseil du maître (tests/browser/maitre.mjs), captures dans
|
||||
# build/browser-shots/maitre/.
|
||||
#
|
||||
# BROWSERS=chromium et SCRIPTS=accueil (ou play, match, maitre) restreignent le
|
||||
# test.
|
||||
#
|
||||
# Outils : l'image de Playwright (navigateurs) et le paquet playwright,
|
||||
@@ -22,7 +25,7 @@ cd "$(dirname "$0")/.."
|
||||
image="${PLAYWRIGHT_IMAGE:?lancer via make test-browser}"
|
||||
browsers="${BROWSERS:-chromium firefox}"
|
||||
# Les scripts à lancer (tests/browser/<nom>.mjs), tous par défaut.
|
||||
scripts="${SCRIPTS:-play accueil match}"
|
||||
scripts="${SCRIPTS:-play accueil match maitre}"
|
||||
|
||||
unset MAKEFLAGS MFLAGS MAKELEVEL
|
||||
make -s package >/dev/null
|
||||
@@ -59,7 +62,8 @@ update_port="$(docker port "$update_container" 80/tcp | head -n1 | sed 's/.*://'
|
||||
|
||||
rm -rf build/browser-shots
|
||||
mkdir -p build/browser-shots
|
||||
cp tests/browser/play.mjs tests/browser/accueil.mjs tests/browser/match.mjs tests/browser/recette.mjs "$tools/"
|
||||
cp tests/browser/play.mjs tests/browser/accueil.mjs tests/browser/match.mjs tests/browser/recette.mjs \
|
||||
tests/browser/maitre.mjs "$tools/"
|
||||
status=0
|
||||
for browser in $browsers; do
|
||||
# Réseau de l'hôte : le site est sur 127.0.0.1. Le service worker
|
||||
@@ -85,5 +89,12 @@ for browser in $browsers; do
|
||||
-v "$PWD/$tools:/tools" -v "$PWD/build/browser-shots:/shots" -w /tools "$image" \
|
||||
node match.mjs "http://localhost:$port/" "$browser" /shots || status=1
|
||||
fi
|
||||
if [[ " $scripts " == *" maitre "* ]]; then
|
||||
# Conseil du maître : ligne, feuille d'explication, videau, réglage ;
|
||||
# captures de la recette dans build/browser-shots/maitre/.
|
||||
docker run --rm --init --ipc=host --network host --user "$(id -u):$(id -g)" -e HOME=/tmp \
|
||||
-v "$PWD/$tools:/tools" -v "$PWD/build/browser-shots:/shots" -w /tools "$image" \
|
||||
node maitre.mjs "http://localhost:$port/" "$browser" /shots || status=1
|
||||
fi
|
||||
done
|
||||
exit "$status"
|
||||
|
||||
@@ -0,0 +1,297 @@
|
||||
// maitre.mjs -- le conseil du maître (étape 1d) joué par l'interface, dans
|
||||
// un vrai navigateur sans écran (Playwright), sur le site servi par l'image
|
||||
// de production (étape 1d, docs/etape-1.md ; charte, ecrans-etape-1.md).
|
||||
//
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
//
|
||||
// Usage : node maitre.mjs <adresse du site> <chromium|firefox> <dossier des captures>
|
||||
// Lancé par tests/browser.sh. Chaque cas part d'une position connue
|
||||
// (#/position/..., « Jouer à partir d'ici ») avec des dés imposés : les dés
|
||||
// de l'application viennent de Math.random, remplacé ici par une file de
|
||||
// valeurs choisies (window.__dice), puis par un générateur à graine.
|
||||
// Vérifie la ligne du maître (bon coup, imprécision, erreur, grosse
|
||||
// erreur), la feuille d'explication et son adresse, une décision de videau
|
||||
// commentée, le conseil coupé dans le menu, l'absence d'erreur et de
|
||||
// violation de la politique de sécurité. Captures de la recette design
|
||||
// (Chromium) dans <dossier des captures>/maitre/, en 412×915 et 915×412.
|
||||
|
||||
import { mkdirSync } from "node:fs";
|
||||
import { chromium, firefox } from "playwright";
|
||||
|
||||
const [base, browserName, shots] = process.argv.slice(2);
|
||||
const browserType = { chromium, firefox }[browserName];
|
||||
if (!base || !browserType || !shots) {
|
||||
console.error("usage : node maitre.mjs <adresse> <chromium|firefox> <dossier des captures>");
|
||||
process.exit(2);
|
||||
}
|
||||
const full = browserName === "chromium";
|
||||
const dir = `${shots}/maitre`;
|
||||
mkdirSync(dir, { recursive: true });
|
||||
const PORTRAIT = { width: 412, height: 915 };
|
||||
const LANDSCAPE = { width: 915, height: 412 };
|
||||
const START = "4HPwATDgc/ABMA";
|
||||
// Une position de double et prise (videau au centre) : doubler y est juste.
|
||||
const DOUBLE_TAKE = "6O5uAAC4uzsAAA";
|
||||
|
||||
const problems = [];
|
||||
const log = (text) => console.log(`[${browserName}] ${text}`);
|
||||
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
let captures = 0;
|
||||
|
||||
function check(condition, message) {
|
||||
if (!condition) {
|
||||
throw new Error(message);
|
||||
}
|
||||
}
|
||||
|
||||
const browser = await browserType.launch();
|
||||
const context = await browser.newContext({
|
||||
viewport: PORTRAIT,
|
||||
deviceScaleFactor: 1,
|
||||
locale: "fr-FR",
|
||||
// Sans animation : les captures montrent les coups posés, avec leurs
|
||||
// traces.
|
||||
reducedMotion: "reduce",
|
||||
});
|
||||
await context.addInitScript(() => {
|
||||
// Les dés imposés d'abord (un dé par valeur), puis une graine fixe.
|
||||
window.__dice = [];
|
||||
let state = 11;
|
||||
Math.random = () => {
|
||||
const die = window.__dice.shift();
|
||||
if (die !== undefined) {
|
||||
return (die - 0.5) / 6;
|
||||
}
|
||||
state = (state * 1103515245 + 12345) % 2147483648;
|
||||
return state / 2147483648;
|
||||
};
|
||||
window.__csp = [];
|
||||
document.addEventListener("securitypolicyviolation", (e) =>
|
||||
window.__csp.push(`${e.effectiveDirective} ${e.blockedURI}`),
|
||||
);
|
||||
});
|
||||
const page = await context.newPage();
|
||||
page.on("pageerror", (error) => problems.push(`erreur de la page : ${error.message}`));
|
||||
page.on("console", (message) => {
|
||||
if (message.type() === "error") {
|
||||
problems.push(`console : ${message.text()}`);
|
||||
}
|
||||
});
|
||||
|
||||
/** Attend une condition dans la page (sans page.waitForFunction, qui passe
|
||||
* par eval, interdit par la politique de sécurité du site). */
|
||||
async function until(what, fn, arg, ms = 90_000) {
|
||||
for (let waited = 0; waited < ms; waited += 100) {
|
||||
if (await page.evaluate(fn, arg)) {
|
||||
return;
|
||||
}
|
||||
await sleep(100);
|
||||
}
|
||||
await page.screenshot({ path: `${dir}/${browserName}-bloque.png` }).catch(() => {});
|
||||
throw new Error(`attente trop longue : ${what}`);
|
||||
}
|
||||
|
||||
const idle = () => until("écran libre", () => !document.body.classList.contains("busy"));
|
||||
|
||||
/** Une capture de la recette, dans les deux tailles (Chromium seulement). */
|
||||
async function capture(name) {
|
||||
if (!full) {
|
||||
return;
|
||||
}
|
||||
// Le bandeau « Reprendre l'ancienne » (10 s) ne gêne pas la lecture.
|
||||
await until("bandeau parti", () => document.getElementById("banner").hidden, null, 15_000);
|
||||
for (const size of [PORTRAIT, LANDSCAPE]) {
|
||||
await page.setViewportSize(size);
|
||||
await sleep(300);
|
||||
await page.screenshot({ path: `${dir}/${name}-${size.width}x${size.height}.png` });
|
||||
captures++;
|
||||
}
|
||||
await page.setViewportSize(PORTRAIT);
|
||||
await sleep(300);
|
||||
}
|
||||
|
||||
/** Une nouvelle partie à partir d'une position, joueur au trait. */
|
||||
async function startFrom(position) {
|
||||
await page.goto(`${base}#/position/${encodeURIComponent(position)}`);
|
||||
await until("position lue", () => {
|
||||
const play = document.getElementById("position-play");
|
||||
return !play.hidden && !play.disabled;
|
||||
});
|
||||
await page.click("#position-play");
|
||||
await until("partie commencée", () =>
|
||||
location.hash === "#/" && !document.body.classList.contains("busy") &&
|
||||
!document.getElementById("roll").hidden);
|
||||
}
|
||||
|
||||
/** Lance les dés imposés et joue un coup, demi-coup par demi-coup : le pion
|
||||
* de départ, puis sa case d'arrivée si le pion ne s'y est pas déjà posé. */
|
||||
async function play(dice, halfMoves) {
|
||||
await page.evaluate((d) => {
|
||||
window.__dice.push(...d);
|
||||
}, dice);
|
||||
await page.click("#roll");
|
||||
await idle();
|
||||
for (const [from, to] of halfMoves) {
|
||||
await page.click(`#board .hit[data-place="p${from}"]`);
|
||||
await idle();
|
||||
const land = page.locator(`#board .hit.can-land[data-place="p${to}"]`);
|
||||
if ((await land.count()) > 0) {
|
||||
await land.click();
|
||||
await idle();
|
||||
}
|
||||
}
|
||||
check(!(await page.locator("#confirm").isHidden()), `coup incomplet : ${JSON.stringify(halfMoves)}`);
|
||||
await page.click("#confirm");
|
||||
}
|
||||
|
||||
/** La ligne du maître telle qu'elle s'affiche. */
|
||||
async function adviceLine() {
|
||||
return page.evaluate(() => {
|
||||
const line = document.getElementById("advice-line");
|
||||
return {
|
||||
shown: !line.hidden,
|
||||
word: line.querySelector(".advice-word")?.textContent ?? "",
|
||||
phrase: line.querySelector(".advice-phrase")?.textContent ?? "",
|
||||
href: line.getAttribute("href"),
|
||||
infoShown: !document.getElementById("info").hidden,
|
||||
height: line.getBoundingClientRect().height,
|
||||
chip: document.getElementById("advice-chip") !== null,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function waitAdvice() {
|
||||
await until("ligne du maître", () =>
|
||||
!document.getElementById("advice-line").hidden &&
|
||||
!document.body.classList.contains("busy"));
|
||||
return adviceLine();
|
||||
}
|
||||
|
||||
// ------------------------------------------------- Bon coup, et ses voisins
|
||||
|
||||
const cases = [
|
||||
{ name: "1-bon-coup", dice: [3, 1], moves: [[8, 5], [6, 5]], word: "Bon coup." },
|
||||
{ name: "2-imprecision", dice: [2, 1], moves: [[13, 11], [11, 10]], word: "Imprécision." },
|
||||
{ name: "3-erreur", dice: [2, 1], moves: [[24, 22], [6, 5]], word: "Erreur." },
|
||||
{ name: "4-grosse-erreur", dice: [3, 1], moves: [[24, 23], [13, 10]], word: "Grosse erreur." },
|
||||
];
|
||||
for (const c of cases) {
|
||||
await startFrom(START);
|
||||
// Rien pendant que le joueur réfléchit.
|
||||
check(!(await adviceLine()).shown, `${c.name} : ligne avant le coup`);
|
||||
await play(c.dice, c.moves);
|
||||
const line = await waitAdvice();
|
||||
check(line.word === c.word, `${c.name} : mot « ${line.word} », attendu « ${c.word} »`);
|
||||
check(line.phrase.length > 0 && !line.phrase.includes("!"), `${c.name} : phrase « ${line.phrase} »`);
|
||||
check(!line.infoShown, `${c.name} : la ligne du dernier coup reste visible`);
|
||||
check(line.height <= 44, `${c.name} : ligne de ${line.height} px`);
|
||||
check(/^#\/explication\/\d+$/.test(line.href), `${c.name} : lien ${line.href}`);
|
||||
if (c.word !== "Bon coup.") {
|
||||
check(line.phrase.startsWith("J'aurais joué") || line.phrase.startsWith("Tout près"),
|
||||
`${c.name} : le coup du maître n'est pas nommé : « ${line.phrase} »`);
|
||||
}
|
||||
await capture(c.name);
|
||||
if (full) {
|
||||
// En paysage, la pastille du mot mène à la même feuille.
|
||||
await page.setViewportSize(LANDSCAPE);
|
||||
await sleep(200);
|
||||
check((await adviceLine()).chip, `${c.name} : pas de pastille en paysage`);
|
||||
await page.setViewportSize(PORTRAIT);
|
||||
await sleep(200);
|
||||
}
|
||||
log(`${c.name} : « ${line.word} ${line.phrase} »`);
|
||||
}
|
||||
|
||||
// La ligne s'efface à l'action suivante du joueur.
|
||||
await page.click("#roll");
|
||||
await idle();
|
||||
check(!(await adviceLine()).shown, "la ligne reste après le lancer suivant");
|
||||
|
||||
// ------------------------------------------------- Feuille d'explication
|
||||
|
||||
await startFrom(START);
|
||||
await play([3, 1], [[24, 23], [13, 10]]);
|
||||
await waitAdvice();
|
||||
await page.click("#advice-line");
|
||||
await until("feuille ouverte", () =>
|
||||
/^#\/explication\/\d+$/.test(location.hash) &&
|
||||
!document.getElementById("explain-veil").hidden &&
|
||||
document.getElementById("explain-board") !== null &&
|
||||
document.querySelector("#explain-board .board-frame") !== null);
|
||||
const sheet = await page.evaluate(() => ({
|
||||
verdict: document.getElementById("explain-verdict").textContent,
|
||||
title: document.getElementById("explain-title").textContent,
|
||||
mine: document.getElementById("explain-mine")?.getAttribute("aria-checked"),
|
||||
best: document.getElementById("explain-best")?.getAttribute("aria-checked"),
|
||||
numbersOpen: document.getElementById("explain-numbers").open,
|
||||
}));
|
||||
check(sheet.verdict === "Grosse erreur · votre 3-1 d'ouverture", `feuille : ${JSON.stringify(sheet)}`);
|
||||
check(sheet.title === "J'aurais joué 8/5 6/5", `titre : ${sheet.title}`);
|
||||
check(sheet.best === "true" && sheet.mine === "false", "le coup du maître n'est pas montré d'abord");
|
||||
check(!sheet.numbersOpen, "les chiffres du moteur ne sont pas repliés");
|
||||
await capture("5-feuille-explication");
|
||||
await page.click("#explain-mine");
|
||||
await sleep(300);
|
||||
await page.click("#explain-numbers summary");
|
||||
const rows = await page.evaluate(() => document.querySelector(".explain-table .strong")?.textContent);
|
||||
check(rows !== undefined, "votre coup n'est pas en gras dans les chiffres");
|
||||
await capture("5b-feuille-votre-coup-chiffres");
|
||||
// L'adresse se recharge : la feuille revient, le conseil vient du journal.
|
||||
await page.reload();
|
||||
await until("feuille rouverte après rechargement", () =>
|
||||
!document.getElementById("explain-veil").hidden &&
|
||||
document.getElementById("explain-title")?.textContent === "J'aurais joué 8/5 6/5");
|
||||
await page.click("#explain-done");
|
||||
await until("feuille fermée", () =>
|
||||
location.hash === "#/" && document.getElementById("explain-veil").hidden);
|
||||
log("feuille d'explication : mot, coup du maître, votre coup, chiffres repliés, adresse rechargée");
|
||||
|
||||
// ------------------------------------------------- Décision de videau
|
||||
|
||||
await startFrom(DOUBLE_TAKE);
|
||||
check(!(await page.locator("#double").isHidden()), "doubler n'est pas proposé");
|
||||
await page.click("#double");
|
||||
await page.click("#double-confirm");
|
||||
const cube = await waitAdvice();
|
||||
check(cube.word === "Bonne décision." && cube.phrase === "Doubler était le bon choix.",
|
||||
`videau : « ${cube.word} ${cube.phrase} »`);
|
||||
await capture("6-decision-de-videau");
|
||||
log(`décision de videau : « ${cube.word} ${cube.phrase} »`);
|
||||
|
||||
// ------------------------------------------------- Conseil coupé
|
||||
|
||||
await page.click("#menu-button");
|
||||
await page.waitForSelector("#menu", { state: "visible" });
|
||||
check((await page.getAttribute("#menu-advice", "aria-checked")) === "true", "conseil coupé par défaut");
|
||||
await page.click("#menu-advice");
|
||||
check((await page.getAttribute("#menu-advice", "aria-checked")) === "false", "interrupteur sans effet");
|
||||
await page.click("#menu-close");
|
||||
await startFrom(START);
|
||||
await play([3, 1], [[24, 23], [13, 10]]);
|
||||
await idle();
|
||||
// Laisse le temps à l'analyse : la ligne ne doit pas venir.
|
||||
await sleep(3000);
|
||||
const off = await adviceLine();
|
||||
check(!off.shown && off.infoShown, `conseil coupé : ${JSON.stringify(off)}`);
|
||||
await capture("7-conseil-desactive");
|
||||
// On le rallume : le conseil déjà prêt réapparaît.
|
||||
await page.click("#menu-button");
|
||||
await page.waitForSelector("#menu", { state: "visible" });
|
||||
await page.click("#menu-advice");
|
||||
await page.click("#menu-close");
|
||||
check((await waitAdvice()).word === "Grosse erreur.", "conseil rallumé : la ligne ne revient pas");
|
||||
log("conseil coupé dans le menu : la ligne reste vide ; rallumé, elle revient");
|
||||
|
||||
const csp = await page.evaluate(() => window.__csp);
|
||||
for (const violation of csp) {
|
||||
problems.push(`politique de sécurité : ${violation}`);
|
||||
}
|
||||
await browser.close();
|
||||
if (problems.length > 0) {
|
||||
for (const problem of problems) {
|
||||
console.error(`[${browserName}] ${problem}`);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
log(`conseil du maître : tout est conforme (${captures} captures)`);
|
||||
+221
@@ -2671,3 +2671,224 @@ html.landscape .position-view.with-board .position-actions {
|
||||
html.landscape .position-view.with-board .position-actions > * {
|
||||
padding-inline: 16px;
|
||||
}
|
||||
|
||||
/* ------------------------------------------- Conseil du maître (1d) */
|
||||
|
||||
/* La ligne du maître : à la place du dernier coup, dans la ligne réservée
|
||||
* de 44 px (elle ne pousse rien). Pictogramme et mot en couleur
|
||||
* d'appréciation, phrase en italique tronquée, chevron. */
|
||||
.advice-line {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
gap: var(--space-2);
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
height: 44px;
|
||||
font-size: 15px;
|
||||
line-height: 20px;
|
||||
color: var(--text);
|
||||
text-decoration: none;
|
||||
border-radius: 12px;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
.advice-line.appear {
|
||||
animation: fade-in var(--duration-fast) var(--easing-standard);
|
||||
}
|
||||
|
||||
.advice-line .icon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.advice-line .icon-chevron {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.advice-text {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.advice-word {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
[data-verdict="good"] .icon:not(.icon-chevron),
|
||||
[data-verdict="good"] .advice-word { color: var(--verdict-good); }
|
||||
[data-verdict="doubtful"] .icon:not(.icon-chevron),
|
||||
[data-verdict="doubtful"] .advice-word { color: var(--verdict-doubtful); }
|
||||
[data-verdict="bad"] .icon:not(.icon-chevron),
|
||||
[data-verdict="bad"] .advice-word,
|
||||
[data-verdict="veryBad"] .icon:not(.icon-chevron),
|
||||
[data-verdict="veryBad"] .advice-word { color: var(--verdict-bad); }
|
||||
|
||||
/* Paysage : la pastille du mot, au début de la bande centrale. */
|
||||
.advice-chip-pill {
|
||||
fill: var(--surface);
|
||||
stroke: currentColor;
|
||||
stroke-width: 2;
|
||||
}
|
||||
|
||||
.advice-chip-icon {
|
||||
fill: none;
|
||||
stroke: currentColor;
|
||||
stroke-width: 2px;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
vector-effect: non-scaling-stroke;
|
||||
}
|
||||
|
||||
.advice-chip-text {
|
||||
fill: currentColor;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.advice-chip.verdict-good { color: var(--verdict-good); }
|
||||
.advice-chip.verdict-doubtful { color: var(--verdict-doubtful); }
|
||||
.advice-chip.verdict-bad,
|
||||
.advice-chip.verdict-veryBad { color: var(--verdict-bad); }
|
||||
|
||||
/* La feuille d'explication : elle monte du bas, au-dessus du plateau
|
||||
* voilé. */
|
||||
.explain-sheet {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.explain-verdict {
|
||||
display: inline-flex;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
line-height: 20px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.explain-verdict .icon {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
.explain-verdict[data-verdict="good"] { color: var(--verdict-good); }
|
||||
.explain-verdict[data-verdict="doubtful"] { color: var(--verdict-doubtful); }
|
||||
.explain-verdict[data-verdict="bad"],
|
||||
.explain-verdict[data-verdict="veryBad"] { color: var(--verdict-bad); }
|
||||
|
||||
.explain-title {
|
||||
margin: 0;
|
||||
font-size: 24px;
|
||||
line-height: 30px;
|
||||
}
|
||||
|
||||
.explain-phrase {
|
||||
margin: 0;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.explain-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.explain-body .segment {
|
||||
min-height: 44px;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
/* Le coup montré : le vôtre en couleur du texte, celui du maître en
|
||||
* couleur du bon coup (charte 1d). */
|
||||
.explain-board[data-move="mine"] { --last-move: var(--text); }
|
||||
.explain-board[data-move="best"] { --last-move: var(--verdict-good); }
|
||||
|
||||
.explain-legend {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
line-height: 18px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.explain-numbers {
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.explain-numbers summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-height: 44px;
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.explain-table {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr auto;
|
||||
gap: 4px 12px;
|
||||
font-size: 15px;
|
||||
line-height: 20px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.explain-table .strong {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.explain-note {
|
||||
margin: var(--space-2) 0 0;
|
||||
font-size: 13px;
|
||||
line-height: 18px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.explain-glossary {
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
.explain-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.explain-actions button {
|
||||
min-width: 140px;
|
||||
}
|
||||
|
||||
/* En paysage : la feuille prend la hauteur de l'écran ; le plateau à
|
||||
* gauche, le texte à droite. */
|
||||
/* Le plateau tient dans la hauteur, sous le sélecteur et sa légende. */
|
||||
html.landscape .explain-board {
|
||||
width: auto;
|
||||
max-width: 100%;
|
||||
height: calc(100vh - 140px);
|
||||
height: calc(100dvh - 140px);
|
||||
aspect-ratio: 640 / 520;
|
||||
}
|
||||
|
||||
html.landscape .explain-sheet {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||
grid-auto-flow: dense;
|
||||
column-gap: var(--space-4);
|
||||
align-content: start;
|
||||
width: min(100%, 60rem);
|
||||
height: 100%;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
html.landscape .explain-sheet > * {
|
||||
grid-column: 2;
|
||||
}
|
||||
|
||||
html.landscape .explain-sheet > .explain-body {
|
||||
grid-column: 1;
|
||||
grid-row: 1 / span 8;
|
||||
}
|
||||
|
||||
@@ -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