Compare commits

..
Author SHA1 Message Date
Pierre MARTINandClaude Opus 5.5 74693c4277 Recette design du match : Crawford en paysage, « Nouvelle partie » en deux colonnes, « Rejouer »
- Paysage : pendant la partie Crawford, la consigne de la bande centrale
  commence par « Partie Crawford · » ; la police se resserre si besoin.
- Écran « Nouvelle partie » en paysage : deux colonnes au-dessus de la
  barre d'action (type de partie et longueur à gauche, niveau et conseil
  du maître à droite), tout tient en 915x412 et en 844x320.
- Fin de partie : « Rejouer » relance tout de suite une partie aux mêmes
  réglages (un match de même longueur après un match), « Partie suivante »
  dans un match qui continue, lien « Changer les réglages ».
- Recette automatique : aucun élément interactif visible sous une barre
  d'action collée, vérifié sur chaque écran capturé (tests/browser/recette.mjs).
- Scénario « Comment rejouer avec les mêmes réglages ? ».

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 05:03:59 +01:00
43 changed files with 525 additions and 3156 deletions
+3 -4
View File
@@ -127,9 +127,8 @@ 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, 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
# - 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
# ailleurs) ;
# - version.json (commit publié) ;
# - sw.js, complété par la liste de tous ces fichiers (scripts/service-worker.sh).
@@ -142,7 +141,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 docs/maitre $(BUILD_DIR)/site/docs/
cp -r docs/glossaire docs/scenarios docs/tutoriel $(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
-166
View File
@@ -1,166 +0,0 @@
// 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 };
}
-263
View File
@@ -1,263 +0,0 @@
// 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",
"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),
count: String(advice.moveCount),
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 {
const key = {
good: "coup.proche",
doubtful: "coup.douteux",
bad: "coup.mauvais",
veryBad: "coup.tres-mauvais",
}[verdict];
base = fill(key);
fact = differenceFact(phrases, advice);
}
const withFact = fact === null ? base : `${base} ${fact}`;
const phrase = `${word}. ${withFact}`.length < LINE_LIMIT ? withFact : base;
return {
verdict,
word,
phrase,
title: fill(advice.rank === 1 ? "titre.coup.meme" : "titre.coup.autre"),
context: fill(advice.opening ? "contexte.coup.ouverture" : "contexte.coup"),
};
}
/** Ce que fait le coup lui-même : pions frappés, points faits. */
function selfFact(
phrases: MasterPhrases,
id: string,
move: string,
facts: MoveFacts,
): string | null {
const values = { move, points: pointsText(facts.madePoints), hits: String(facts.hits) };
const made = facts.madePoints.length > 0;
if (facts.hits === 1 && made) {
return fillIn(pick(phrases, "fait.frappe-et-point", id), values);
}
if (facts.hits > 1) {
return fillIn(pick(phrases, "fait.frappe-plusieurs", id), values);
}
if (facts.hits === 1) {
return fillIn(pick(phrases, "fait.frappe", id), values);
}
return made ? fillIn(pick(phrases, "fait.point", id), values) : null;
}
/** Un fait qui distingue le coup du maître du coup joué, s'il y en a un. */
function differenceFact(phrases: MasterPhrases, advice: MoveAdvice): string | null {
const best = advice.bestFacts;
const played = advice.playedFacts;
if (best.hits > 0 && played.hits === 0) {
return pick(phrases, "fait.maitre-frappe", advice.eventId);
}
const onlyBest = best.madePoints.filter((p) => !played.madePoints.includes(p));
if (onlyBest.length > 0) {
return fillIn(pick(phrases, "fait.maitre-point", advice.eventId), {
points: pointsText(onlyBest),
});
}
if (played.hits > 0 && best.hits === 0) {
return pick(phrases, "fait.joueur-frappe", advice.eventId);
}
return null;
}
function cubeWords(phrases: MasterPhrases, advice: CubeAdvice): MasterWords {
const verdict = verdictOf(advice.skill);
const master = masterCubeAction(advice);
const same = master === advice.action;
const group = verdict !== "good" ? "autre" : same ? "bon" : "proche";
const shown = group === "bon" ? advice.action : master;
const context =
advice.action === "take" || advice.action === "pass"
? "contexte.videau.reponse"
: `contexte.videau.${ACTION_KEYS[advice.action]}`;
return {
verdict,
word: pick(phrases, wordKey(verdict, "cube"), advice.eventId),
phrase: pick(phrases, `videau.${group}.${ACTION_KEYS[shown]}`, advice.eventId),
title: pick(
phrases,
same ? "titre.videau.meme" : `titre.videau.${ACTION_KEYS[master]}`,
advice.eventId,
),
context: pick(phrases, context, advice.eventId),
};
}
/** « votre point 5 », « vos points 5 et 7 », « vos points 4, 5 et 7 ». */
function pointsText(points: readonly number[]): string {
const sorted = [...points].sort((a, b) => a - b).map(String);
if (sorted.length <= 1) {
return `votre point ${sorted[0] ?? ""}`;
}
return `vos points ${sorted.slice(0, -1).join(", ")} et ${sorted.at(-1)}`;
}
/** Un gabarit de la section key ; plusieurs tournent selon l'identifiant
* de l'événement. Erreur si la section manque. */
function pick(phrases: MasterPhrases, key: string, id: string): string {
const choices = phrases.get(key) ?? [];
if (choices.length === 0) {
throw new Error(`phrases du maître : section « ${key} » absente`);
}
const n = Number.parseInt(id, 10);
// Les identifiants des coups du joueur vont souvent de quatre en quatre :
// un brassage (hachage de Knuth, bits de poids fort) les fait tourner.
const index = (Math.imul(Number.isNaN(n) ? 0 : n, 2654435761) >>> 16) % choices.length;
return choices[index] as string;
}
/** Remplace les {repères} ; erreur sur un repère inconnu. */
function fillIn(template: string, values: Readonly<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;
});
}
-124
View File
@@ -1,124 +0,0 @@
// 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;
}
-11
View File
@@ -80,17 +80,6 @@ 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). */
+5 -47
View File
@@ -12,7 +12,6 @@
// 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";
@@ -39,8 +38,7 @@ 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(saved: GameJournal): GameJournal {
const journal = keepKnownAdvice(saved);
export function migrateJournal(journal: GameJournal): GameJournal {
if (journal.events.every((event) => event.version === EVENT_VERSION)) {
return journal;
}
@@ -57,34 +55,7 @@ export function migrateJournal(saved: GameJournal): GameJournal {
}
return Object.freeze({ ...event, version: EVENT_VERSION });
});
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) };
return { events, head: journal.head };
}
interface EventBase {
@@ -179,14 +150,10 @@ 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à. 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. */
* courant (head) : la partie en est là. */
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 };
@@ -199,16 +166,7 @@ export function appendEvent(journal: GameJournal, event: NewGameEvent): GameJour
parent: journal.head,
version: EVENT_VERSION,
}) as GameEvent;
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 } };
return { events: [...journal.events, added], head: added.id };
}
/** Les événements du début jusqu'à l'événement courant, dans l'ordre. */
@@ -232,5 +190,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 { ...journal, head: id };
return { events: journal.events, head: id };
}
+32 -46
View File
@@ -9,7 +9,6 @@
// 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";
@@ -23,9 +22,8 @@ import {
goBackTo,
migrateJournal,
type NewGameEvent,
withAdvice,
} from "./events.js";
import { type Match, nextGameOf } from "./match.js";
import { firstGameOf, type Match, nextGameOf } from "./match.js";
import { BAR, type HalfMove, OFF, type PositionId, START_POSITION } from "./position.js";
import { type DiceSource, type Roll, randomDice, rollDice } from "./roll.js";
import { otherSide, type Side } from "./side.js";
@@ -168,6 +166,17 @@ export class Game {
return match === null ? null : { level: state.level, match };
}
/** « Rejouer » : une nouvelle partie aux mêmes réglages que celle-ci,
* au même niveau ; pour un match, un nouveau match de même longueur,
* à 0 à 0. Une partie commencée à une position donnée se rejoue depuis
* la position de départ. */
get rematch(): NewGame {
const state = this.state;
return state.match === null
? { level: state.level }
: { level: state.level, match: firstGameOf(state.match.length) };
}
/** Les demi-coups déjà saisis du coup en cours. */
get pendingHalfMoves(): readonly HalfMove[] {
return this.#pending.map((p) => p.halfMove);
@@ -218,59 +227,36 @@ export class Game {
* (checkHalfMoves, puis board) : aucune règle n'est rejouée ici.
*/
async lastMoveSteps(): Promise<MoveSteps | null> {
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 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 positions = [before.position];
for (let i = 1; i <= halfMoves.length; i++) {
for (let i = 1; i <= played.halfMoves.length; i++) {
const check = await this.#engine.checkHalfMoves(
before.position,
rollToPlay(before),
halfMoves.slice(0, i),
roll,
played.halfMoves.slice(0, i),
);
if (check.position === null) {
throw new Error("coup refusé par le moteur");
throw new Error("coup enregistré refusé par le moteur");
}
positions.push(check.position);
}
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(
const boards = await Promise.all(
positions.map((p) => this.#sideBoards(p, played.side, before)),
);
const opponent = otherSide(played.side);
const hits = played.halfMoves.map(
(_, i) => (boards[i + 1]?.[opponent].bar ?? 0) > (boards[i]?.[opponent].bar ?? 0),
);
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);
return { side: played.side, halfMoves: played.halfMoves, boards, hits };
}
/**
-8
View File
@@ -195,14 +195,6 @@ 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,
+1 -27
View File
@@ -5,7 +5,6 @@
// 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";
@@ -16,7 +15,7 @@ import {
tutorialBoard,
} from "./domain/learning/tutorial.js";
import { createWasmEngine } from "./engine/wasm-engine.js";
import { type EngineWorker, startEngineWorker } from "./engine/worker-run.js";
import { 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";
@@ -28,30 +27,6 @@ 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);
@@ -142,7 +117,6 @@ 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),
});
-55
View File
@@ -1,55 +0,0 @@
// Les textes de l'interface du conseil du maître (ligne, feuille
// d'explication, menu). Les mots et les phrases du maître eux-mêmes sont
// dans docs/maitre/phrases.md (une seule source, ADR 0008).
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
import type { CubeAction } from "../domain/game/engine.js";
export const ADVICE_TEXTS = {
/** Interrupteur du menu, comme sur l'écran « Nouvelle partie ». */
menuSwitch: "Conseil du maître",
lineLabel: (line: string) => `Conseil du maître : ${line} Toucher pour l'explication.`,
/** Paysage : la pastille du mot, au lecteur d'écran. */
chipLabel: (line: string) => `Conseil du maître : ${line} Toucher pour l'explication.`,
sheetLabel: "Explication du maître",
yourMove: "Votre coup",
masterMove: "Le coup du maître",
choiceLabel: "Coup montré sur le plateau",
legendMine: (move: string) =>
`Votre coup, ${move}, joué pion par pion sur la position d'avant.`,
legendBest: (move: string) =>
`Le coup du maître, ${move}, joué pion par pion sur la position d'avant votre coup.`,
legendSame: (move: string) => `${move}, joué pion par pion sur la position d'avant.`,
legendCube: "La position au moment de la décision.",
replay: "Rejouer le coup",
numbers: "Voir les chiffres du moteur",
you: "(vous)",
moveNote: (ply: number) =>
`Écart d'équité à ${ply} plis, par rapport au meilleur coup. Un écart de 0,03 ou moins reste un bon coup.`,
cubeNote: (ply: number) =>
`Équités de celui qui double, pour un videau de 1, à ${ply} plis. Écart de votre décision : `,
cubeRows: {
noDouble: "Pas de double",
doubleTake: "Double, prend",
doublePass: "Double, passe",
},
glossary: (term: string) => `En savoir plus : « ${term} »`,
close: "Fermer",
missing: "Le maître n'a pas d'explication pour ce coup.",
} as const;
/** La fiche du glossaire d'une décision de videau, et son titre. */
export const CUBE_GLOSSARY: Readonly<Record<CubeAction, { file: string; term: string }>> = {
double: { file: "doubler", term: "Doubler" },
noDouble: { file: "doubler", term: "Doubler" },
take: { file: "prendre", term: "Prendre" },
pass: { file: "passer", term: "Passer" },
};
/** Un nombre à la française, signé, à trois décimales : « −0,123 ». */
export function formatEquity(value: number): string {
const text = Math.abs(value).toFixed(3).replace(".", ",");
return value < -0.0005 ? `−${text}` : value > 0.0005 ? `+${text}` : text;
}
-152
View File
@@ -1,152 +0,0 @@
// 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;
}
-409
View File
@@ -1,409 +0,0 @@
// La feuille d'explication du maître (#/explication/<événement>), qui monte
// du bas au-dessus du plateau voilé (charte, 1d « La feuille
// d'explication ») : le mot et le contexte, le coup du maître en titre, sa
// phrase, « Votre coup » et « Le coup du maître » joués pion par pion sur la
// position d'avant, puis les chiffres du moteur, repliés.
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
import { type MasterPhrases, masterWords } from "../domain/analysis/phrases.js";
import {
type Advice,
type CubeAdvice,
type MoveAdvice,
masterCubeAction,
type Verdict,
} from "../domain/game/advice.js";
import type { Game, MoveSteps } from "../domain/game/game.js";
import { ADVICE_TEXTS, CUBE_GLOSSARY, formatEquity } from "./advice-texts.js";
import { boardModel, moveTrace, placeOf } from "./board-model.js";
import { checkerSpot, NO_MARKS, renderBoard, slideChecker } from "./board-view.js";
import { boardGeometry, PORTRAIT_ASPECT } from "./geometry.js";
import { type IconName, icon } from "./icons.js";
import { routeHash } from "./router.js";
const SLIDE_MS = 160;
const PAUSE_MS = 120;
/** Le pictogramme d'une appréciation (interaction.md, pictogrammes). */
export const VERDICT_ICONS: Readonly<Record<Verdict, IconName>> = {
good: "verdictGood",
doubtful: "verdictDoubtful",
bad: "verdictBad",
veryBad: "verdictBad",
};
export interface AdviceViewOptions {
/** Le voile de la feuille (index.html) ; la feuille est construite ici. */
readonly veil: HTMLElement;
readonly game: () => Game | null;
readonly phrases: () => Promise<MasterPhrases>;
readonly reducedMotion: MediaQueryList;
/** Fermer : quitter l'adresse de la feuille. */
readonly close: () => void;
}
export interface AdviceView {
/** Ouvre la feuille sur le conseil de l'événement eventId. */
show(eventId: string): Promise<void>;
hide(): void;
}
type Shown = "mine" | "best";
function element<K extends keyof HTMLElementTagNameMap>(
parent: HTMLElement,
tag: K,
options: { id?: string; className?: string; text?: string } = {},
): HTMLElementTagNameMap[K] {
const child = document.createElement(tag);
if (options.id !== undefined) {
child.id = options.id;
}
if (options.className !== undefined) {
child.className = options.className;
}
if (options.text !== undefined) {
child.textContent = options.text;
}
parent.append(child);
return child;
}
export function createAdviceView(options: AdviceViewOptions): AdviceView {
const { veil } = options;
const sheet = element(veil, "div", { id: "explain-sheet", className: "sheet explain-sheet" });
sheet.setAttribute("role", "dialog");
sheet.setAttribute("aria-modal", "true");
sheet.setAttribute("aria-labelledby", "explain-title");
veil.addEventListener("click", (event) => {
if (event.target === veil) {
options.close();
}
});
// Chaque ouverture a son numéro : une ouverture plus récente, ou la
// fermeture, arrête l'animation en cours.
let opened = 0;
// Chaque coup joué sur le plateau de la feuille a aussi le sien.
let playing = 0;
async function show(eventId: string): Promise<void> {
const id = ++opened;
const game = options.game();
const advice = game?.advice[eventId] ?? null;
sheet.replaceChildren();
veil.hidden = false;
const head = element(sheet, "div", { className: "sheet-head" });
element(head, "span", { className: "grip" }).setAttribute("aria-hidden", "true");
const verdictLine = element(head, "p", {
id: "explain-verdict",
className: "explain-verdict",
});
const close = element(head, "button", { id: "explain-close", className: "icon-button" });
close.type = "button";
close.append(icon("close"));
close.setAttribute("aria-label", ADVICE_TEXTS.close);
close.onclick = () => options.close();
const title = element(sheet, "h2", { id: "explain-title", className: "explain-title" });
if (game === null || advice === null) {
title.textContent = ADVICE_TEXTS.sheetLabel;
element(sheet, "p", { className: "explain-phrase", text: ADVICE_TEXTS.missing });
closeButton();
close.focus();
return;
}
const words = masterWords(await options.phrases(), advice);
if (id !== opened) {
return;
}
verdictLine.dataset.verdict = words.verdict;
verdictLine.append(icon(VERDICT_ICONS[words.verdict]));
element(verdictLine, "span", { text: `${words.word} · ${words.context}` });
title.textContent = words.title;
element(sheet, "p", {
id: "explain-phrase",
className: "explain-phrase",
// Le titre dit déjà le coup du maître : la phrase ne le répète pas.
text: words.phrase.startsWith(`${words.title}. `)
? words.phrase.slice(words.title.length + 2)
: words.phrase,
});
const body = element(sheet, "div", { className: "explain-body" });
const boardBox = element(body, "div", { className: "explain-board-box" });
if (advice.kind === "move") {
await showMoves(id, game, advice, body, boardBox);
} else {
await showCube(id, game, advice, boardBox);
}
if (id !== opened) {
return;
}
numbers(advice);
if (advice.kind === "cube") {
const entry = CUBE_GLOSSARY[masterCubeAction(advice)];
const link = element(sheet, "a", {
id: "explain-glossary",
className: "link-button explain-glossary",
text: ADVICE_TEXTS.glossary(entry.term),
});
link.href = routeHash({ screen: "glossary", term: entry.file });
}
closeButton();
close.focus();
}
function closeButton(): void {
const actions = element(sheet, "div", { className: "explain-actions" });
const done = element(actions, "button", {
id: "explain-done",
className: "secondary",
text: ADVICE_TEXTS.close,
});
done.type = "button";
done.onclick = () => options.close();
}
/** Le plateau de la feuille, dans son cadre. */
function boardSvg(box: HTMLElement): SVGSVGElement {
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
svg.id = "explain-board";
svg.setAttribute("class", "board explain-board");
svg.setAttribute("role", "img");
box.append(svg);
return svg;
}
function draw(svg: SVGSVGElement, steps: MoveSteps, index: number, traced: boolean): void {
const boards = steps.boards[index];
if (boards === undefined) {
return;
}
const width = svg.getBoundingClientRect().width || 358;
renderBoard(
svg,
boardGeometry("portrait", PORTRAIT_ASPECT, width / 640),
boardModel(boards),
{
...NO_MARKS,
dice: [],
cube: null,
band: null,
controls: null,
trace: traced ? moveTrace(steps.side, steps.halfMoves, steps.hits) : null,
},
);
// Les flèches des traces renvoient à leur pointe par identifiant :
// celui du plateau de jeu serait pris à sa place.
for (const marker of svg.querySelectorAll("marker[id]")) {
marker.id = `explain-${marker.id}`;
}
for (const path of svg.querySelectorAll("[marker-end]")) {
const ref = path.getAttribute("marker-end") ?? "";
path.setAttribute("marker-end", ref.replace("url(#", "url(#explain-"));
}
}
/** Joue un coup pion par pion (sans mouvement si le système le
* demande), puis laisse ses traces. */
async function animate(svg: SVGSVGElement, steps: MoveSteps): Promise<void> {
const id = ++playing;
if (!options.reducedMotion.matches) {
for (const [i, halfMove] of steps.halfMoves.entries()) {
if (id !== playing) {
return;
}
if (i > 0) {
await new Promise((resolve) => setTimeout(resolve, PAUSE_MS));
}
const from = placeOf(steps.side, halfMove.from);
const to = placeOf(steps.side, halfMove.to);
draw(svg, steps, i, false);
const start = checkerSpot(svg, from);
const victim = steps.hits[i] === true ? checkerSpot(svg, to) : null;
draw(svg, steps, i + 1, false);
await Promise.all([
start && slideChecker(svg, to, start, SLIDE_MS),
victim && slideChecker(svg, "bar-computer", victim, SLIDE_MS),
]);
}
}
if (id === playing) {
draw(svg, steps, steps.halfMoves.length, true);
}
}
async function showMoves(
id: number,
game: Game,
advice: MoveAdvice,
body: HTMLElement,
boardBox: HTMLElement,
): Promise<void> {
const same = advice.rank === 1;
const [mine, best] = await Promise.all([
game.stepsBefore(advice.eventId, "player", advice.played.halfMoves),
same ? null : game.stepsBefore(advice.eventId, "player", advice.best.halfMoves),
]);
if (id !== opened) {
return;
}
const legend = element(body, "p", { id: "explain-legend", className: "explain-legend" });
const svg = boardSvg(boardBox);
if (best === null) {
svg.dataset.move = "mine";
svg.setAttribute("aria-label", ADVICE_TEXTS.legendSame(advice.played.notation));
legend.textContent = ADVICE_TEXTS.legendSame(advice.played.notation);
draw(svg, mine, 0, false);
void animate(svg, mine);
return;
}
const choice = element(body, "div", { id: "explain-choice", className: "segmented" });
choice.setAttribute("role", "radiogroup");
choice.setAttribute("aria-label", ADVICE_TEXTS.choiceLabel);
body.prepend(choice);
const buttons: Record<Shown, HTMLButtonElement> = {
mine: element(choice, "button", {
id: "explain-mine",
className: "segment",
text: ADVICE_TEXTS.yourMove,
}),
best: element(choice, "button", {
id: "explain-best",
className: "segment",
text: ADVICE_TEXTS.masterMove,
}),
};
const select = (shown: Shown) => {
for (const key of ["mine", "best"] as const) {
buttons[key].setAttribute("aria-checked", String(key === shown));
}
const notation = shown === "mine" ? advice.played.notation : advice.best.notation;
const text =
shown === "mine"
? ADVICE_TEXTS.legendMine(notation)
: ADVICE_TEXTS.legendBest(notation);
legend.textContent = text;
svg.setAttribute("aria-label", text);
svg.dataset.move = shown;
const steps = shown === "mine" ? mine : best;
draw(svg, steps, 0, false);
void animate(svg, steps);
};
for (const key of ["mine", "best"] as const) {
const button = buttons[key];
button.type = "button";
button.setAttribute("role", "radio");
button.onclick = () => select(key);
}
// Flèches gauche et droite entre les deux segments.
choice.addEventListener("keydown", (event) => {
if (event.key === "ArrowLeft" || event.key === "ArrowRight") {
event.preventDefault();
const next = buttons.mine.getAttribute("aria-checked") === "true" ? "best" : "mine";
select(next);
buttons[next].focus();
}
});
// Par défaut, le coup du maître (maquette).
select("best");
}
async function showCube(
id: number,
game: Game,
advice: CubeAdvice,
boardBox: HTMLElement,
): Promise<void> {
const steps = await game.stepsBefore(advice.eventId, "player", []);
if (id !== opened) {
return;
}
const svg = boardSvg(boardBox);
svg.setAttribute("aria-label", ADVICE_TEXTS.legendCube);
draw(svg, steps, 0, false);
element(boardBox, "p", { className: "explain-legend", text: ADVICE_TEXTS.legendCube });
}
/** « Voir les chiffres du moteur », replié par défaut. */
function numbers(advice: Advice): void {
const details = element(sheet, "details", {
id: "explain-numbers",
className: "explain-numbers",
});
element(details, "summary", { text: ADVICE_TEXTS.numbers });
const table = element(details, "div", { className: "explain-table" });
const row = (cells: string[], strong: boolean) => {
for (const text of cells) {
element(table, "span", { className: strong ? "strong" : "", text });
}
};
if (advice.kind === "move") {
// 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();
}
},
};
}
+29 -172
View File
@@ -21,13 +21,6 @@
// 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";
@@ -36,9 +29,6 @@ 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,
@@ -50,7 +40,6 @@ import {
placeOf,
} from "./board-model.js";
import {
type BoardAdvice,
type BoardDecor,
checkerSpot,
type DiceAction,
@@ -130,14 +119,8 @@ 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. */
@@ -211,9 +194,6 @@ 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"),
@@ -265,6 +245,7 @@ export function startApp(options: AppOptions): void {
panelVeil: byId("panel-veil"),
panelText: byId("panel-text"),
panelPrimary: byId<HTMLButtonElement>("panel-primary"),
panelSettings: byId<HTMLAnchorElement>("panel-settings"),
panelSecondary: byId<HTMLButtonElement>("panel-secondary"),
banner: byId("banner"),
bannerText: byId("banner-text"),
@@ -338,7 +319,7 @@ export function startApp(options: AppOptions): void {
ui.confirm.textContent = TEXTS.confirm;
label(ui.cancel, TEXTS.cancel, "undo");
ui.skip.textContent = TEXTS.skip;
ui.again.textContent = TEXTS.newGame;
ui.again.textContent = MATCH_TEXTS.replay;
ui.moveHint.textContent = TEXTS.moveHint;
ui.doubleConfirm.textContent = TEXTS.double;
ui.doubleCancel.textContent = TEXTS.cancel;
@@ -360,8 +341,6 @@ 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"));
@@ -455,36 +434,6 @@ 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)");
@@ -588,9 +537,12 @@ export function startApp(options: AppOptions): void {
diceAction: action,
canDouble: !busy && canDouble,
band:
landscape && !panelShown ? (text === null ? TEXTS.gameOver : text.short) : null,
landscape && !panelShown
? text === null
? TEXTS.gameOver
: 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);
@@ -598,8 +550,6 @@ 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) {
@@ -662,69 +612,6 @@ 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 {
@@ -848,6 +735,7 @@ export function startApp(options: AppOptions): void {
detail.textContent = end.detail;
ui.panelSecondary.textContent = TEXTS.reviewGame;
ui.panelPrimary.textContent = afterGameLabel(g);
ui.panelSettings.hidden = false;
ui.panelVeil.dataset.kind = "over";
} else if (ask) {
overline.textContent = "";
@@ -855,6 +743,7 @@ export function startApp(options: AppOptions): void {
detail.textContent = "";
ui.panelSecondary.textContent = TEXTS.drop;
ui.panelPrimary.textContent = TEXTS.take;
ui.panelSettings.hidden = true;
ui.panelVeil.dataset.kind = "doubled";
}
ui.panelPrimary.disabled = busy;
@@ -869,7 +758,6 @@ 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));
}
@@ -1085,19 +973,11 @@ 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
@@ -1146,14 +1026,11 @@ export function startApp(options: AppOptions): void {
}
/** Après une partie finie : la partie suivante du match s'il continue,
* sinon l'écran « Nouvelle partie ». */
* sinon « Rejouer », une nouvelle partie aux mêmes réglages (un match de
* même longueur après un match), tout de suite. */
function afterGame(): void {
const next = game?.nextGame ?? null;
if (next !== null) {
newGame(next);
} else {
closeOverlays();
navigation.go(routeHash({ screen: "newGame" }));
if (game !== null) {
newGame(game.nextGame ?? game.rematch);
}
}
@@ -1205,10 +1082,7 @@ 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 === "explanation";
route.screen === "menu" || route.screen === "games" || route.screen === "install";
// « Mes parties » : reprendre une partie (ou une branche) en change à
// l'écran, sans recharger la page.
@@ -1232,15 +1106,6 @@ 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 {
@@ -1410,7 +1275,6 @@ export function startApp(options: AppOptions): void {
ui.menuVeil.hidden = true;
ui.installVeil.hidden = true;
gamesView?.hide();
adviceView.hide();
if (menuWasOpen) {
(menuReturnFocus ?? ui.menuButton).focus();
}
@@ -1444,6 +1308,13 @@ export function startApp(options: AppOptions): void {
afterGame();
}
};
// « Changer les réglages » : un lien vers l'écran « Nouvelle partie » ;
// le panneau se ferme, le résultat reste sur l'écran de jeu.
ui.panelSettings.textContent = MATCH_TEXTS.changeSettings;
ui.panelSettings.addEventListener("click", () => {
panelDismissed = true;
render();
});
ui.panelSecondary.onclick = () => {
if (ui.panelVeil.dataset.kind === "doubled") {
actions.drop();
@@ -1474,7 +1345,6 @@ 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é
@@ -1571,15 +1441,6 @@ 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.
@@ -2092,10 +1953,6 @@ 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]();
@@ -2126,12 +1983,6 @@ 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.
@@ -2313,10 +2164,16 @@ function matchScore(state: GameState): MatchScore | null {
return { length: state.match.length, computer: score.computer, player: score.player };
}
/** Paysage : la consigne de la bande centrale ; pendant la partie
* Crawford, elle le dit d'abord (la ligne du match n'y est pas). */
function bandText(prompt: string, crawford: boolean): string {
return crawford && prompt !== "" ? MATCH_TEXTS.crawfordPrompt(prompt) : prompt;
}
/** Le bouton qui suit une partie finie : « Partie suivante » dans un match
* qui continue, sinon « Nouvelle partie » (l'écran des choix). */
* qui continue, sinon « Rejouer » (mêmes réglages). */
function afterGameLabel(game: Game): string {
return game.nextGame === null ? TEXTS.newGame : MATCH_TEXTS.nextGame;
return game.nextGame === null ? MATCH_TEXTS.replay : MATCH_TEXTS.nextGame;
}
/** Le résultat d'une partie finie, sur une ligne (consigne, une fois le
+12 -85
View File
@@ -83,21 +83,6 @@ 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. */
@@ -203,10 +188,8 @@ 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, chip);
drawBand(svg, g, decor.band);
}
drawDice(svg, g, decor.dice);
const cube = decor.cube === null ? null : drawCube(svg, g, decor.cube);
@@ -232,9 +215,6 @@ 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);
}
@@ -516,13 +496,17 @@ function addRing(
}
/** Paysage : la consigne courte, dans la moitié gauche de la bande
* 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 {
* centrale, sur une pastille accent-soft. */
function drawBand(svg: SVGSVGElement, g: Geometry, text: string): void {
const middle = g.height / 2;
const width = 6 * g.pointWidth - 24 - before;
const width = 6 * g.pointWidth - 24;
const height = Math.max(44, g.bandFont + 20);
const x = g.x0 + 12 + before;
const x = g.x0 + 12;
// 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
// 0,45 em.
const fontSize = Math.max(13, Math.min(g.bandFont, (width - 24) / (0.45 * text.length)));
add(svg, "rect", {
class: "band-pill",
x,
@@ -538,70 +522,13 @@ function drawBand(svg: SVGSVGElement, g: Geometry, text: string, before = 0): vo
class: "band-text",
id: "band-text",
x: x + width / 2,
y: middle + g.bandFont * 0.35,
"font-size": g.bandFont,
y: middle + fontSize * 0.35,
"font-size": fontSize,
},
text,
);
}
/** Largeur de la pastille du conseil : pictogramme, mot (estimé à 0,55 em
* par lettre, police Alegreya Sans) et marges. */
function adviceChipWidth(g: Geometry, advice: BoardAdvice): number {
return 16 + g.bandFont + 6 + advice.word.length * g.bandFont * 0.55 + 16;
}
/**
* Paysage : le conseil du maître, une pastille au début de la bande
* centrale (pictogramme et mot en couleur d'appréciation), lien vers la
* feuille d'explication (charte 1d, adaptée au paysage : la phrase est dans
* la feuille). Posée parmi les zones à toucher, devant celle des dés.
*/
function drawAdviceChip(svg: SVGSVGElement, g: Geometry, hits: Element, advice: BoardAdvice): void {
const middle = g.height / 2;
const height = Math.max(44, g.bandFont + 20);
const width = adviceChipWidth(g, advice);
const x = g.x0 + 12;
const group = add(svg, "g", { class: `advice-chip verdict-${advice.verdict}` });
add(group, "rect", {
class: "advice-chip-pill",
x,
y: middle - height / 2,
width,
height,
rx: height / 2,
});
const size = g.bandFont;
const icon = add(group, "g", {
transform: `translate(${x + 16},${middle - size / 2}) scale(${size / 24})`,
});
add(icon, "path", { class: "advice-chip-icon", d: ICON_PATHS[advice.icon] });
add(
group,
"text",
{
class: "advice-chip-text",
x: x + 16 + size + 6,
y: middle + g.bandFont * 0.35,
"font-size": g.bandFont,
},
advice.word,
);
const link = add(hits, "a", {
href: advice.href,
id: "advice-chip",
role: "link",
"aria-label": advice.label,
});
add(link, "rect", {
class: "hit action",
x,
y: middle - Math.max(height, g.target) / 2,
width,
height: Math.max(height, g.target),
});
}
/**
* Paysage : le score du match, dans une case sous le menu, dans l'ordre des
* camps à l'écran : l'ordinateur en haut, « sur N », vous en bas
-1
View File
@@ -30,7 +30,6 @@ import { TEXTS } from "./texts.js";
export const FEATURES = [
"docs/scenarios/jouer-une-partie.feature",
"docs/scenarios/jouer-un-match.feature",
"docs/scenarios/comprendre-mes-coups.feature",
"docs/scenarios/apprendre-les-regles.feature",
"docs/scenarios/mes-parties.feature",
"docs/scenarios/partager-une-position.feature",
+7 -1
View File
@@ -64,6 +64,8 @@ export const MATCH_TEXTS = {
/** « Match en 5 points », et « · partie Crawford ». */
matchLine: (length: number, crawford: boolean) =>
`Match en ${length} points${crawford ? " · partie Crawford" : ""}`,
/** Paysage : « Partie Crawford · À vous : lancez les dés. ». */
crawfordPrompt: (prompt: string) => `Partie Crawford · ${prompt}`,
/** Ce que lit le lecteur d'écran. */
scoreSpoken: (length: number, computer: number, player: number) =>
`Match en ${length} points : l'ordinateur ${computer}, vous ${player}`,
@@ -72,7 +74,11 @@ export const MATCH_TEXTS = {
playerScore: (n: number) => `Votre score : ${n}`,
computerScore: (n: number) => `Score de l'ordinateur : ${n}`,
// Fin d'une partie de match
// Fin d'une partie
/** Une nouvelle partie aux mêmes réglages (un match de même longueur
* après un match). */
replay: "Rejouer",
changeSettings: "Changer les réglages",
nextGame: "Partie suivante",
nextGameStarted: "Partie suivante du match.",
matchWonTitle: (winner: "player" | "computer") =>
+22 -9
View File
@@ -87,10 +87,14 @@ export function createNewGameView(options: NewGameViewOptions): NewGameView {
closeLink.append(icon("close"));
element(head, "h2", { id: "new-game-title", text: MATCH_TEXTS.title });
// Deux colonnes, l'une sous l'autre en portrait et côte à côte en
// paysage : le type de partie à gauche, le niveau et le conseil à droite.
const body = element(section, "div", { className: "new-game-body" });
const kindColumn = element(body, "div", { className: "new-game-column" });
const levelColumn = element(body, "div", { className: "new-game-column" });
// 1. Type de partie.
const kinds = element(body, "div", { className: "segmented" });
const kinds = element(kindColumn, "div", { className: "segmented" });
kinds.setAttribute("role", "radiogroup");
kinds.setAttribute("aria-label", MATCH_TEXTS.kindLabel);
const free = radio(kinds, "new-game-free", "segment", MATCH_TEXTS.free);
@@ -98,7 +102,7 @@ export function createNewGameView(options: NewGameViewOptions): NewGameView {
free.addEventListener("click", () => options.change({ gameKind: "free" }));
match.addEventListener("click", () => options.change({ gameKind: "match" }));
const matchOptions = element(body, "div", {
const matchOptions = element(kindColumn, "div", {
id: "new-game-match-options",
className: "new-game-options",
});
@@ -119,7 +123,7 @@ export function createNewGameView(options: NewGameViewOptions): NewGameView {
MATCH_TEXTS.crawfordLink,
"regle-de-crawford",
);
const freeOptions = element(body, "div", {
const freeOptions = element(kindColumn, "div", {
id: "new-game-free-options",
className: "new-game-options",
});
@@ -132,8 +136,8 @@ export function createNewGameView(options: NewGameViewOptions): NewGameView {
);
// 2. Niveau de l'ordinateur : six barres de hauteur croissante.
element(body, "h3", { className: "overline", text: MATCH_TEXTS.levelTitle });
const bars = element(body, "div", { className: "level-bars" });
element(levelColumn, "h3", { className: "overline", text: MATCH_TEXTS.levelTitle });
const bars = element(levelColumn, "div", { className: "level-bars" });
bars.setAttribute("role", "radiogroup");
bars.setAttribute("aria-label", MATCH_TEXTS.levelTitle);
const levelButtons = COMPUTER_LEVELS.map((level, i) => {
@@ -144,12 +148,21 @@ export function createNewGameView(options: NewGameViewOptions): NewGameView {
button.addEventListener("click", () => options.change({ level }));
return button;
});
const levelName = element(body, "p", { id: "new-game-level-name", className: "level-name" });
const levelText = element(body, "p", { id: "new-game-level-text", className: "level-text" });
const levelName = element(levelColumn, "p", {
id: "new-game-level-name",
className: "level-name",
});
const levelText = element(levelColumn, "p", {
id: "new-game-level-text",
className: "level-text",
});
// 3. Pendant la partie.
element(body, "h3", { className: "overline", text: MATCH_TEXTS.duringGame });
const advice = element(body, "button", { id: "new-game-advice", className: "item" });
element(levelColumn, "h3", {
className: "overline during-game",
text: MATCH_TEXTS.duringGame,
});
const advice = element(levelColumn, "button", { id: "new-game-advice", className: "item" });
advice.type = "button";
advice.setAttribute("role", "switch");
const adviceLabel = element(advice, "span", { className: "item-label" });
-7
View File
@@ -31,9 +31,6 @@ 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. */
@@ -93,8 +90,6 @@ 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":
@@ -177,8 +172,6 @@ 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
-141
View File
@@ -1,141 +0,0 @@
// 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");
});
-256
View File
@@ -1,256 +0,0 @@
// 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. Votre 24/23 13/10 était le ${worst.rank}e choix sur 16. Mon coup fait votre point 5.`,
);
});
test("décisions de videau : la décision du maître et le contexte", async () => {
const engine = await loadNodeEngine();
const missed = await adviceOn(engine, await playerRollsWithoutDoubling(engine));
const words = masterWords(phrases, missed);
assert.equal(words.phrase, "Ici, j'aurais doublé.");
assert.equal(words.title, "J'aurais doublé");
assert.equal(words.context, "avant votre jet");
const passed = masterWords(phrases, await adviceOn(engine, await playerAnswers(engine, false)));
assert.equal(passed.phrase, "Ici, j'aurais pris.");
assert.equal(passed.context, "le double de l'ordinateur");
});
test("tous les gabarits, remplis avec les plus longues notations, restent sous la limite", () => {
const long = "bar/22* 13/9*(2) 6/off";
const move = (equity: number) => ({ notation: long, halfMoves: [], equity, ply: 2 });
for (const skill of ["none", "doubtful", "bad", "veryBad"] as const) {
for (const rank of [1, 2, 23]) {
for (const facts of [
{ hits: 0, madePoints: [] },
{ hits: 1, madePoints: [4, 5, 7] },
{ hits: 3, madePoints: [] },
]) {
const advice: MoveAdvice = {
version: ADVICE_VERSION,
kind: "move",
eventId: "7",
ply: 2,
position: "4HPwATDgc/ABMA",
roll: [6, 6],
opening: false,
moveCount: 87,
rank,
played: move(-0.2),
best: move(0),
playedFacts: rank === 1 ? facts : { hits: 0, madePoints: [] },
bestFacts: facts,
ranking: [],
equityLoss: rank === 1 ? 0 : -0.2,
skill: rank === 1 ? "none" : skill,
};
for (let id = 0; id < 20; id++) {
const line = masterLine(
masterWords(phrases, { ...advice, eventId: String(id) }),
);
assert.ok(line.length < LINE_LIMIT, `trop long (${line.length}) : ${line}`);
}
}
}
}
});
test("le conseil est rattaché au journal et gardé à l'enregistrement", async () => {
const engine = await loadNodeEngine();
const played = await playOpening(engine, [3, 1], "8/5 6/5");
const advice = await adviceOn(engine, played);
played.game.recordAdvice(advice);
// Le tour de l'ordinateur, puis l'enregistrement : le conseil reste.
await played.game.playComputerTurn();
assert.deepEqual(played.game.advice[played.eventId], advice);
const library = createGameLibrary(createKeyValueBackend(memoryStorage()));
await library.save("g", played.game.journal, { level: "2ply" });
const loaded = await library.load("g");
assert.ok(loaded !== null);
const resumed = Game.resume(engine, loaded.journal);
assert.deepEqual(resumed.advice[played.eventId], advice);
// Revenir en arrière garde le conseil, rattaché à son événement.
resumed.goBackTo(played.eventId);
assert.deepEqual(resumed.advice[played.eventId], advice);
});
test("migration : un journal sans conseil, ou avec un conseil d'une autre version", async () => {
const engine = await loadNodeEngine();
const played = await playOpening(engine, [3, 1], "8/5 6/5");
const { advice: _, ...withoutAdvice } = played.game.journal;
assert.deepEqual(Game.resume(engine, withoutAdvice).advice, {});
const advice = await adviceOn(engine, played);
const future = { ...advice, version: ADVICE_VERSION + 1 };
const orphan = { ...advice, eventId: "999" };
const resumed = Game.resume(engine, {
...played.game.journal,
advice: { [played.eventId]: future, "999": orphan },
});
assert.deepEqual(resumed.advice, {});
assert.throws(() => resumed.recordAdvice(orphan), /introuvable/);
});
+11 -1
View File
@@ -73,7 +73,6 @@ function fakeEngine(overrides: Partial<Engine> = {}): Engine {
};
},
analyseMove: async () => null,
rankMoves: async () => [],
analyseCube: async () => ({
analysed: false,
equities: null,
@@ -506,6 +505,12 @@ test("une partie de match finie : le moteur dit le score, la fin du match et la
crawford: false,
});
assert.equal(crawford.nextGame, null);
// « Rejouer » après le match : un nouveau match de même longueur, à 0 à
// 0, au même niveau.
assert.deepEqual(crawford.rematch, {
level: "advanced",
match: { length: 3, score: { player: 0, computer: 0 }, crawford: false },
});
});
test("passer un double dans un match : le moteur compte le match", async () => {
@@ -538,6 +543,11 @@ test("une partie libre n'a ni match ni partie suivante", () => {
assert.equal(game.nextGame, null);
});
test("« Rejouer » une partie libre : une partie libre au même niveau, depuis le départ", () => {
const game = Game.start(fakeEngine(), { dice: fixedDice([3, 1]), level: "0ply" });
assert.deepEqual(game.rematch, { level: "0ply" });
});
test("migration : un journal de la version 1 se reprend en version 2, au niveau 2 plis, en partie libre", async () => {
// Un journal tel que l'étape 1b l'enregistrait : version 1, GameStarted
// sans level ni match, un coup joué, une branche.
-1
View File
@@ -37,7 +37,6 @@ 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 },
@@ -1,145 +0,0 @@
// É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());
});
+59
View File
@@ -10,6 +10,7 @@ import assert from "node:assert/strict";
import { BeforeAll, Given, setDefaultTimeout, Then, When } from "@cucumber/cucumber";
import type { Engine } from "../../src/domain/game/engine.js";
import { engineCube, Game, type NewGame } from "../../src/domain/game/game.js";
import { COMPUTER_LEVELS, ENGINE_LEVELS } from "../../src/domain/game/level.js";
import { firstGameOf, type Match } from "../../src/domain/game/match.js";
import { START_POSITION } from "../../src/domain/game/position.js";
import { fixedDice } from "../../src/domain/game/roll.js";
@@ -17,6 +18,7 @@ import { createGameLibrary } from "../../src/storage/game-library.js";
import { createGameSession, type GameSession } from "../../src/storage/game-session.js";
import { createKeyValueBackend } from "../../src/storage/key-value-backend.js";
import { memoryStorage } from "../../src/storage/open-library.js";
import { MATCH_TEXTS } from "../../src/ui/match-texts.js";
import { chosenGame, parseSettings, type Settings } from "../../src/ui/settings.js";
import { loadNodeEngine } from "../support/node-engine.js";
import { playToTheEnd, seededDice } from "../support/play.js";
@@ -167,6 +169,63 @@ Then("la partie Crawford s'est jouée sans videau", () => {
}
});
// ------------------------------------------------------------- Rejouer
/** Le niveau de gnubg d'un niveau nommé comme à l'écran (« Avancé »). */
function engineLevelNamed(name: string) {
const level = COMPUTER_LEVELS.find((l) => MATCH_TEXTS.levels[l].name === name);
assert.ok(level !== undefined, `niveau inconnu : ${name}`);
return ENGINE_LEVELS[level];
}
Given("une partie libre contre un ordinateur {word} vient de se terminer", async (name: string) => {
game = Game.start(engine, {
setup: { position: GAMMON_NEXT_ROLL, firstTurn: "player" },
level: engineLevelNamed(name),
dice: fixedDice([6, 5]),
});
await playFirstMove(game);
assert.equal(game.state.phase, "over");
assert.equal(game.nextGame, null);
});
Given(
"un match en {int} points contre un ordinateur {word} vient de se terminer",
async (n: number, name: string) => {
// Le joueur mène 1 à 0 et gagne un gammon : 3 points, le match est
// fini.
game = Game.start(engine, {
setup: { position: GAMMON_NEXT_ROLL, firstTurn: "player" },
level: engineLevelNamed(name),
match: { length: n, score: { player: n - 2, computer: 0 }, crawford: false },
dice: fixedDice([6, 5]),
});
await playFirstMove(game);
assert.equal(game.state.matchAfter?.over, true);
assert.equal(game.nextGame, null);
},
);
When("vous rejouez", () => {
game = Game.start(engine, { dice: fixedDice([3, 1]), ...game.rematch });
});
Then("une partie libre commence, contre un ordinateur {word}", (name: string) => {
assert.equal(game.state.phase, "moving");
assert.equal(game.state.position, START_POSITION);
assert.equal(game.state.match, null);
assert.equal(game.state.level, engineLevelNamed(name));
});
Then(
"un match en {int} points commence, à 0 à 0, contre un ordinateur {word}",
(n: number, name: string) => {
assert.equal(game.state.position, START_POSITION);
assert.deepEqual(game.state.match, firstGameOf(n));
assert.equal(game.state.level, engineLevelNamed(name));
},
);
// ------------------------------------------------------- La partie Crawford
let crawfordGame: Game;
-108
View File
@@ -1,108 +0,0 @@
// 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,25 +128,3 @@ 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.
-18
View File
@@ -112,21 +112,3 @@ 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
View File
@@ -43,7 +43,6 @@ 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 |
-18
View File
@@ -1,18 +0,0 @@
---
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)
+2 -1
View File
@@ -11,6 +11,7 @@ Référence : `interaction.md`, maquettes « Révision UX » du canevas.
- [ ] Une seule action principale (brique) visible à la fois.
- [ ] Aucun `window.confirm` ni `alert` dans le code de l'app (`grep -rn "confirm(\|alert(" app/src` vide).
- [ ] Aucune cible tactile sous 44 × 44 px (boutons, pictogrammes, dés, videau).
- [ ] Aucun élément interactif visible caché, même en partie, sous une barre d'action collée en bas, sur aucun écran (vérifié sur chaque capture par `tests/browser/recette.mjs`).
- [ ] Textes à lire : 13 px au moins ; numéros des flèches : 12 px au moins.
- [ ] Thème sombre : aucune couleur codée en dur hors `tokens` ; le cadre se distingue du fond.
- [ ] Anneau de focus `focus` visible au clavier sur chaque élément interactif.
@@ -34,7 +35,7 @@ Référence : `interaction.md`, maquettes « Révision UX » du canevas.
- [ ] Comptes en pastilles aux deux bouts de la barre, 13 px, couleur du camp ; ils ne chevauchent pas les pions sur la barre.
- [ ] Consigne et dés dans la bande centrale, sans chevaucher un pion.
- [ ] Rotation portrait ↔ paysage en cours de partie : la position, la sélection et les dés sont conservés.
- [ ] Fin de partie : panneau centré sur un voile, « Revoir la partie » et « Nouvelle partie ».
- [ ] Fin de partie : panneau centré sur un voile, « Revoir la partie » et « Rejouer » (« Partie suivante » dans un match qui continue), lien « Changer les réglages » dessous.
## Comportement
-216
View File
@@ -1,216 +0,0 @@
# Les phrases du maître
Les mots et les phrases du conseil du maître (étape 1d), en une seule source
(ADR 0008) : `make site` copie ce dossier dans le site publié
(`docs/maitre/`), l'application le lit à l'exécution
(`app/src/domain/analysis/phrases.ts`), et `make test` rend chaque gabarit
sur de vraies analyses du moteur (`app/tests/advice.test.ts`).
Texte sous licence **CC BY-SA 4.0**. Version 1, rédigée par Claude Code
d'après la charte (`docs/identite/charte/ecrans-etape-1.md`, « Les phrases du
maître »), **à relire** par la session « identité » puis par Pierre.
## Règles
- Le maître parle à la première personne et vouvoie : « J'aurais joué… »,
jamais « Vous auriez dû… ».
- Il ne cite que des faits donnés par le moteur : le coup recommandé, le rang
du coup joué, le nombre de coups possibles, les pions frappés et les points
faits (lus sur les plateaux décodés par le moteur), la décision de videau
qu'il préfère. Aucune explication stratégique.
- La ligne (le mot et la phrase) fait moins de 140 caractères ; pas de point
d'exclamation, pas d'émoji, pas de superlatif.
- Quand une section a plusieurs gabarits, ils tournent d'un coup à l'autre
(l'identifiant de l'événement choisit), pour ne pas lasser.
## Format
Une section `## clé` par emploi, suivie d'un gabarit par ligne (`- …`). Les
mots entre accolades sont remplis avec les données du moteur :
| Repère | Ce qu'il devient |
| --- | --- |
| `{best}` | le coup du maître, en notation de gnubg (« 8/5 6/5 ») |
| `{played}` | votre coup, même notation |
| `{move}` | le coup dont parle le fait (le vôtre ou celui du maître) |
| `{rank}` | le rang de votre coup dans le classement du moteur (2, 3…) |
| `{count}` | le nombre de coups possibles avec ce jet |
| `{points}` | « votre point 5 » ou « vos points 5 et 7 » |
| `{roll}` | le jet, « 3-1 » |
| `{hits}` | le nombre de pions frappés, en chiffres |
Un fait (sections `fait.…`) s'ajoute à la phrase seulement si la ligne reste
sous 140 caractères.
Tout ce qui précède la première section `## mot.…` n'est pas lu.
## mot.coup.bon
- Bon coup
## mot.videau.bon
- Bonne décision
## mot.douteux
- Imprécision
## mot.mauvais
- Erreur
## mot.tres-mauvais
- Grosse erreur
## coup.meilleur
- C'est le coup du maître.
- Exactement ce que j'aurais joué.
- Bien vu.
- C'est aussi mon choix.
## coup.seul
- C'était le seul coup possible.
## coup.proche
- Tout près : j'aurais joué {best}, votre coup était le {rank}e choix.
## coup.douteux
- J'aurais joué {best}. Votre coup était le {rank}e choix, tout près.
## coup.mauvais
- J'aurais joué {best}. Votre {played} était le {rank}e choix.
## coup.tres-mauvais
- J'aurais joué {best}. Votre {played} était le {rank}e choix sur {count}.
## fait.frappe
- {move} frappe un pion.
## fait.frappe-plusieurs
- {move} frappe {hits} pions.
## fait.point
- {move} fait {points}.
## fait.frappe-et-point
- {move} frappe un pion et fait {points}.
## fait.maitre-frappe
- Mon coup frappe un pion, le vôtre non.
## fait.maitre-point
- Mon coup fait {points}.
## fait.joueur-frappe
- Votre coup frappe un pion, le mien non.
## videau.bon.double
- Doubler était le bon choix.
## videau.bon.pas-de-double
- Ne pas doubler était le bon choix.
## videau.bon.prendre
- Prendre était le bon choix.
## videau.bon.passer
- Passer était le bon choix.
## videau.proche.double
- Les deux choix étaient très proches. J'aurais doublé.
## videau.proche.pas-de-double
- Les deux choix étaient très proches. Je n'aurais pas doublé.
## videau.proche.prendre
- Les deux choix étaient très proches. J'aurais pris.
## videau.proche.passer
- Les deux choix étaient très proches. J'aurais passé.
## videau.autre.double
- Ici, j'aurais doublé.
## videau.autre.pas-de-double
- Ici, je n'aurais pas doublé.
## videau.autre.prendre
- Ici, j'aurais pris.
## videau.autre.passer
- Ici, j'aurais passé.
## titre.coup.meme
- C'est aussi le coup que j'aurais joué
## titre.coup.autre
- J'aurais joué {best}
## titre.videau.meme
- C'est aussi ce que j'aurais fait
## titre.videau.double
- J'aurais doublé
## titre.videau.pas-de-double
- Je n'aurais pas doublé
## titre.videau.prendre
- J'aurais pris
## titre.videau.passer
- J'aurais passé
## contexte.coup
- votre {roll}
## contexte.coup.ouverture
- votre {roll} d'ouverture
## contexte.videau.double
- votre double
## contexte.videau.pas-de-double
- avant votre jet
## contexte.videau.reponse
- le double de l'ordinateur
-1
View File
@@ -57,7 +57,6 @@ 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 |
@@ -1,52 +0,0 @@
# 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. »
+18 -3
View File
@@ -1,8 +1,8 @@
# language: fr
Fonctionnalité: Choisir sa partie et jouer un match
L'écran « Nouvelle partie » s'ouvre depuis le menu, l'accueil et la fin
d'une partie. Vous y choisissez une partie libre ou un match en 3, 5, 7
L'écran « Nouvelle partie » s'ouvre depuis le menu, l'accueil et, à la
fin d'une partie, par « Changer les réglages ». Vous y choisissez une partie libre ou un match en 3, 5, 7
ou 11 points, le niveau de l'ordinateur, et si le maître commente vos
coups. Vos choix sont gardés pour la prochaine fois.
@@ -25,7 +25,8 @@ Fonctionnalité: Choisir sa partie et jouer un match
gagnant (le double pour un gammon, le triple pour un backgammon, fois la
valeur du videau), et le premier qui atteint le nombre de points du
match le gagne. À la fin de chaque partie, « Partie suivante » lance la
suivante, avec le score du match.
suivante, avec le score du match. Le match fini, « Rejouer » en commence
un autre de même longueur.
Étant donné que vous commencez un match en 3 points contre un ordinateur Débutant
Quand le match se joue jusqu'au bout, partie après partie
@@ -33,6 +34,20 @@ Fonctionnalité: Choisir sa partie et jouer un match
Et chaque partie commence avec le score laissé par la précédente
Et la partie Crawford s'est jouée sans videau
Scénario: Comment rejouer avec les mêmes réglages ?
À la fin d'une partie, « Rejouer » en commence tout de suite une autre,
avec les mêmes réglages : le même niveau, et après un match, un nouveau
match de même longueur, à 0 à 0. Tant qu'un match n'est pas fini, c'est
« Partie suivante » qui le continue. Pour changer de niveau ou de type
de partie, touchez « Changer les réglages », sous les boutons.
Étant donné qu'une partie libre contre un ordinateur Expert vient de se terminer
Quand vous rejouez
Alors une partie libre commence, contre un ordinateur Expert
Étant donné qu'un match en 3 points contre un ordinateur Avancé vient de se terminer
Quand vous rejouez
Alors un match en 3 points commence, à 0 à 0, contre un ordinateur Avancé
Scénario: Qu'est-ce que la partie Crawford ?
Dans un match, quand un joueur arrive à un point de la victoire, la
partie suivante se joue sans videau : personne ne peut doubler. C'est la
+4 -23
View File
@@ -11,7 +11,6 @@ set -euo pipefail
CAPROVER_MACHINE="${CAPROVER_MACHINE:-sans-pub}"
CAPROVER_APP="${CAPROVER_APP:-ustagammon}"
SITE_URL="${SITE_URL:-https://ustagammon.sans.pub}"
die() {
echo "Erreur : $1" >&2
@@ -34,14 +33,9 @@ git fetch -q origin main
[ "$(git rev-parse main)" = "$(git rev-parse origin/main)" ] \
|| die "main diffère d'origin/main : git pull (ou fusionner la MR) d'abord"
# Le jeton CapRover expire ; caprover ls ne le vérifie pas. Sans terminal
# (stdin fermé), la CLI ne peut pas demander le mot de passe : elle doit
# échouer, et sa sortie ne doit pas contenir de demande de mot de passe (le
# 2026-11-09, elle a demandé le mot de passe puis rendu 0 sans rien publier).
if ! session=$(caprover api -n "$CAPROVER_MACHINE" -t /user/system/info -m GET -d '{}' -o false </dev/null 2>&1) \
|| grep -qi "password\|not valid anymore" <<<"$session"; then
die "session CapRover expirée : lancer caprover login dans un terminal"
fi
# Le jeton CapRover expire ; caprover ls ne le vérifie pas.
caprover api -n "$CAPROVER_MACHINE" -t /user/system/info -m GET -d '{}' -o false >/dev/null 2>&1 \
|| die "session CapRover expirée : lancer caprover login dans un terminal"
echo "Vérification avant publication : make lint et make test"
make lint
@@ -49,17 +43,4 @@ make test
make package
echo "Publication de main ($(git rev-parse --short main)) sur l'app $CAPROVER_APP"
caprover deploy -n "$CAPROVER_MACHINE" -a "$CAPROVER_APP" -t build/ustagammon.tar </dev/null
# La CLI peut rendre 0 sans avoir publié : on vérifie le résultat réel, le
# commit servi par le site (version.json), pendant deux minutes au plus.
commit=$(git rev-parse main)
for _ in $(seq 24); do
served=$(curl -fsS "$SITE_URL/version.json?verif=$RANDOM" 2>/dev/null || true)
if grep -q "\"$commit\"" <<<"$served"; then
echo "Publié : $SITE_URL sert $(git rev-parse --short main)."
exit 0
fi
sleep 5
done
die "le site ne sert pas le commit $(git rev-parse --short main) après 2 minutes : la publication a échoué (version servie : ${served:-aucune})"
caprover deploy -n "$CAPROVER_MACHINE" -a "$CAPROVER_APP" -t build/ustagammon.tar
+3 -13
View File
@@ -8,10 +8,7 @@
# partie » et un match en 3 points joué jusqu'au bout (tests/browser/match.mjs),
# captures dans build/browser-shots/match/.
#
# 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
# BROWSERS=chromium et SCRIPTS=accueil (ou play, ou match) restreignent le
# test.
#
# Outils : l'image de Playwright (navigateurs) et le paquet playwright,
@@ -25,7 +22,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 maitre}"
scripts="${SCRIPTS:-play accueil match}"
unset MAKEFLAGS MFLAGS MAKELEVEL
make -s package >/dev/null
@@ -62,7 +59,7 @@ 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/maitre.mjs "$tools/"
cp tests/browser/play.mjs tests/browser/accueil.mjs tests/browser/match.mjs tests/browser/recette.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
@@ -88,12 +85,5 @@ 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"
+3
View File
@@ -16,6 +16,7 @@
import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
import { chromium, firefox } from "playwright";
import { hiddenUnderActionBar } from "./recette.mjs";
const [base, browserName, shots, updateBase, updateDir] = process.argv.slice(2);
const browserType = { chromium, firefox }[browserName];
@@ -114,6 +115,8 @@ async function capture(page, name, settle = async () => {}, sizes = SIZES) {
await page.setViewportSize({ width, height });
await sleep(250);
await settle();
const hidden = await hiddenUnderActionBar(page);
check(hidden.length === 0, `${name} ${width}x${height} : caché sous la barre d'action : ${hidden.join(", ")}`);
await page.screenshot({ path: `${dir}/${name}-${width}x${height}.png`, fullPage: true });
captures++;
}
-297
View File
@@ -1,297 +0,0 @@
// 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 (docs/plans/2026-11-03-conseil-du-maitre.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)`);
+92 -12
View File
@@ -13,10 +13,13 @@
// joueur joue toujours le premier pion jouable vers sa première case
// d'arrivée, prend les doubles et ne double jamais. Captures de la recette
// design (Chromium) dans <dossier des captures>/match/ : écran « Nouvelle
// partie », score du match et partie Crawford, en 412×915 et 915×412.
// partie » (aussi en 844×320), score du match et partie Crawford, en
// 412×915 et 915×412. Sur chaque capture, rien d'interactif ne doit être
// caché sous la barre d'action collée (recette.mjs).
import { mkdirSync } from "node:fs";
import { chromium, firefox } from "playwright";
import { hiddenUnderActionBar } from "./recette.mjs";
const [base, browserName, shots] = process.argv.slice(2);
const browserType = { chromium, firefox }[browserName];
@@ -29,6 +32,7 @@ const dir = `${shots}/match`;
mkdirSync(dir, { recursive: true });
const PORTRAIT = { width: 412, height: 915 };
const LANDSCAPE = { width: 915, height: 412 };
const SMALL_LANDSCAPE = { width: 844, height: 320 };
// La graine des dés : un match qui passe par une partie Crawford.
const SEED = 7;
@@ -83,14 +87,18 @@ async function until(what, fn, arg, ms = 60_000) {
throw new Error(`attente trop longue : ${what}`);
}
/** Une capture de la recette, dans les deux tailles (Chromium seulement). */
async function capture(name) {
/** Une capture de la recette, dans chaque taille (Chromium seulement),
* après la règle commune : rien d'interactif sous la barre d'action. */
async function capture(name, sizes = [PORTRAIT, LANDSCAPE]) {
if (!full) {
return;
}
for (const size of [PORTRAIT, LANDSCAPE]) {
for (const size of sizes) {
await page.setViewportSize(size);
await sleep(200);
const hidden = await hiddenUnderActionBar(page);
check(hidden.length === 0,
`${name} ${size.width}x${size.height} : caché sous la barre d'action : ${hidden.join(", ")}`);
await page.screenshot({ path: `${dir}/${name}-${size.width}x${size.height}.png` });
captures++;
}
@@ -189,7 +197,45 @@ check((await page.getAttribute("#new-game-crawford a", "href")) === "#/glossaire
"lien vers la règle de Crawford");
check((await page.textContent("#new-game-level-text")).startsWith("Joue simplement"),
"description du niveau Débutant");
await capture("1-nouvelle-partie");
// Paysage : deux colonnes au-dessus de la barre d'action, tout tient sans
// défiler, jusqu'en 844×320 ; le type de partie à gauche, le niveau et le
// conseil du maître à droite.
for (const size of [LANDSCAPE, SMALL_LANDSCAPE]) {
await page.setViewportSize(size);
await sleep(200);
const where = `${size.width}x${size.height}`;
const layout = await page.evaluate(() => {
const box = (selector) => {
const r = document.querySelector(selector).getBoundingClientRect();
return { top: r.top, bottom: r.bottom, left: r.left, right: r.right };
};
return {
scroll: document.scrollingElement.scrollHeight,
height: innerHeight,
bar: box(".new-game-actions"),
kinds: box(".segmented"),
lengths: box(".lengths"),
rule: box("#new-game-crawford"),
bars: box(".level-bars"),
name: box("#new-game-level-name"),
text: box("#new-game-level-text"),
advice: box("#new-game-advice"),
};
});
check(layout.scroll <= layout.height + 1,
`${where} : l'écran « Nouvelle partie » défile (${layout.scroll} px pour ${layout.height})`);
for (const part of ["kinds", "lengths", "rule", "bars", "name", "text", "advice"]) {
check(layout[part].top >= 0 && layout[part].bottom <= layout.bar.top + 1,
`${where} : ${part} n'est pas au-dessus de la barre d'action : ${JSON.stringify(layout)}`);
}
for (const part of ["bars", "name", "text", "advice"]) {
check(layout[part].left >= layout.kinds.right,
`${where} : ${part} n'est pas dans la colonne de droite : ${JSON.stringify(layout)}`);
}
}
await page.setViewportSize(PORTRAIT);
await sleep(200);
await capture("1-nouvelle-partie", [PORTRAIT, LANDSCAPE, SMALL_LANDSCAPE]);
// Fermer ne change rien : on revient à la partie, et les choix sont gardés.
const before = await page.evaluate(() => localStorage.getItem("ustagammon.settings"));
await page.click("#new-game-close");
@@ -240,7 +286,13 @@ for (;;) {
const detail = await page.textContent("#panel-detail");
const primary = await page.textContent("#panel-primary");
log(`partie ${games}${crawford ? " (Crawford)" : ""} : ${title} ; ${detail}`);
if (primary === "Nouvelle partie") {
const settingsLink = await page.evaluate(() => {
const a = document.getElementById("panel-settings");
return a.hidden ? null : { text: a.textContent, href: a.getAttribute("href") };
});
check(settingsLink?.text === "Changer les réglages" && settingsLink.href === "#/nouvelle-partie",
`lien « Changer les réglages » : ${JSON.stringify(settingsLink)}`);
if (primary === "Rejouer") {
check(/gagne(z)? le match$/.test(title) && detail.includes("Score final"),
`fin du match : ${title} ; ${detail}`);
const [, player, computer] = /vous (\d+), l'ordinateur (\d+)/.exec(detail) ?? [];
@@ -254,20 +306,30 @@ for (;;) {
`score en paysage : « ${box} »`);
await page.setViewportSize(PORTRAIT);
await sleep(200);
// Après le match, « Nouvelle partie » mène à l'écran des choix.
await page.click("#panel-primary");
// Après le match, « Changer les réglages » mène à l'écran des
// choix ; le fermer ramène au résultat, où « Rejouer » relance
// tout de suite un match de même longueur.
await page.click("#panel-settings");
await until("écran « Nouvelle partie » après le match", () =>
location.hash === "#/nouvelle-partie");
location.hash === "#/nouvelle-partie" && document.getElementById("panel-veil").hidden);
await page.click("#new-game-close");
await until("retour au résultat du match", () => location.hash === "#/" &&
!document.getElementById("again").hidden);
check((await page.textContent("#again")) === "Rejouer",
`après le match, barre d'actions : « ${await page.textContent("#again")} »`);
log(`match ${matches} fini : ${games} parties, ${crawfordGames} Crawford au total`);
await page.click("#again");
await until("match rejoué", () => location.hash === "#/" &&
!document.body.classList.contains("busy"));
const again = await shownMatch();
check(again.line === "Match en 3 points" && again.computer === "0" && again.player === "0",
`« Rejouer » après le match : ${JSON.stringify(again)}`);
if (crawfordGames > 0 && scoreShot) {
break;
}
check(matches < 5, `aucune partie Crawford en ${matches} matchs (graine ${SEED})`);
matches++;
games = 1;
await page.click("#new-game-start");
await until("match suivant", () => location.hash === "#/" &&
!document.body.classList.contains("busy"));
continue;
}
check(primary === "Partie suivante" && /Match en 3 points : vous \d+, l'ordinateur \d+/.test(detail),
@@ -281,6 +343,24 @@ for (;;) {
check(next.cube === 0, "partie Crawford : le videau est dessiné");
if (!crawfordShot) {
crawfordShot = true;
check(next.line === "Match en 3 points · partie Crawford",
`partie Crawford, portrait : « ${next.line} »`);
await page.setViewportSize(LANDSCAPE);
await sleep(200);
const band = await page.evaluate(() => {
const text = document.getElementById("band-text");
const pill = document.querySelector("#board .band-pill");
return {
text: text?.textContent ?? "",
fits: text !== null && pill !== null &&
text.getBBox().width <= pill.getBBox().width - 16,
};
});
check(band.text.startsWith("Partie Crawford · "),
`partie Crawford, paysage : « ${band.text} »`);
check(band.fits, `partie Crawford, paysage : la consigne déborde (« ${band.text} »)`);
await page.setViewportSize(PORTRAIT);
await sleep(200);
await capture("3-partie-crawford");
}
} else if (!scoreShot) {
+25 -10
View File
@@ -21,6 +21,7 @@
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { chromium, firefox } from "playwright";
import { hiddenUnderActionBar } from "./recette.mjs";
const [base, browserName, shots] = process.argv.slice(2);
const browserType = { chromium, firefox }[browserName];
@@ -343,6 +344,8 @@ async function checkView(width, height, when) {
const where = `${width}x${height} (${when})`;
const wrapped = await wrappedButtons(page);
check(wrapped.length === 0, `${where} : boutons sur plusieurs lignes : ${wrapped.join(", ")}`);
const hidden = await hiddenUnderActionBar(page);
check(hidden.length === 0, `${where} : caché sous la barre d'action : ${hidden.join(", ")}`);
const seen = await page.evaluate(() => {
const visible = (el) => {
if (el.closest("[hidden]")) {
@@ -809,6 +812,8 @@ async function checkAddresses() {
const where = `${name} ${width}x${height}`;
const wrapped = await wrappedButtons(tab);
check(wrapped.length === 0, `${where} : boutons sur plusieurs lignes : ${wrapped.join(", ")}`);
const hidden = await hiddenUnderActionBar(tab);
check(hidden.length === 0, `${where} : caché sous la barre d'action : ${hidden.join(", ")}`);
if (name === "question" || name === "terme") {
// La barre haute de l'aide reste en vue, et ce qu'on a
// ouvert s'arrête juste sous elle.
@@ -996,6 +1001,15 @@ async function startChosenGame() {
"« Commencer » ne ramène pas à la partie");
}
/** « Rejouer » après une partie finie : une nouvelle partie aux mêmes
* réglages commence tout de suite, sans passer par l'écran « Nouvelle
* partie ». */
async function rematch() {
await page.click("#again");
check((await page.evaluate(() => location.hash)) === "#/" && !(await page.isVisible("#new-game")),
"« Rejouer » ouvre un autre écran que la partie");
}
/** Nouvelle partie depuis le menu, par l'écran « Nouvelle partie » : effet
* immédiat, bandeau 10 s. Renvoie le journal de la partie quittée. */
async function newGameFromMenu() {
@@ -1525,8 +1539,10 @@ const won = journal.events.at(-1).winner === "player";
check((await vibrations()).at(-1) === (won ? "30,50,30,50,60" : "60"),
`fin de partie : vibration ${(await vibrations()).at(-1)}`);
check((await page.textContent("#panel-secondary")) === "Revoir la partie" &&
(await page.textContent("#panel-primary")) === "Nouvelle partie",
"fin de partie : panneau sans « Revoir la partie » et « Nouvelle partie »");
(await page.textContent("#panel-primary")) === "Rejouer" &&
(await page.textContent("#panel-settings")) === "Changer les réglages" &&
(await page.getAttribute("#panel-settings", "href")) === "#/nouvelle-partie",
"fin de partie : panneau sans « Revoir la partie », « Rejouer » et « Changer les réglages »");
await review("6-fin-de-partie");
await page.setViewportSize({ width: 844, height: 320 });
await sleep(150);
@@ -1557,11 +1573,12 @@ log(skips > 0 ? `aucun coup possible ${skips} fois (${skipsByDice} en touchant l
log(`vibrations demandées : ${(await vibrations()).length}`);
await shot("4-fin-de-partie");
// « Revoir la partie » ferme le panneau ; « Nouvelle partie » reste dans la
// barre d'actions.
// « Revoir la partie » ferme le panneau ; « Rejouer » reste dans la barre
// d'actions.
await page.click("#panel-secondary");
check(!(await page.isVisible("#panel")) && (await page.isVisible("#again")),
"« Revoir la partie » : panneau toujours là, ou pas de « Nouvelle partie »");
check(!(await page.isVisible("#panel")) && (await page.textContent("#again")) === "Rejouer" &&
(await page.isVisible("#again")),
"« Revoir la partie » : panneau toujours là, ou pas de « Rejouer »");
step("mes parties");
// Mes parties (IndexedDB), depuis le menu : une deuxième partie commencée,
@@ -1569,8 +1586,7 @@ step("mes parties");
// recharger la page.
{
const finished = await savedJournal();
await page.click("#again");
await startChosenGame();
await rematch();
await waitForPlayer();
const second = await savedJournal();
check(second.head !== finished.head || second.events.length !== finished.events.length,
@@ -1651,8 +1667,7 @@ log("compte de points : bulle avec la fiche du glossaire");
step("thème sombre");
await page.emulateMedia({ colorScheme: "dark" });
await page.click("#again");
await startChosenGame();
await rematch();
await waitForPlayer();
await shot("6-sombre");
await page.emulateMedia({ colorScheme: "light" });
+74
View File
@@ -0,0 +1,74 @@
// recette.mjs -- règles de la recette design communes aux tests dans un
// navigateur (play.mjs, accueil.mjs, match.mjs), vérifiées sur chaque écran
// capturé.
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
/**
* Les éléments interactifs visibles cachés, même en partie, sous une barre
* d'action collée en bas de l'écran (position: sticky, ancrée par bottom) :
* un bouton, un lien ou un interrupteur que la barre recouvre ne se voit
* pas et ne se touche pas. Un élément entièrement sous le bas de l'écran
* n'est pas visible : il n'est pas compté. Ce qui passe devant la barre (le
* menu, un panneau) n'est pas caché : seul compte ce que la barre recouvre
* vraiment, au point du recouvrement.
*
* Renvoie la liste des éléments cachés, décrits (vide si tout va bien).
*/
export async function hiddenUnderActionBar(target) {
return target.evaluate(() => {
const shown = (el) => {
if (el.closest("[hidden]")) {
return false;
}
const style = getComputedStyle(el);
if (style.display === "none" || style.visibility === "hidden") {
return false;
}
const r = el.getBoundingClientRect();
return r.width > 0 && r.height > 0;
};
const bars = [...document.querySelectorAll("body *")].filter((el) => {
const style = getComputedStyle(el);
return style.position === "sticky" && style.bottom !== "auto" && shown(el);
});
const describe = (el) =>
el.id
? `#${el.id}`
: `${el.tagName.toLowerCase()}${[...el.classList].map((c) => `.${c}`).join("")} « ${
el.getAttribute("aria-label") ?? el.textContent.trim().slice(0, 40)
} »`;
const hidden = [];
const interactive = document.querySelectorAll(
"button, a[href], input, select, textarea, [role=switch], [role=radio], " +
"[tabindex]:not([tabindex='-1'])",
);
for (const bar of bars) {
const b = bar.getBoundingClientRect();
for (const el of interactive) {
if (bar.contains(el) || el.contains(bar) || !shown(el)) {
continue;
}
const r = el.getBoundingClientRect();
if (r.top >= innerHeight) {
continue;
}
const top = Math.max(r.top, b.top);
const bottom = Math.min(r.bottom, b.bottom, innerHeight);
const left = Math.max(r.left, b.left);
const right = Math.min(r.right, b.right);
if (bottom - top <= 1 || right - left <= 1) {
continue;
}
const hit = document.elementFromPoint((left + right) / 2, (top + bottom) / 2);
if (hit !== null && bar.contains(hit)) {
hidden.push(
`${describe(el)} sous ${describe(bar)} (${Math.round(bottom - top)} px)`,
);
}
}
}
return hidden;
});
}
+122 -221
View File
@@ -835,6 +835,17 @@ button:disabled {
padding: 0 12px;
}
/* Fin de partie : « Changer les réglages », lien secondaire sous les
* boutons. */
.panel-link {
display: flex;
align-items: center;
justify-content: center;
min-height: 44px;
margin-top: var(--space-2);
font-weight: 700;
}
/* « Nouvelle partie commencée. Reprendre l'ancienne », 10 s, avec sa
* barre de temps. */
.banner {
@@ -2339,6 +2350,14 @@ html.landscape .welcome {
padding-bottom: var(--space-6);
}
/* Portrait : les deux colonnes l'une sous l'autre, comme une seule. */
.new-game-column {
display: flex;
flex-direction: column;
gap: var(--space-3);
min-width: 0;
}
.new-game-body .overline {
margin-top: var(--space-4);
}
@@ -2505,6 +2524,109 @@ html.landscape .welcome {
color: var(--muted);
}
/* En paysage (915 × 412, et jusqu'à 844 × 320), tout tient sans défiler :
* deux colonnes au-dessus de la barre d'action, le type de partie à
* gauche, le niveau et le conseil du maître à droite ; tout est resserré,
* et la barre d'action tient sur une ligne, le bouton à droite sous le
* pouce. */
html.landscape .new-game {
max-width: 60rem;
}
html.landscape .new-game-head {
min-height: 44px;
}
html.landscape .new-game-head h2 {
font-size: 20px;
line-height: 24px;
}
html.landscape .new-game-body {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: var(--space-6);
align-content: start;
padding-bottom: var(--space-2);
}
html.landscape .new-game-column {
gap: var(--space-2);
}
html.landscape .new-game-body .overline {
margin-top: 0;
}
html.landscape .new-game-body .during-game {
display: none;
}
html.landscape .level-bars .level {
height: 48px;
padding: 3px;
}
html.landscape .level-1 .level-bar { height: 10px; }
html.landscape .level-2 .level-bar { height: 17px; }
html.landscape .level-3 .level-bar { height: 24px; }
html.landscape .level-4 .level-bar { height: 30px; }
html.landscape .level-5 .level-bar { height: 36px; }
html.landscape .level-6 .level-bar { height: 42px; }
html.landscape .level-name {
font-size: 18px;
line-height: 22px;
}
html.landscape .level-text {
font-size: 15px;
line-height: 20px;
}
html.landscape .new-game-body .item {
min-height: 44px;
}
html.landscape .new-game-body .item-label {
line-height: 22px;
}
html.landscape .new-game-body .item small {
line-height: 18px;
}
html.landscape .new-game-actions {
flex-direction: row;
gap: var(--space-4);
align-items: center;
justify-content: flex-end;
padding: var(--space-2) 0;
}
html.landscape .new-game-kept {
order: -1;
text-align: right;
}
/* Un peu plus d'air quand la hauteur le permet (915 × 412). */
@media (min-height: 380px) {
html.landscape .new-game-column {
gap: var(--space-3);
}
html.landscape .level-bars .level {
height: 64px;
}
html.landscape .level-1 .level-bar { height: 14px; }
html.landscape .level-2 .level-bar { height: 22px; }
html.landscape .level-3 .level-bar { height: 30px; }
html.landscape .level-4 .level-bar { height: 39px; }
html.landscape .level-5 .level-bar { height: 48px; }
html.landscape .level-6 .level-bar { height: 58px; }
}
/* En paysage, comme l'écran de jeu : le plateau à gauche sur toute la
* hauteur (colonne gauche du cadre avec le videau seul), le titre, le contexte et les actions
* dans une colonne à droite. */
@@ -2549,224 +2671,3 @@ 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;
}
+1 -9
View File
@@ -63,12 +63,8 @@
<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>
@@ -147,17 +143,12 @@
<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">
@@ -195,6 +186,7 @@
<button id="panel-secondary" type="button" class="secondary"></button>
<button id="panel-primary" type="button"></button>
</div>
<a id="panel-settings" class="panel-link" href="#/nouvelle-partie" hidden></a>
</div>
</div>