Compare commits

...
Author SHA1 Message Date
Pierre MARTINandClaude Opus 5.5 c7a54a8c81 Conseil du maître : rang cité seulement pour un coup proche ou une imprécision au 2e ou 3e choix ; 1d faite, plan clos
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 05:36:11 +01:00
Pierre MARTINandClaude Opus 5.5 4fa54ad3f3 Plan 1d : retouches P1 et P2 consignées
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 05:33:13 +01:00
Pierre MARTIN f609987f4e Merge remote-tracking branch 'origin/main' into conseil-du-maitre 2026-11-10 05:23:49 +01:00
Pierre MARTINandClaude Opus 5.5 9dce965164 Phrases du maître : grosse erreur sans « sur N », rang cité seulement s'il est proche ; « Mon coup frappe un pion. »
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 05:23:48 +01:00
Pierre MARTINandClaude Opus 5.5 3ae1f23938 Plan 1d : résultat
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 05:12:43 +01:00
Pierre MARTIN 9ada35e46d Merge remote-tracking branch 'origin/match-et-reglages' into conseil-du-maitre
# Conflicts:
#	app/src/ui/board-view.ts
#	tests/browser.sh
2026-11-10 05:08:23 +01:00
Pierre MARTINandClaude Opus 5.5 526e47e79b Fusion de la MR !49 : match, niveaux et réglages, Crawford, Jacoby, Rejouer (1c)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 05:07:51 +01:00
Pierre MARTINandClaude Opus 5.5 7c88b3ba76 Feuille du maître : écarts lus dans le classement, phrase sans redite du titre, plateau à la hauteur en paysage
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 05:07:07 +01:00
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
Pierre MARTINandClaude Opus 5.5 84086900d1 Scénario du conseil du maître : des questions, comme l'aide
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 04:57:48 +01:00
Pierre MARTINandClaude Opus 5.5 cd5f70e548 Conseil du maître : test dans le navigateur, ADR 0007 et 0013 amendés
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 04:49:03 +01:00
Pierre MARTINandClaude Opus 5.5 5d6cbec89b Mise en forme (Biome)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 04:47:23 +01:00
Pierre MARTINandClaude Opus 5.5 5897925b33 Conseil du maître : ligne du maître, feuille d'explication, second moteur pour l'analyse
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 04:47:14 +01:00
Pierre MARTINandClaude Opus 5.5 1330ee6df4 Conseil du maître : analyse des décisions, phrases du maître, conseil rattaché au journal
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 04:36:57 +01:00
Pierre MARTIN 16fc836cb9 Merge remote-tracking branch 'origin/match-et-reglages' into conseil-du-maitre 2026-11-10 00:31:12 +01:00
Pierre MARTINandClaude Opus 5.5 5eaf6743b6 Fusion de la MR !48 : publication, session CapRover vérifiée et version servie contrôlée
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 00:20:51 +01:00
Pierre MARTINandClaude Opus 5.5 715176e144 Publication : session CapRover vraiment vérifiée, version servie contrôlée
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 00:20:44 +01:00
Pierre MARTINandClaude Opus 5.5 67bd207337 Fusion d'origin/main (MR !44) ; 1c faite, plan clos
Conflits : l'aide garde l'approche de main (défilement sur la carte du
terme) ; styles des deux côtés conservés.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 00:14:58 +01:00
Pierre MARTINandClaude Opus 5.5 dd18a0cc21 Fusion de la MR !44 : accueil, tutoriel des règles, aide en questions, installation et mise à jour (0e.5)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 00:10:44 +01:00
Pierre MARTINandClaude Opus 5.5 86c0ebfc36 Recette design : tutoriel en paysage, sens du jeu, bandeau de mise à jour
- Sens du jeu (étape 2) : le tracé passe sous les pions et sous les
  numéros des flèches ; liseré ivoire de 8 px, points de 4 px à l'écran.
- Tutoriel en paysage : plateau à gauche sur toute la hauteur (géométrie
  du jeu en paysage, colonne gauche du cadre), colonne à droite avec la
  barre de progression, le titre, le texte, la consigne et les boutons ;
  seul le texte peut défiler. Tient en 915x412 et 844x320.
- Recette automatique étendue au tutoriel : numéros d'au moins 12 px,
  page sans défilement et boutons en vue en paysage, captures en 844x320.
- Boutons du videau « Prendre » et « Passer », lien « Quitter ».
- Bandeau « Nouvelle version prête. » en bas, au-dessus de la barre
  d'actions, seulement entre deux tours (betweenTurns, testé).
- Test des adresses : le lien Aide est lu au moment du toucher, la carte
  du terme ouvert est mesurée sous la barre de l'aide.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 00:10:15 +01:00
Pierre MARTINandClaude Opus 5.5 7c10c688c3 Tutoriel : étape 7 réécrite, « Prendre », « Passer » et « Quitter »
Texte de l'étape 7 remplacé par la version de la recette design (gammon,
backgammon et règle de Jacoby en un paragraphe), légende du plateau
gardée. Le texte du tutoriel est validé par Pierre (9 novembre 2026).
Scénario : les réponses au double sont « Prendre » et « Passer », le lien
du haut est « Quitter ».

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 00:10:15 +01:00
Pierre MARTINandClaude Opus 5.5 1e9dfbb74b Aide : le terme ouvert s'arrête sous la barre ; test du match : attendre l'enregistrement avant de recharger
Après la fusion de main, l'aide amenait la carte du terme en haut alors
que la marge de défilement est posée sur son titre (dt).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 00:03:17 +01:00
Pierre MARTINandClaude Opus 5.5 414dfa79f8 Plan 1c : résultat
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 23:37:36 +01:00
Pierre MARTINandClaude Opus 5.5 d1e234bb11 Fusion de main (MR !43) dans accueil-tutoriel-aide
Conflits résolus en gardant les deux apports : barre de l'aide collée
(scroll-margin sur la carte du terme) et position partagée en paysage.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 23:37:15 +01:00
Pierre MARTINandClaude Opus 5.5 4357f7e3f2 Fusion d'origin/main dans match-et-reglages
Conflits résolus dans web/app.css (styles ajoutés des deux côtés) et
app/src/ui/help.ts (commentaire de main, fiche de la branche).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 23:32:07 +01:00
Pierre MARTINandClaude Opus 5.5 85bdf4bb93 Interface : écran Nouvelle partie, score du match et partie Crawford
Écran #/nouvelle-partie (type de partie, niveau, conseil du maître,
choix gardés), score du match en portrait et en paysage, videau caché
pendant la partie Crawford, « Partie suivante » ; test navigateur d'un
match en 3 points jusqu'au bout.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 23:31:26 +01:00
Pierre MARTINandClaude Opus 5.5 809b5ec832 Fusion de la MR !47 : décisions de Pierre du 2026-11-09, journal
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 23:31:16 +01:00
Pierre MARTINandClaude Opus 5.5 1eeb6c95ae Décisions de Pierre du 2026-11-09 ; journal
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 19:12:54 +01:00
Pierre MARTINandClaude Opus 5.5 339d699488 Domaine : match, niveau de l'ordinateur, événements en version 2
Partie de match (score et Crawford dits par le moteur), niveau choisi
porté par GameStarted, ordinateur joué comme ComputerTurn de gnubg ;
migration des journaux de la version 1 ; scénarios, glossaire, ADR 0007
et 0013 amendés.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 19:07:21 +01:00
Pierre MARTINandClaude Opus 5.5 65a4638475 Fusion de la MR !43 : une adresse pour chaque écran, et un lien pour chaque position (0e.4)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 19:03:28 +01:00
Pierre MARTINandClaude Opus 5.5 c0f827c5ba Moteur : videau de l'ordinateur comme ComputerTurn, match après une partie, Match ID d'un match
Fenêtre de marché et double facultatif à 0 pli (play.c), réponse au
double, règle de Crawford automatique ; commandes double, take et
aftergame d'engine-cli, matchid étendu aux matchs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 18:42:35 +01:00
pierre 8e5be8e339 Fusion de la MR !46 : tutoriel, Jacoby 2026-11-09 17:38:55 +00:00
Pierre MARTINandClaude Opus 5.5 e724368f09 Charte : tutoriel, étape 7 avec la règle de Jacoby, lien « Quitter »
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 18:38:53 +01:00
Pierre MARTINandClaude Opus 5.5 2352a5317f Fusion de la MR !45 : « Grosse erreur » plutôt que « gaffe »
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 18:37:41 +01:00
Pierre MARTINandClaude Opus 5.5 0a88e835d2 Vocabulaire : « Grosse erreur » plutôt que « gaffe » (choix de Pierre)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 18:37:29 +01:00
Pierre MARTINandClaude Opus 5.5 71bff4def1 Accueil, tutoriel, aide : plan clos ; adresses ajoutées à l'ADR 0013
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 15:49:16 +01:00
Pierre MARTINandClaude Opus 5.5 8fd1ba3f4f Lint : formatage, ShellCheck sur le nettoyage du second site
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 15:36:19 +01:00
Pierre MARTINandClaude Opus 5.5 5fdca812d9 Tests navigateur : accueil, tutoriel, aide, installation, mise à jour ; plan : résultat
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 15:35:00 +01:00
Pierre MARTINandClaude Opus 5.5 441bca1bd7 Accueil, tutoriel, aide en questions, installation et mise à jour de l'application
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 15:17:49 +01:00
Pierre MARTINandClaude Opus 5.5 3f5b00133d Tutoriel : texte source, lecture, vérification par le moteur, adresses
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 14:55:32 +01:00
Pierre MARTIN f1c589eff1 Merge remote-tracking branch 'origin/adresses-ecrans' into accueil-tutoriel-aide 2026-11-09 14:45:10 +01:00
pierre dd5954a113 Fusion de la MR !42 : écrans 1e à 1i 2026-11-09 13:01:54 +00:00
Pierre MARTINandClaude Opus 5.5 4cdef612fd Charte : écrans de l'étape 1, lots 1e à 1i (bilan, analyse, IA, leçons, progression, gestes)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 14:01:51 +01:00
pierre 2eaa8ef304 Fusion de la MR !41 : écrans 1d 2026-11-09 12:53:29 +00:00
Pierre MARTINandClaude Opus 5.5 71a30cb2b6 Charte : écrans de l'étape 1, lot 1d (ligne et feuille du maître, phrases)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 13:53:27 +01:00
pierre 76fd72bbb5 Fusion de la MR !40 : écrans 1c 2026-11-09 12:47:21 +00:00
Pierre MARTINandClaude Opus 5.5 0e816564f9 Charte : écrans de l'étape 1, lot 1c (nouvelle partie, score de match)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 13:47:19 +01:00
109 changed files with 11929 additions and 614 deletions
+5 -3
View File
@@ -127,8 +127,10 @@ 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 et des scénarios sous docs/, lues par l'aide de
# l'application (ADR 0008 : jamais commitées ailleurs) ;
# - les copies du glossaire, des scénarios, du tutoriel et des phrases du
# maître sous docs/, lues par l'aide, le tutoriel et le conseil du maître
# (ADR 0008 : jamais commitées
# ailleurs) ;
# - version.json (commit publié) ;
# - sw.js, complété par la liste de tous ces fichiers (scripts/service-worker.sh).
site: engine-wasm web ## Assemble le site publié dans build/site/
@@ -140,7 +142,7 @@ site: engine-wasm web ## Assemble le site publié dans build/site/
cp -r $(BUILD_DIR)/web/src $(BUILD_DIR)/site/app
cp $(BUILD_DIR)/wasm/engine.mjs $(BUILD_DIR)/wasm/engine.wasm $(BUILD_DIR)/wasm/engine.data \
$(BUILD_DIR)/wasm/positions.json $(BUILD_DIR)/site/wasm/
cp -r docs/glossaire docs/scenarios $(BUILD_DIR)/site/docs/
cp -r docs/glossaire docs/scenarios docs/tutoriel docs/maitre $(BUILD_DIR)/site/docs/
printf '{"commit": "%s", "dirty": %s}\n' "$$(git rev-parse HEAD)" \
"$$(git diff --quiet HEAD && echo false || echo true)" > $(BUILD_DIR)/site/version.json
scripts/service-worker.sh $(BUILD_DIR)/site
+166
View File
@@ -0,0 +1,166 @@
// L'analyse des décisions du joueur par le moteur, pour le conseil du
// maître (étape 1d, ADR 0011) et le bilan de fin de partie (1e).
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
//
// Tout vient du moteur : l'analyse du coup joué (rang, meilleur coup, équité
// perdue, appréciation), le classement des coups, l'analyse de la décision
// de videau, et les plateaux décodés, où se lisent les pions frappés et les
// points faits. L'application lance ces analyses sur un second moteur, en
// arrière-plan : elles ne retardent jamais la partie.
import { ADVICE_VERSION, type Advice, type AdvisedMove, type MoveFacts } from "../game/advice.js";
import type { BoardSide, CubeAction, Engine, EvaluatedMove, Ply } from "../game/engine.js";
import { currentLine, type GameEvent, type GameJournal } from "../game/events.js";
import { engineCube, STANDARD_PLY } from "../game/game.js";
import { replay } from "../game/state.js";
/** Niveau de l'analyse : 2 plis, comme l'analyse de partie de gnubg par
* défaut, et le niveau « standard » (ADR 0009). */
export const ANALYSIS_PLY: Ply = STANDARD_PLY;
/** Nombre de coups gardés du classement du moteur, pour « Voir les
* chiffres du moteur ». */
export const RANKING_SIZE = 5;
/**
* Les décisions du joueur à analyser parmi des événements : ses coups (pas
* un tour passé faute de coup possible), ses jets (il aurait pu doubler
* avant), ses doubles et ses réponses aux doubles de l'ordinateur.
*/
export function playerDecisions(events: readonly GameEvent[]): GameEvent[] {
return events.filter((event) => {
switch (event.type) {
case "MovePlayed":
return event.side === "player" && event.halfMoves.length > 0;
case "DiceRolled":
case "CubeOffered":
case "CubeTaken":
case "CubeDropped":
return event.side === "player";
default:
return false;
}
});
}
/**
* Le conseil du moteur sur la décision du joueur eventId, au niveau ply ;
* null s'il n'y a rien à analyser (pas une décision du joueur, aucun coup
* possible, videau inaccessible ou position de départ pour gnubg).
*/
export async function analyseDecision(
engine: Engine,
journal: GameJournal,
eventId: string,
ply: Ply = ANALYSIS_PLY,
): Promise<Advice | null> {
const line = currentLine({ ...journal, head: eventId });
const event = line.at(-1);
if (event === undefined || playerDecisions([event]).length === 0) {
return null;
}
const earlier = line.slice(0, -1);
const before = replay(earlier);
if (event.type === "MovePlayed") {
if (before.roll === null) {
return null;
}
const cube = engineCube(before, "player");
// Le coup joué est enregistré vu de l'ordinateur : le moteur donne la
// même position vue du joueur.
const after = await engine.board(event.position, engineCube(before, "computer"));
const analysis = await engine.analyseMove(
before.position,
cube,
before.roll,
after.swappedPosition,
ply,
);
if (analysis === null) {
return null;
}
const ranking = await engine.rankMoves(before.position, cube, before.roll, ply);
const start = await engine.board(before.position, cube);
const facts = async (move: EvaluatedMove) =>
factsOf(start.onRoll, start.opponent, await engine.board(move.position, cube));
return {
version: ADVICE_VERSION,
kind: "move",
eventId,
ply,
position: before.position,
roll: before.roll,
opening: !earlier.some((e) => e.type === "MovePlayed"),
moveCount: analysis.moveCount,
rank: analysis.rank,
played: advised(analysis.played),
best: advised(analysis.best),
playedFacts: await facts(analysis.played),
bestFacts: await facts(analysis.best),
ranking: ranking.slice(0, RANKING_SIZE).map(advised),
equityLoss: analysis.equityLoss,
skill: analysis.skill,
};
}
let action: CubeAction;
let decider: "player" | "computer" = "player";
if (event.type === "DiceRolled") {
if (before.phase !== "rolling") {
return null;
}
action = "noDouble";
} else if (event.type === "CubeOffered") {
action = "double";
} else {
action = event.type === "CubeTaken" ? "take" : "pass";
decider = "computer";
}
const analysis = await engine.analyseCube(
before.position,
engineCube(before, decider),
action,
ply,
);
if (!analysis.analysed || analysis.equities === null) {
return null;
}
return {
version: ADVICE_VERSION,
kind: "cube",
eventId,
ply,
action,
position: before.position,
equities: analysis.equities,
equityLoss: analysis.equityLoss,
skill: analysis.skill,
};
}
function advised(move: EvaluatedMove): AdvisedMove {
return {
notation: move.notation,
halfMoves: move.halfMoves.map((h) => ({ from: h.from, to: h.to })),
equity: move.equity,
ply: move.ply,
};
}
/** Ce que le coup a changé sur le plateau, lu sur les plateaux du moteur
* (vus du joueur) : pions adverses envoyés sur la barre, cases où le
* joueur a désormais deux pions ou plus. */
function factsOf(
mine: BoardSide,
theirs: BoardSide,
after: { readonly onRoll: BoardSide; readonly opponent: BoardSide },
): MoveFacts {
const madePoints: number[] = [];
for (let i = 0; i < 24; i++) {
if ((mine.points[i] ?? 0) < 2 && (after.onRoll.points[i] ?? 0) >= 2) {
madePoints.push(i + 1);
}
}
return { hits: Math.max(0, after.opponent.bar - theirs.bar), madePoints };
}
+267
View File
@@ -0,0 +1,267 @@
// Les mots et les phrases du maître : lecture de leur source
// (docs/maitre/phrases.md, ADR 0008 : une seule source) et remplissage des
// gabarits avec un conseil du moteur. Fonctions pures, testées sous Node
// sur de vraies analyses (app/tests/advice.test.ts).
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
import {
type Advice,
type CubeAdvice,
type MoveAdvice,
type MoveFacts,
masterCubeAction,
type Verdict,
verdictOf,
} from "../game/advice.js";
import type { CubeAction } from "../game/engine.js";
/** Les gabarits, par clé de section (« coup.meilleur »…). */
export type MasterPhrases = ReadonlyMap<string, readonly string[]>;
/** Longueur maximale de la ligne du maître (mot et phrase), en caractères
* (charte, « Les phrases du maître ») : elle doit rester en dessous. */
export const LINE_LIMIT = 140;
/** Ce que le maître dit d'une décision. */
export interface MasterWords {
readonly verdict: Verdict;
/** Le mot d'appréciation, sans point : « Bon coup », « Erreur »… */
readonly word: string;
/** La phrase du maître. */
readonly phrase: string;
/** Le titre de la feuille d'explication. */
readonly title: string;
/** Le contexte de la décision : « votre 3-1 d'ouverture »… */
readonly context: string;
}
/** Lit la source des phrases : les sections « ## clé » à partir de la
* première « ## mot.… », un gabarit par ligne « - … ». */
export function parsePhrases(text: string): MasterPhrases {
const phrases = new Map<string, string[]>();
let key: string | null = null;
let started = false;
for (const raw of text.split("\n")) {
const line = raw.trim();
const heading = /^## (\S+)$/.exec(line);
if (heading !== null) {
const name = heading[1] as string;
started ||= name.startsWith("mot.");
key = started ? name : null;
if (key !== null) {
phrases.set(key, []);
}
} else if (key !== null && line.startsWith("- ")) {
phrases.get(key)?.push(line.slice(2).trim());
}
}
return phrases;
}
/** Les clés que la source doit contenir (vérifié par les tests). */
export const PHRASE_KEYS: readonly string[] = [
"mot.coup.bon",
"mot.videau.bon",
"mot.douteux",
"mot.mauvais",
"mot.tres-mauvais",
"coup.meilleur",
"coup.seul",
"coup.proche",
"coup.douteux",
"coup.mauvais",
"coup.tres-mauvais",
"coup.loin",
"fait.frappe",
"fait.frappe-plusieurs",
"fait.point",
"fait.frappe-et-point",
"fait.maitre-frappe",
"fait.maitre-point",
"fait.joueur-frappe",
...["bon", "proche", "autre"].flatMap((group) =>
["double", "pas-de-double", "prendre", "passer"].map((a) => `videau.${group}.${a}`),
),
"titre.coup.meme",
"titre.coup.autre",
"titre.videau.meme",
...["double", "pas-de-double", "prendre", "passer"].map((a) => `titre.videau.${a}`),
"contexte.coup",
"contexte.coup.ouverture",
"contexte.videau.double",
"contexte.videau.pas-de-double",
"contexte.videau.reponse",
];
/** Ce que le maître dit d'un conseil du moteur. */
export function masterWords(phrases: MasterPhrases, advice: Advice): MasterWords {
return advice.kind === "move" ? moveWords(phrases, advice) : cubeWords(phrases, advice);
}
/** La ligne du maître telle qu'elle s'affiche : « Erreur. J'aurais… ». */
export function masterLine(words: MasterWords): string {
return `${words.word}. ${words.phrase}`;
}
const ACTION_KEYS: Readonly<Record<CubeAction, string>> = {
double: "double",
noDouble: "pas-de-double",
take: "prendre",
pass: "passer",
};
function wordKey(verdict: Verdict, kind: Advice["kind"]): string {
switch (verdict) {
case "good":
return kind === "move" ? "mot.coup.bon" : "mot.videau.bon";
case "doubtful":
return "mot.douteux";
case "bad":
return "mot.mauvais";
case "veryBad":
return "mot.tres-mauvais";
}
}
function moveWords(phrases: MasterPhrases, advice: MoveAdvice): MasterWords {
const verdict = verdictOf(advice.skill);
const word = pick(phrases, wordKey(verdict, "move"), advice.eventId);
const values = {
best: advice.best.notation,
played: advice.played.notation,
rank: String(advice.rank),
roll: `${advice.roll[0]}-${advice.roll[1]}`,
};
const fill = (key: string, extra: Readonly<Record<string, string>> = {}) =>
fillIn(pick(phrases, key, advice.eventId), { ...values, ...extra });
let base: string;
let fact: string | null;
if (advice.moveCount <= 1) {
base = fill("coup.seul");
fact = null;
} else if (advice.rank === 1) {
base = fill("coup.meilleur");
fact = selfFact(phrases, advice.eventId, advice.played.notation, advice.playedFacts);
} else {
// Le rang n'est cité que pour un bon coup non meilleur ou une
// imprécision, au 2e ou 3e choix : il mesure mal la gravité d'une
// erreur, et le classement est dans les chiffres du moteur.
const near = advice.rank <= 3;
const key = {
good: near ? "coup.proche" : "coup.loin",
doubtful: near ? "coup.douteux" : "coup.loin",
bad: "coup.mauvais",
veryBad: "coup.tres-mauvais",
}[verdict];
base = fill(key);
fact = differenceFact(phrases, advice);
}
const withFact = fact === null ? base : `${base} ${fact}`;
const phrase = `${word}. ${withFact}`.length < LINE_LIMIT ? withFact : base;
return {
verdict,
word,
phrase,
title: fill(advice.rank === 1 ? "titre.coup.meme" : "titre.coup.autre"),
context: fill(advice.opening ? "contexte.coup.ouverture" : "contexte.coup"),
};
}
/** Ce que fait le coup lui-même : pions frappés, points faits. */
function selfFact(
phrases: MasterPhrases,
id: string,
move: string,
facts: MoveFacts,
): string | null {
const values = { move, points: pointsText(facts.madePoints), hits: String(facts.hits) };
const made = facts.madePoints.length > 0;
if (facts.hits === 1 && made) {
return fillIn(pick(phrases, "fait.frappe-et-point", id), values);
}
if (facts.hits > 1) {
return fillIn(pick(phrases, "fait.frappe-plusieurs", id), values);
}
if (facts.hits === 1) {
return fillIn(pick(phrases, "fait.frappe", id), values);
}
return made ? fillIn(pick(phrases, "fait.point", id), values) : null;
}
/** Un fait qui distingue le coup du maître du coup joué, s'il y en a un. */
function differenceFact(phrases: MasterPhrases, advice: MoveAdvice): string | null {
const best = advice.bestFacts;
const played = advice.playedFacts;
if (best.hits > 0 && played.hits === 0) {
return pick(phrases, "fait.maitre-frappe", advice.eventId);
}
const onlyBest = best.madePoints.filter((p) => !played.madePoints.includes(p));
if (onlyBest.length > 0) {
return fillIn(pick(phrases, "fait.maitre-point", advice.eventId), {
points: pointsText(onlyBest),
});
}
if (played.hits > 0 && best.hits === 0) {
return pick(phrases, "fait.joueur-frappe", advice.eventId);
}
return null;
}
function cubeWords(phrases: MasterPhrases, advice: CubeAdvice): MasterWords {
const verdict = verdictOf(advice.skill);
const master = masterCubeAction(advice);
const same = master === advice.action;
const group = verdict !== "good" ? "autre" : same ? "bon" : "proche";
const shown = group === "bon" ? advice.action : master;
const context =
advice.action === "take" || advice.action === "pass"
? "contexte.videau.reponse"
: `contexte.videau.${ACTION_KEYS[advice.action]}`;
return {
verdict,
word: pick(phrases, wordKey(verdict, "cube"), advice.eventId),
phrase: pick(phrases, `videau.${group}.${ACTION_KEYS[shown]}`, advice.eventId),
title: pick(
phrases,
same ? "titre.videau.meme" : `titre.videau.${ACTION_KEYS[master]}`,
advice.eventId,
),
context: pick(phrases, context, advice.eventId),
};
}
/** « votre point 5 », « vos points 5 et 7 », « vos points 4, 5 et 7 ». */
function pointsText(points: readonly number[]): string {
const sorted = [...points].sort((a, b) => a - b).map(String);
if (sorted.length <= 1) {
return `votre point ${sorted[0] ?? ""}`;
}
return `vos points ${sorted.slice(0, -1).join(", ")} et ${sorted.at(-1)}`;
}
/** Un gabarit de la section key ; plusieurs tournent selon l'identifiant
* de l'événement. Erreur si la section manque. */
function pick(phrases: MasterPhrases, key: string, id: string): string {
const choices = phrases.get(key) ?? [];
if (choices.length === 0) {
throw new Error(`phrases du maître : section « ${key} » absente`);
}
const n = Number.parseInt(id, 10);
// Les identifiants des coups du joueur vont souvent de quatre en quatre :
// un brassage (hachage de Knuth, bits de poids fort) les fait tourner.
const index = (Math.imul(Number.isNaN(n) ? 0 : n, 2654435761) >>> 16) % choices.length;
return choices[index] as string;
}
/** Remplace les {repères} ; erreur sur un repère inconnu. */
function fillIn(template: string, values: Readonly<Record<string, string>>): string {
return template.replace(/\{([a-z]+)\}/g, (_, name: string) => {
const value = values[name];
if (value === undefined) {
throw new Error(`phrases du maître : repère {${name}} inconnu`);
}
return value;
});
}
+124
View File
@@ -0,0 +1,124 @@
// Le conseil du maître sur une décision du joueur : ce que le moteur en dit,
// rattaché à l'événement de la décision dans le journal (étape 1d).
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
//
// Ce fichier ne contient que des données : tout ce qu'elles disent vient du
// moteur (analyse du coup, classement des coups, analyse de la décision de
// videau, plateaux décodés). Le calcul est dans domain/analysis/ ; le
// stockage, dans le journal (events.ts, champ advice). Le format est
// durable (ADR 0007) : chaque conseil porte sa version (ADVICE_VERSION).
import type { CubeAction, CubeEquities, Ply, Skill } from "./engine.js";
import type { HalfMove, PositionId } from "./position.js";
import type { Roll } from "./roll.js";
/** Version du format d'un conseil enregistré. Un conseil d'une autre
* version est ignoré à la reprise (il peut être recalculé). */
export const ADVICE_VERSION = 1;
/**
* Le conseil du maître sur une décision du joueur : un coup ou une décision
* de videau, avec l'appréciation du moteur.
*
* Glossaire : docs/glossaire/conseil-du-maitre.md
*/
export type Advice = MoveAdvice | CubeAdvice;
/** Un coup, tel que le conseil le retient. */
export interface AdvisedMove {
/** Notation de gnubg, par exemple « 8/5 6/5 ». */
readonly notation: string;
readonly halfMoves: readonly HalfMove[];
/** Équité (avec videau) du point de vue du joueur, et le niveau auquel
* le moteur l'a évaluée. */
readonly equity: number;
readonly ply: Ply;
}
/** Ce que l'on lit sur les plateaux décodés par le moteur, avant et après
* un coup : de quoi écrire une phrase sans rien inventer. */
export interface MoveFacts {
/** Pions adverses envoyés sur la barre. */
readonly hits: number;
/** Cases (numérotation du joueur) où il n'avait pas deux pions avant
* le coup et en a deux ou plus après : les points qu'il fait. */
readonly madePoints: readonly number[];
}
export interface MoveAdvice {
readonly version: number;
readonly kind: "move";
/** L'événement MovePlayed du coup. */
readonly eventId: string;
/** Niveau de l'analyse, en plis. */
readonly ply: Ply;
/** La position avant le coup, vue du joueur, et son jet. */
readonly position: PositionId;
readonly roll: Roll;
/** Le premier coup de la partie (après le jet d'ouverture). */
readonly opening: boolean;
/** Nombre de coups légaux et rang du coup joué (à partir de 1). */
readonly moveCount: number;
readonly rank: number;
readonly played: AdvisedMove;
readonly best: AdvisedMove;
readonly playedFacts: MoveFacts;
readonly bestFacts: MoveFacts;
/** Les meilleurs coups du classement du moteur, du meilleur au moins
* bon (cinq au plus). */
readonly ranking: readonly AdvisedMove[];
/** Équité du coup joué moins celle du meilleur : 0 ou négative. */
readonly equityLoss: number;
readonly skill: Skill;
}
export interface CubeAdvice {
readonly version: number;
readonly kind: "cube";
/** L'événement de la décision : DiceRolled (le joueur lance sans
* doubler), CubeOffered (il double), CubeTaken ou CubeDropped (il
* répond au double de l'ordinateur). */
readonly eventId: string;
readonly ply: Ply;
/** La décision du joueur. */
readonly action: CubeAction;
/** La position, vue de celui qui double ou non (l'ordinateur quand le
* joueur prend ou passe). */
readonly position: PositionId;
/** Équités de celui qui double ou non, pour un videau de 1. */
readonly equities: CubeEquities;
/** Équité perdue par le joueur : 0 ou négative. */
readonly equityLoss: number;
readonly skill: Skill;
}
/** L'appréciation dite au joueur : celle du moteur, « rien à signaler »
* devenant « bon ». */
export type Verdict = "good" | "doubtful" | "bad" | "veryBad";
export function verdictOf(skill: Skill): Verdict {
return skill === "none" ? "good" : skill;
}
/** L'autre choix d'une décision de videau. */
export function otherCubeAction(action: CubeAction): CubeAction {
switch (action) {
case "double":
return "noDouble";
case "noDouble":
return "double";
case "take":
return "pass";
case "pass":
return "take";
}
}
/** La décision que le maître aurait prise : celle du joueur s'il n'a perdu
* aucune équité, l'autre sinon (une décision de videau n'a que deux
* choix). */
export function masterCubeAction(advice: CubeAdvice): CubeAction {
return advice.equityLoss < 0 ? otherCubeAction(advice.action) : advice.action;
}
+51 -3
View File
@@ -51,6 +51,24 @@ export interface Engine {
/** La décision de videau de l'ordinateur à ce niveau, bruit compris. */
levelCubeDecision(position: PositionId, cube: EngineCube, level: Level): Promise<CubeDecision>;
/** L'ordinateur au trait, à ce niveau, double-t-il avant de lancer les
* dés ? Comme ComputerTurn de gnubg (play.c) : accès au videau, fenêtre
* de marché, double facultatif seulement à 0 pli. */
levelDouble(position: PositionId, cube: EngineCube, level: Level): Promise<boolean>;
/** Le joueur au trait double : l'ordinateur, son adversaire, prend-il à
* ce niveau ? Comme ComputerTurn de gnubg (play.c). */
levelTake(position: PositionId, cube: EngineCube, level: Level): Promise<boolean>;
/** Le match après une partie gagnée par winner pour points points :
* score, fin du match et partie Crawford suivante, comme gnubg de
* bureau. */
matchAfterGame(
match: EngineMatch,
winner: "onRoll" | "opponent",
points: number,
): Promise<EngineMatchAfter>;
/** L'analyse du coup joué (played : position après le coup, vue du joueur
* qui l'a joué, comme Move.position), au niveau d'analyse ply ; null si
* aucun coup n'était possible. */
@@ -62,6 +80,17 @@ export interface Engine {
ply: Ply,
): Promise<MoveAnalysis | null>;
/** Les coups légaux d'un jet classés du meilleur au moins bon, au
* niveau ply, comme la commande « hint » de gnubg (les coups écartés
* par ses filtres gardent l'évaluation d'un niveau inférieur, voir leur
* ply). */
rankMoves(
position: PositionId,
cube: EngineCube,
roll: Roll,
ply: Ply,
): Promise<EvaluatedMove[]>;
/** L'analyse d'une décision de videau, position vue du joueur au trait
* (celui qui double ou non ; take et pass sont la réponse de son
* adversaire). */
@@ -75,8 +104,8 @@ export interface Engine {
/** La chance d'un jet pour le joueur au trait. */
luck(position: PositionId, cube: EngineCube, roll: Roll): Promise<Luck>;
/** Le Match ID d'une partie libre, joueur 0 au trait, avec ce videau vu
* de lui, sans dés. */
/** Le Match ID, joueur 0 au trait, avec ce videau et ce match vus de lui
* (partie libre sans match), sans dés. */
encodeMatch(cube: EngineCube): Promise<MatchId>;
/** Un Match ID décodé ; erreur s'il est illisible pour le moteur. */
@@ -212,10 +241,29 @@ export interface CubeEquities {
*/
export type Ply = number;
/** Le videau vu du joueur au trait. */
/** Le videau vu du joueur au trait, et le match s'il y en a un (sans lui :
* partie libre, avec la règle Jacoby). */
export interface EngineCube {
readonly value: number;
readonly owner: "centered" | "onRoll" | "opponent";
readonly match?: EngineMatch;
}
/** Un match vu du joueur au trait : sa longueur, le score avant la partie,
* et si la partie est la partie Crawford. Le moteur le code dans le Match
* ID. */
export interface EngineMatch {
readonly length: number;
readonly score: { readonly onRoll: number; readonly opponent: number };
readonly crawford: boolean;
}
/** Le match après une partie, vu du même joueur que le match d'avant. */
export interface EngineMatchAfter {
readonly score: { readonly onRoll: number; readonly opponent: number };
readonly over: boolean;
/** La partie suivante est la partie Crawford. */
readonly crawford: boolean;
}
/**
+95 -7
View File
@@ -12,14 +12,80 @@
// 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 { ResultKind } from "./engine.js";
import type { Level, ResultKind } from "./engine.js";
import type { Match, MatchAfterGame } from "./match.js";
import type { HalfMove, PositionId } from "./position.js";
import type { Roll } from "./roll.js";
import type { Side } from "./side.js";
/** Version du format des événements. */
export const EVENT_VERSION = 1;
/**
* Version du format des événements :
* - 1 : jusqu'à l'étape 1b, partie libre au niveau 2 plis ;
* - 2 (étape 1c) : GameStarted porte le niveau de l'ordinateur (level) et,
* pour une partie de match, le match (match) ; GameEnded porte le match
* après la partie. migrateJournal passe un journal de la version 1 à la 2.
*/
export const EVENT_VERSION = 2;
/** Le niveau des parties de la version 1 : 2 plis (STANDARD_PLY, sans
* bruit), le seul qui existait. */
const VERSION_1_LEVEL: Level = "2ply";
/**
* Un journal enregistré, à la version courante du format. Version 1 : le
* niveau, implicite, devient explicite (2 plis) ; rien d'autre ne change,
* 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);
if (journal.events.every((event) => event.version === EVENT_VERSION)) {
return journal;
}
const events = journal.events.map((event): GameEvent => {
if (event.version !== 1) {
return event;
}
if (event.type === "GameStarted") {
return Object.freeze({
...event,
level: event.level ?? VERSION_1_LEVEL,
version: EVENT_VERSION,
});
}
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) };
}
interface EventBase {
/** Identifiant, unique dans la partie. */
@@ -31,12 +97,18 @@ interface EventBase {
/** Début de partie : la position, le videau, et qui commence (null : le jet
* d'ouverture le dira). La position est vue de celui qui commence ; celle de
* départ est la même pour les deux. */
* départ est la même pour les deux. level et match viennent avec la version
* 2 du format (étape 1c). */
export interface GameStarted extends EventBase {
readonly type: "GameStarted";
readonly position: PositionId;
readonly cube: Cube;
readonly firstTurn: Side | null;
/** Le niveau de l'ordinateur ; absent : DEFAULT_LEVEL (state.ts). Les
* journaux de la version 1 l'ont après migrateJournal. */
readonly level?: Level;
/** Le match dont c'est une partie ; absent : partie libre. */
readonly match?: Match;
}
/** Des dés tirés. Pour le jet d'ouverture, side vaut null et chacun lance un
@@ -80,6 +152,9 @@ export interface GameEnded extends EventBase {
readonly winner: Side;
readonly kind: ResultKind;
readonly points: number;
/** Dans un match : le match après cette partie, dit par le moteur
* (absent en partie libre). */
readonly match?: MatchAfterGame;
}
/**
@@ -104,10 +179,14 @@ export type NewGameEvent = WithoutBase<GameEvent>;
type WithoutBase<E> = E extends unknown ? Omit<E, keyof EventBase> : never;
/** Tous les événements d'une partie, branches comprises, et l'événement
* courant (head) : la partie en est là. */
* courant (head) : la partie en est là. advice (étape 1d, facultatif) : le
* conseil du maître sur les décisions du joueur, par identifiant de
* l'événement de la décision. Ce n'est pas un événement : il arrive après
* coup, quand l'analyse du moteur est prête, et ne change pas la partie. */
export interface GameJournal {
readonly events: readonly GameEvent[];
readonly head: string | null;
readonly advice?: Readonly<Record<string, Advice>>;
}
export const EMPTY_JOURNAL: GameJournal = { events: [], head: null };
@@ -120,7 +199,16 @@ export function appendEvent(journal: GameJournal, event: NewGameEvent): GameJour
parent: journal.head,
version: EVENT_VERSION,
}) as GameEvent;
return { events: [...journal.events, added], head: added.id };
return { ...journal, events: [...journal.events, added], head: added.id };
}
/** Rattache un conseil du maître à son événement (il remplace un conseil
* déjà rattaché). Erreur si l'événement n'est pas dans le journal. */
export function withAdvice(journal: GameJournal, advice: Advice): GameJournal {
if (!journal.events.some((event) => event.id === advice.eventId)) {
throw new Error(`événement introuvable : ${advice.eventId}`);
}
return { ...journal, advice: { ...journal.advice, [advice.eventId]: advice } };
}
/** Les événements du début jusqu'à l'événement courant, dans l'ordre. */
@@ -144,5 +232,5 @@ export function goBackTo(journal: GameJournal, id: string): GameJournal {
if (!journal.events.some((event) => event.id === id)) {
throw new Error(`événement introuvable : ${id}`);
}
return { events: journal.events, head: id };
return { ...journal, head: id };
}
+176 -81
View File
@@ -1,4 +1,4 @@
// Une partie libre contre l'ordinateur.
// Une partie contre l'ordinateur : partie libre, ou une partie d'un match.
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
@@ -6,11 +6,13 @@
// Le joueur et l'ordinateur agissent chacun leur tour ; chaque action ajoute
// des événements au journal (events.ts), d'où l'état est recalculé
// (state.ts). Les règles viennent toutes du moteur : coups légaux, coup de
// l'ordinateur, videau, fin de partie et points.
// 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, Ply } from "./engine.js";
import type { BoardSide, Engine, EngineCube, Level, Ply, ResultKind } from "./engine.js";
import {
appendEvent,
currentLine,
@@ -19,16 +21,19 @@ import {
type GameEvent,
type GameJournal,
goBackTo,
migrateJournal,
type NewGameEvent,
withAdvice,
} from "./events.js";
import { firstGameOf, type Match, nextGameOf } from "./match.js";
import { BAR, type HalfMove, OFF, type PositionId, START_POSITION } from "./position.js";
import { type DiceSource, type Roll, randomDice, rollDice } from "./roll.js";
import { otherSide, type Side } from "./side.js";
import { type GameState, replay } from "./state.js";
/**
* Niveau « standard » de l'ordinateur : 2 plis. Sur le Fairphone 4 (Chrome),
* le meilleur coup à ce niveau prend au plus 348 ms (ADR 0009).
* Niveau « standard » d'analyse : 2 plis. Sur le Fairphone 4 (Chrome), le
* meilleur coup à ce niveau prend au plus 348 ms (ADR 0009).
*/
export const STANDARD_PLY: Ply = 2;
@@ -62,12 +67,19 @@ export interface MoveSteps {
readonly hits: readonly boolean[];
}
export interface GameOptions {
/** Ce que l'on choisit pour une nouvelle partie. */
export interface NewGame {
/** Une position de départ particulière (sans jet d'ouverture). */
readonly setup?: GameSetup;
/** Niveau de l'ordinateur ; DEFAULT_LEVEL (state.ts) par défaut. */
readonly level?: Level;
/** Le match dont ce sera une partie ; absent : partie libre. */
readonly match?: Match;
}
export interface GameOptions extends NewGame {
/** Source des dés ; le hasard ordinaire par défaut. */
readonly dice?: DiceSource;
/** Niveau de l'ordinateur ; STANDARD_PLY par défaut. */
readonly ply?: Ply;
readonly setup?: GameSetup;
}
/**
@@ -78,7 +90,6 @@ export interface GameOptions {
export class Game {
readonly #engine: Engine;
readonly #dice: DiceSource;
readonly #ply: Ply;
#journal: GameJournal = EMPTY_JOURNAL;
// Coup du joueur en cours de saisie : pas encore dans le journal (il n'y
// entre qu'à sa validation, confirmMove). Pour chaque demi-coup saisi, la
@@ -88,11 +99,11 @@ export class Game {
private constructor(engine: Engine, options: GameOptions) {
this.#engine = engine;
this.#dice = options.dice ?? randomDice;
this.#ply = options.ply ?? STANDARD_PLY;
}
/** Commence une partie : à la position de départ avec le jet d'ouverture,
* ou à la position de options.setup. */
* ou à la position de options.setup ; au niveau options.level, et dans
* le match options.match s'il est donné. */
static start(engine: Engine, options: GameOptions = {}): Game {
const game = new Game(engine, options);
const setup = options.setup;
@@ -101,6 +112,8 @@ export class Game {
position: setup?.position ?? START_POSITION,
cube: setup?.cube ?? CENTERED_CUBE,
firstTurn: setup?.firstTurn ?? null,
...(options.level === undefined ? {} : { level: options.level }),
...(options.match === undefined ? {} : { match: options.match }),
});
while (game.state.phase === "opening") {
game.#append({ type: "DiceRolled", side: null, roll: rollDice(game.#dice) });
@@ -112,11 +125,13 @@ export class Game {
* Reprend une partie enregistrée, à partir de son journal. Erreur si le
* journal est d'un autre format ou incohérent.
*/
static resume(engine: Engine, journal: GameJournal, options: GameOptions = {}): Game {
const first = journal.events[0];
if (first?.type !== "GameStarted" || journal.head === null) {
static resume(engine: Engine, saved: GameJournal, options: GameOptions = {}): Game {
const first = saved.events[0];
if (first?.type !== "GameStarted" || saved.head === null) {
throw new Error("journal de partie vide ou mal formé");
}
// Un journal d'une version précédente passe à la version courante.
const journal = migrateJournal(saved);
if (journal.events.some((event) => event.version !== EVENT_VERSION)) {
throw new Error("journal de partie d'une autre version");
}
@@ -141,6 +156,29 @@ export class Game {
return replay(this.events);
}
/** La partie finie appartient à un match qui continue : ce que sera la
* partie suivante (score et Crawford dits par le moteur à la fin de
* celle-ci) ; null sinon. */
get nextGame(): NewGame | null {
const state = this.state;
if (state.phase !== "over" || state.match === null || state.matchAfter === null) {
return null;
}
const match = nextGameOf(state.match, state.matchAfter);
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);
@@ -182,7 +220,7 @@ export class Game {
const state = this.state;
// La position est vue du camp qui a la main (le joueur pendant le jet
// d'ouverture : la position de départ est la même pour les deux).
return this.#sideBoards(this.displayedPosition, state.turn ?? "player", state.cube);
return this.#sideBoards(this.displayedPosition, state.turn ?? "player", state);
}
/**
@@ -191,36 +229,59 @@ export class Game {
* (checkHalfMoves, puis board) : aucune règle n'est rejouée ici.
*/
async lastMoveSteps(): Promise<MoveSteps | null> {
const events = this.events;
const index = events.findLastIndex((e) => e.type === "MovePlayed");
const played = events[index];
if (played?.type !== "MovePlayed") {
return null;
}
// L'état juste avant le coup : la position vue de celui qui joue, et
// son jet.
const before = replay(events.slice(0, index));
const roll = rollToPlay(before);
const played = this.events.findLast((e) => e.type === "MovePlayed");
return played?.type === "MovePlayed"
? this.stepsBefore(played.id, played.side, played.halfMoves)
: null;
}
/**
* Un coup de side joué demi-coup par demi-coup à partir de la position
* d'avant l'événement eventId (le coup réellement joué, ou un autre coup
* du même jet : celui du maître). Les plateaux viennent du moteur ;
* erreur s'il refuse un demi-coup. Sans demi-coup, boards ne contient
* que la position d'avant l'événement.
*/
async stepsBefore(
eventId: string,
side: Side,
halfMoves: readonly HalfMove[],
): Promise<MoveSteps> {
const line = currentLine({ ...this.#journal, head: eventId });
// L'état juste avant l'événement : la position vue de celui qui a la
// main, et son jet.
const before = replay(line.slice(0, -1));
const positions = [before.position];
for (let i = 1; i <= played.halfMoves.length; i++) {
for (let i = 1; i <= halfMoves.length; i++) {
const check = await this.#engine.checkHalfMoves(
before.position,
roll,
played.halfMoves.slice(0, i),
rollToPlay(before),
halfMoves.slice(0, i),
);
if (check.position === null) {
throw new Error("coup enregistré refusé par le moteur");
throw new Error("coup refusé par le moteur");
}
positions.push(check.position);
}
const boards = await Promise.all(
positions.map((p) => this.#sideBoards(p, played.side, before.cube)),
);
const opponent = otherSide(played.side);
const hits = played.halfMoves.map(
const onRoll = before.turn ?? side;
const boards = await Promise.all(positions.map((p) => this.#sideBoards(p, onRoll, before)));
const opponent = otherSide(side);
const hits = halfMoves.map(
(_, i) => (boards[i + 1]?.[opponent].bar ?? 0) > (boards[i]?.[opponent].bar ?? 0),
);
return { side: played.side, halfMoves: played.halfMoves, boards, hits };
return { side, halfMoves, boards, hits };
}
/** Les conseils du maître déjà rattachés aux décisions du joueur, par
* identifiant d'événement. */
get advice(): Readonly<Record<string, Advice>> {
return this.#journal.advice ?? {};
}
/** Rattache le conseil du maître à sa décision dans le journal (il sera
* enregistré avec la partie). Erreur si l'événement n'existe pas. */
recordAdvice(advice: Advice): void {
this.#journal = withAdvice(this.#journal, advice);
}
/**
@@ -260,10 +321,11 @@ export class Game {
if (state.turn !== "player" || state.phase !== "rolling") {
return false;
}
// Le droit de doubler ne dépend pas du niveau : 0 pli suffit.
// Le droit de doubler ne dépend pas du niveau : 0 pli suffit. Le
// moteur le refuse aussi pendant la partie Crawford.
const decision = await this.#engine.cubeDecision(
state.position,
engineCube(state.cube, "player"),
engineCube(state, "player"),
0,
);
return decision.available;
@@ -349,62 +411,61 @@ export class Game {
}
/** Le joueur double, avant de lancer les dés ; l'ordinateur prend ou passe
* selon le moteur. */
* à son niveau, selon le moteur. */
async double(): Promise<void> {
const state = this.#expect("player", "rolling");
const decision = await this.#engine.cubeDecision(
state.position,
engineCube(state.cube, "player"),
this.#ply,
);
const cube = engineCube(state, "player");
const decision = await this.#engine.cubeDecision(state.position, cube, 0);
if (!decision.available) {
throw new Error("vous ne pouvez pas doubler : le videau est chez l'ordinateur");
throw new Error("vous ne pouvez pas doubler maintenant");
}
const take = await this.#engine.levelTake(state.position, cube, state.level);
this.#append({ type: "CubeOffered", side: "player" });
this.#answerDouble("computer", decision.take);
await this.#answerDouble("computer", take);
}
/** Le joueur prend le double de l'ordinateur. */
take(): void {
this.#expect("computer", "doubled");
this.#answerDouble("player", true);
this.#append({ type: "CubeTaken", side: "player" });
}
/** Le joueur passe le double de l'ordinateur : il perd la partie. */
drop(): void {
async drop(): Promise<void> {
this.#expect("computer", "doubled");
this.#answerDouble("player", false);
await this.#answerDouble("player", false);
}
// --------------------------------------------------------- Ordinateur
/**
* Le tour de l'ordinateur : il double si le moteur le recommande (le
* tour s'arrête alors, en attendant la réponse du joueur), sinon il lance
* les dés et joue le meilleur coup du moteur.
* Le tour de l'ordinateur, à son niveau : il double si le moteur le dit
* (le tour s'arrête alors, en attendant la réponse du joueur), sinon il
* lance les dés et joue son coup. Le déroulement est celui de
* l'ordinateur de gnubg (ComputerTurn, play.c), repris par le moteur.
*/
async playComputerTurn(): Promise<void> {
let state = this.#expect("computer", "rolling", "moving");
if (state.phase === "rolling") {
const decision = await this.#engine.cubeDecision(
const doubles = await this.#engine.levelDouble(
state.position,
engineCube(state.cube, "computer"),
this.#ply,
engineCube(state, "computer"),
state.level,
);
if (decision.double) {
if (doubles) {
this.#append({ type: "CubeOffered", side: "computer" });
return;
}
this.#append({ type: "DiceRolled", side: "computer", roll: rollDice(this.#dice) });
state = this.state;
}
const best = await this.#engine.bestMove(
const move = await this.#engine.levelMove(
state.position,
engineCube(state.cube, "computer"),
engineCube(state, "computer"),
rollToPlay(state),
this.#ply,
state.level,
);
await this.#playMove("computer", best?.halfMoves ?? [], best?.position ?? null);
await this.#playMove("computer", move?.halfMoves ?? [], move?.position ?? null);
}
// ------------------------------------------------------------ Retour
@@ -445,8 +506,8 @@ export class Game {
}
/** Le plateau d'une position vue de onRoll, décodé par le moteur. */
async #sideBoards(position: PositionId, onRoll: Side, cube: Cube): Promise<SideBoards> {
const board = await this.#engine.board(position, engineCube(cube, onRoll));
async #sideBoards(position: PositionId, onRoll: Side, state: GameState): Promise<SideBoards> {
const board = await this.#engine.board(position, engineCube(state, onRoll));
return onRoll === "player"
? { player: board.onRoll, computer: board.opponent }
: { player: board.opponent, computer: board.onRoll };
@@ -474,10 +535,7 @@ export class Game {
after: PositionId | null,
): Promise<void> {
const state = this.state;
const board = await this.#engine.board(
after ?? state.position,
engineCube(state.cube, side),
);
const board = await this.#engine.board(after ?? state.position, engineCube(state, side));
this.#append({
type: "MovePlayed",
side,
@@ -485,28 +543,54 @@ export class Game {
position: board.swappedPosition,
});
if (board.result !== null) {
this.#append({
type: "GameEnded",
winner: board.result.winner === "onRoll" ? side : otherSide(side),
kind: board.result.kind,
points: board.result.points,
});
await this.#endGame(
board.result.winner === "onRoll" ? side : otherSide(side),
board.result.kind,
board.result.points,
);
}
}
/** taker prend ou passe le double proposé par l'autre camp. */
#answerDouble(taker: Side, take: boolean): void {
async #answerDouble(taker: Side, take: boolean): Promise<void> {
if (take) {
this.#append({ type: "CubeTaken", side: taker });
return;
}
// Passer : on perd la partie à la valeur du videau d'avant le double.
this.#append({ type: "CubeDropped", side: taker });
await this.#endGame(otherSide(taker), "single", this.state.cube.value);
}
/** Fin de partie ; dans un match, le moteur dit ce que devient le match
* (score, fin du match, partie Crawford suivante). */
async #endGame(winner: Side, kind: ResultKind, points: number): Promise<void> {
const match = this.state.match;
if (match === null) {
this.#append({ type: "GameEnded", winner, kind, points });
return;
}
// Le joueur est « au trait » pour le moteur : les scores restent à
// leur place.
const after = await this.#engine.matchAfterGame(
{
length: match.length,
score: { onRoll: match.score.player, opponent: match.score.computer },
crawford: match.crawford,
},
winner === "player" ? "onRoll" : "opponent",
points,
);
this.#append({
type: "GameEnded",
winner: otherSide(taker),
kind: "single",
points: this.state.cube.value,
winner,
kind,
points,
match: {
score: { player: after.score.onRoll, computer: after.score.opponent },
over: after.over,
crawford: after.crawford,
},
});
}
}
@@ -576,10 +660,21 @@ function occupiedPoints(side: BoardSide): number[] {
return points;
}
/** Le videau vu du camp au trait, pour le moteur. */
function engineCube(cube: Cube, onRoll: Side): EngineCube {
if (cube.owner === null) {
return { value: cube.value, owner: "centered" };
/** Le videau et le match vus du camp au trait, pour le moteur. */
export function engineCube(state: Pick<GameState, "cube" | "match">, onRoll: Side): EngineCube {
const { cube, match } = state;
const owner = cube.owner === null ? "centered" : cube.owner === onRoll ? "onRoll" : "opponent";
if (match === null) {
return { value: cube.value, owner };
}
return { value: cube.value, owner: cube.owner === onRoll ? "onRoll" : "opponent" };
const opponent = otherSide(onRoll);
return {
value: cube.value,
owner,
match: {
length: match.length,
score: { onRoll: match.score[onRoll], opponent: match.score[opponent] },
crawford: match.crawford,
},
};
}
+50
View File
@@ -0,0 +1,50 @@
// Les niveaux de l'ordinateur tels que le joueur les choisit.
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
//
// Six noms (docs/identite/charte/ecrans-etape-1.md, chapitre 1c), chacun
// relié à un des niveaux prédéfinis de GNU Backgammon (Level, engine.ts). La
// correspondance est documentée dans docs/glossaire/niveau-de-jeu.md :
// - Débutant, Amateur, Intermédiaire et Avancé : les quatre niveaux de gnubg
// à 0 pli avec bruit (beginner, casual, intermediate, advanced) ;
// - Expert : 0 pli sans bruit, que gnubg appelait autrefois « expert » ;
// - Champion : 2 plis, l'ancien « world class » de gnubg, le plus fort qui
// tienne dans le temps du téléphone (au plus 348 ms par coup sur le
// Fairphone 4, ADR 0009). 3 et 4 plis ne sont pas proposés.
import type { Level } from "./engine.js";
/** Un niveau proposé au joueur, du plus faible au plus fort. */
export type ComputerLevel =
| "beginner"
| "amateur"
| "intermediate"
| "advanced"
| "expert"
| "champion";
export const COMPUTER_LEVELS: readonly ComputerLevel[] = [
"beginner",
"amateur",
"intermediate",
"advanced",
"expert",
"champion",
];
/** Le niveau de gnubg de chaque niveau proposé. */
export const ENGINE_LEVELS: Readonly<Record<ComputerLevel, Level>> = {
beginner: "beginner",
amateur: "casual",
intermediate: "intermediate",
advanced: "advanced",
expert: "0ply",
champion: "2ply",
};
/** Le niveau proposé qui correspond à un niveau de gnubg (null pour 1, 3 et
* 4 plis, jamais proposés). */
export function computerLevelOf(level: Level): ComputerLevel | null {
return COMPUTER_LEVELS.find((name) => ENGINE_LEVELS[name] === level) ?? null;
}
+55
View File
@@ -0,0 +1,55 @@
// Le match : une suite de parties jusqu'à un nombre de points.
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
//
// Chaque partie d'un match est une partie à part entière (son journal, son
// enregistrement), qui porte le match tel qu'il est à son début. Les règles
// viennent du moteur : le score après une partie, la fin du match et la
// partie Crawford (Engine.matchAfterGame, qui reprend gnubg de bureau). Ce
// module ne fait que recopier ce que le moteur a dit.
import type { Side } from "./side.js";
/**
* Un match, au début d'une de ses parties : sa longueur, le score avant
* cette partie, et si elle est la partie Crawford (où personne ne peut
* doubler).
*
* Glossaire : docs/glossaire/match.md
*/
export interface Match {
/** Le nombre de points à atteindre pour gagner le match. */
readonly length: number;
readonly score: Readonly<Record<Side, number>>;
readonly crawford: boolean;
}
/** Le match après une partie, tel que le moteur le calcule. */
export interface MatchAfterGame {
readonly score: Readonly<Record<Side, number>>;
/** Le gagnant de la partie a gagné le match. */
readonly over: boolean;
/** La partie suivante est la partie Crawford. */
readonly crawford: boolean;
}
/** Les longueurs de match proposées à l'écran « Nouvelle partie ». */
export const MATCH_LENGTHS = [3, 5, 7, 11] as const;
/** La première partie d'un match en length points : 0 à 0. */
export function firstGameOf(length: number): Match {
if (!Number.isInteger(length) || length < 1) {
throw new Error(`longueur de match invalide : ${length}`);
}
return { length, score: { player: 0, computer: 0 }, crawford: false };
}
/** La partie suivante d'un match, d'après ce que le moteur a dit à la fin
* de la précédente ; null si le match est fini. */
export function nextGameOf(match: Match, after: MatchAfterGame): Match | null {
if (after.over) {
return null;
}
return { length: match.length, score: after.score, crawford: after.crawford };
}
+8 -11
View File
@@ -11,7 +11,7 @@
import type { Cube } from "./cube.js";
import type { BoardResult, Engine, EngineCube, MatchId } from "./engine.js";
import type { GameSetup, SideBoards } from "./game.js";
import { engineCube, type GameSetup, type SideBoards } from "./game.js";
import type { PositionId } from "./position.js";
import type { Side } from "./side.js";
import type { GameState } from "./state.js";
@@ -100,19 +100,16 @@ export async function readPosition(engine: Engine, link: PositionLink): Promise<
/**
* Le lien de la position d'une partie : la position vue du camp au trait,
* et le Match ID que le moteur écrit pour son videau (partie libre, sans
* dés). Pendant le jet d'ouverture, la position de départ, vue du joueur.
* et le Match ID que le moteur écrit pour son videau et son match (score,
* Crawford), sans dés. Pendant le jet d'ouverture, la position de départ, vue
* du joueur.
*/
export async function linkOf(engine: Engine, state: GameState): Promise<PositionLink> {
const onRoll: Side = state.turn ?? "player";
const cube: EngineCube =
state.cube.owner === null
? { value: state.cube.value, owner: "centered" }
: {
value: state.cube.value,
owner: state.cube.owner === onRoll ? "onRoll" : "opponent",
};
return { position: state.position, matchId: await engine.encodeMatch(cube) };
return {
position: state.position,
matchId: await engine.encodeMatch(engineCube(state, onRoll)),
};
}
/** La partie à commencer avec « Jouer à partir d'ici » : le joueur prend la
+30 -1
View File
@@ -10,7 +10,8 @@
import { branches } from "./branches.js";
import type { Level } from "./engine.js";
import { currentLine, type GameJournal } from "./events.js";
import { type GameResult, replay } from "./state.js";
import type { Side } from "./side.js";
import { DEFAULT_LEVEL, type GameResult, replay } from "./state.js";
/**
* Le résumé d'une partie enregistrée.
@@ -33,6 +34,18 @@ export interface SavedGameSummary {
readonly moveCount: number;
/** Nombre de branches (1 : jamais rejouée). */
readonly branchCount: number;
/** La partie d'un match : sa longueur, le score (après la partie si
* elle est finie, avant sinon) et si le match est fini. Absent en
* partie libre, et dans les résumés enregistrés avant l'étape 1c. */
readonly match?: SavedMatch;
}
/** Le match d'une partie enregistrée, pour la liste. */
export interface SavedMatch {
readonly length: number;
readonly score: Readonly<Record<Side, number>>;
readonly crawford: boolean;
readonly over: boolean;
}
/** Ce qu'il faut savoir d'une partie pour l'enregistrer, en plus de son
@@ -41,6 +54,12 @@ export interface SavedGameDetails {
readonly level: Level;
}
/** Le niveau de l'ordinateur d'une partie, d'après son journal. */
export function levelOf(journal: GameJournal): Level {
const start = journal.events[0];
return start?.type === "GameStarted" ? (start.level ?? DEFAULT_LEVEL) : DEFAULT_LEVEL;
}
/** Le résumé d'un journal ; startedAt et updatedAt viennent de l'appelant
* (le domaine ne lit pas l'horloge). */
export function summarizeGame(
@@ -60,5 +79,15 @@ export function summarizeGame(
result: state.result,
moveCount: line.filter((event) => event.type === "MovePlayed").length,
branchCount: branches(journal).length,
...(state.match === null
? {}
: {
match: {
length: state.match.length,
score: state.matchAfter?.score ?? state.match.score,
crawford: state.match.crawford,
over: state.matchAfter?.over ?? false,
},
}),
};
}
+10 -3
View File
@@ -78,11 +78,18 @@ export async function exportSgf(
return properties === null ? below : [{ properties, children: below }];
});
// La partie telle que la voit gnubg : la ligne courante.
const result = replay(currentLine(journal)).result;
// La partie telle que la voit gnubg : la ligne courante. Dans un match,
// sa longueur et le score avant la partie (W, l'ordinateur, est le
// joueur 0 de gnubg), et les règles comme gnubg les écrit (sgf.c) :
// Crawford, et CrawfordGame pour la partie Crawford. Le numéro de la
// partie dans le match n'est pas gardé : 0.
const { result, match } = replay(currentLine(journal));
const root = [
"FF[4]GM[6]CA[UTF-8]AP[Ustagammon]",
"MI[length:0][game:0][ws:0][bs:0]",
match === null
? "MI[length:0][game:0][ws:0][bs:0]"
: `MI[length:${match.length}][game:0][ws:${match.score.computer}][bs:${match.score.player}]` +
`RU[Crawford${match.crawford ? ":CrawfordGame" : ""}]`,
`PW[${escapeText(options.computerName)}]PB[${escapeText(options.playerName)}]`,
options.date === undefined ? "" : `DT[${escapeText(options.date)}]`,
result === null ? "" : `RE[${letter(result.winner)}+${result.points}]`,
+22 -1
View File
@@ -4,8 +4,9 @@
// Licence : GPLv3 ou ultérieure (voir LICENSE).
import { CENTERED_CUBE, type Cube } from "./cube.js";
import type { ResultKind } from "./engine.js";
import type { Level, ResultKind } from "./engine.js";
import type { GameEvent } from "./events.js";
import type { Match, MatchAfterGame } from "./match.js";
import { type PositionId, START_POSITION } from "./position.js";
import type { Roll } from "./roll.js";
import { otherSide, type Side } from "./side.js";
@@ -36,8 +37,22 @@ export interface GameState {
readonly roll: Roll | null;
readonly cube: Cube;
readonly result: GameResult | null;
/** Le niveau de l'ordinateur. */
readonly level: Level;
/** Le match au début de la partie (null : partie libre). */
readonly match: Match | null;
/** Le match après la partie, une fois finie (null sinon, ou en partie
* libre). */
readonly matchAfter: MatchAfterGame | null;
}
/**
* Le niveau d'une partie qui n'en dit rien : 2 plis, celui de toutes les
* parties d'avant le choix du niveau (étape 1c). Sur le Fairphone 4, le
* meilleur coup à ce niveau prend au plus 348 ms (ADR 0009).
*/
export const DEFAULT_LEVEL: Level = "2ply";
/** Avant le premier événement. */
const NOT_STARTED: GameState = {
phase: "opening",
@@ -46,6 +61,9 @@ const NOT_STARTED: GameState = {
roll: null,
cube: CENTERED_CUBE,
result: null,
level: DEFAULT_LEVEL,
match: null,
matchAfter: null,
};
/** Rejoue des événements, du premier au dernier. */
@@ -63,6 +81,8 @@ export function applyEvent(state: GameState, event: GameEvent): GameState {
cube: event.cube,
turn: event.firstTurn,
phase: event.firstTurn === null ? "opening" : "rolling",
level: event.level ?? DEFAULT_LEVEL,
match: event.match ?? null,
};
case "DiceRolled":
if (event.side !== null) {
@@ -103,6 +123,7 @@ export function applyEvent(state: GameState, event: GameEvent): GameState {
phase: "over",
roll: null,
result: { winner: event.winner, kind: event.kind, points: event.points },
matchAfter: event.match ?? null,
};
}
}
+241
View File
@@ -0,0 +1,241 @@
// Le tutoriel des règles, côté domaine : les positions illustrées et les
// écrans « à vous », vérifiés par le moteur.
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
//
// Le texte du tutoriel vit dans docs/tutoriel/ (ADR 0008 : une seule
// source) ; il donne pour chaque écran une position par son identifiant
// gnubg (ADR 0013), un jet, et les coups attendus en notation de gnubg.
// Rien n'est jugé ici : le moteur dit quels coups sont légaux, où mène
// chaque demi-coup, et s'il faut prendre un double. Le domaine ne fait que
// comparer ce qu'a joué le joueur aux coups attendus que le moteur a
// reconnus.
import { CENTERED_CUBE, type Cube } from "../game/cube.js";
import type { BoardResult, Engine, EngineCube, Move } from "../game/engine.js";
import { Game, type MoveSteps, type SideBoards, STANDARD_PLY } from "../game/game.js";
import type { HalfMove, PositionId } from "../game/position.js";
import { fixedDice, type Roll } from "../game/roll.js";
import type { Side } from "../game/side.js";
/** Un plateau du tutoriel, décodé par le moteur, et le résultat si la
* partie y est finie (vu du camp « player »). */
export interface TutorialBoard {
readonly sides: SideBoards;
readonly result: { readonly winner: Side; readonly kind: BoardResult["kind"] } | null;
}
/** Le videau vu du camp au trait. */
function engineCube(cube: Cube, onRoll: Side): EngineCube {
return {
value: cube.value,
owner: cube.owner === null ? "centered" : cube.owner === onRoll ? "onRoll" : "opponent",
};
}
/** Le plateau d'une position vue de onRoll, décodé par le moteur (erreur si
* l'identifiant est illisible). */
export async function tutorialBoard(
engine: Engine,
position: PositionId,
onRoll: Side = "player",
cube: Cube = CENTERED_CUBE,
): Promise<TutorialBoard> {
const board = await engine.board(position, engineCube(cube, onRoll));
const sides =
onRoll === "player"
? { player: board.onRoll, computer: board.opponent }
: { player: board.opponent, computer: board.onRoll };
const result =
board.result === null
? null
: {
winner:
(board.result.winner === "onRoll") === (onRoll === "player")
? ("player" as const)
: ("computer" as const),
kind: board.result.kind,
};
return { sides, result };
}
/** Les coups du jet que le moteur déclare légaux et dont la notation est
* l'une des notations données. Erreur si l'une d'elles n'est pas un coup
* légal : le texte du tutoriel ne correspond plus au moteur. */
export async function expectedMoves(
engine: Engine,
position: PositionId,
roll: Roll,
notations: readonly string[],
): Promise<Move[]> {
const legal = await engine.legalMoves(position, roll);
return notations.map((notation) => {
const move = legal.find((m) => m.notation === notation);
if (move === undefined) {
throw new Error(
`${notation} n'est pas un coup légal de ${roll.join("-")} dans ${position}`,
);
}
return move;
});
}
/** Le coup illustré d'un écran d'explication, demi-coup par demi-coup :
* les plateaux successifs, donnés par le moteur, et le résultat si le coup
* finit la partie. */
export async function illustratedMove(
engine: Engine,
position: PositionId,
roll: Roll,
notation: string,
): Promise<{ steps: MoveSteps; after: TutorialBoard }> {
const [move] = await expectedMoves(engine, position, roll, [notation]);
if (move === undefined) {
throw new Error(`coup illustré introuvable : ${notation}`);
}
const positions = [position];
for (let i = 1; i <= move.halfMoves.length; i++) {
const check = await engine.checkHalfMoves(position, roll, move.halfMoves.slice(0, i));
if (check.position === null) {
throw new Error(`demi-coup refusé par le moteur : ${notation}`);
}
positions.push(check.position);
}
const boards = await Promise.all(positions.map((p) => tutorialBoard(engine, p)));
const hits = move.halfMoves.map(
(_, i) => (boards[i + 1]?.sides.computer.bar ?? 0) > (boards[i]?.sides.computer.bar ?? 0),
);
const after = boards.at(-1) ?? boards[0];
if (after === undefined) {
throw new Error("aucun plateau");
}
return {
steps: {
side: "player",
halfMoves: move.halfMoves,
boards: boards.map((b) => b.sides),
hits,
},
after,
};
}
/**
* Les demi-coups saisis peuvent-ils encore mener à l'un des coups attendus ?
* Oui s'ils font tous partie (comme multiensemble) des demi-coups de l'un
* d'eux, tels que le moteur les donne. Un coup complet est jugé sur sa
* position (verdict de MovePractice).
*/
export function canStillLeadTo(played: readonly HalfMove[], expected: readonly Move[]): boolean {
return expected.some((move) => {
const left = [...move.halfMoves];
return played.every((h) => {
const i = left.findIndex((m) => m.from === h.from && m.to === h.to);
if (i < 0) {
return false;
}
left.splice(i, 1);
return true;
});
});
}
/** Ce que devient un écran « à vous » après un demi-coup. */
export type PracticeVerdict = "continue" | "right" | "wrong";
/**
* Un écran « à vous » où le joueur joue un coup : une partie d'un seul jet,
* commencée à la position de l'écran, jamais enregistrée. Le joueur saisit
* ses demi-coups comme en partie (le moteur dit lesquels sont permis) ; le
* coup est juste s'il mène à la position de l'un des coups attendus.
*/
export class MovePractice {
readonly #game: Game;
readonly #expected: readonly Move[];
readonly roll: Roll;
private constructor(game: Game, expected: readonly Move[], roll: Roll) {
this.#game = game;
this.#expected = expected;
this.roll = roll;
}
/** Prépare l'écran : la position, le jet, et les coups attendus,
* reconnus par le moteur (erreur sinon). */
static async start(
engine: Engine,
position: PositionId,
roll: Roll,
notations: readonly string[],
): Promise<MovePractice> {
const expected = await expectedMoves(engine, position, roll, notations);
const game = Game.start(engine, {
setup: { position, firstTurn: "player" },
dice: fixedDice(roll),
level: "0ply",
});
await game.roll();
return new MovePractice(game, expected, roll);
}
/** Le plateau affiché, demi-coups saisis compris. */
board(): Promise<SideBoards> {
return this.#game.displayedBoard();
}
/** Les demi-coups permis maintenant (le moteur le dit). */
nextHalfMoves(): Promise<HalfMove[]> {
return this.#game.nextHalfMoves();
}
get pendingHalfMoves(): readonly HalfMove[] {
return this.#game.pendingHalfMoves;
}
/** Les dés du jet, et ceux déjà utilisés. */
get diceToPlay(): { value: number; used: boolean }[] {
return this.#game.diceToPlay;
}
/**
* Joue un demi-coup. « wrong » si le moteur le refuse, ou s'il ne peut
* plus mener à un coup attendu, ou si le coup complet n'est pas l'un
* d'eux : le demi-coup reste affiché, à l'appelant de revenir en
* arrière (reset). « right » quand le coup complet est l'un des coups
* attendus.
*/
async play(halfMove: HalfMove): Promise<PracticeVerdict> {
if (!(await this.#game.playHalfMove(halfMove))) {
return "wrong";
}
if (this.#game.moveComplete) {
const position = this.#game.displayedPosition;
return this.#expected.some((m) => m.position === position) ? "right" : "wrong";
}
return canStillLeadTo(this.#game.pendingHalfMoves, this.#expected) ? "continue" : "wrong";
}
/** Le pion revient : tous les demi-coups saisis sont oubliés. */
reset(): void {
this.#game.cancelHalfMoves();
}
}
/**
* Un écran « à vous » de videau : l'ordinateur, au trait dans position (vue
* de lui), double. Le moteur dit, au niveau standard de l'ordinateur, si le
* joueur doit prendre (vrai) ou passer (faux). Erreur si l'ordinateur ne
* peut pas doubler dans cette position.
*/
export async function shouldTake(engine: Engine, position: PositionId): Promise<boolean> {
const decision = await engine.cubeDecision(
position,
{ value: 1, owner: "centered" },
STANDARD_PLY,
);
if (!decision.available) {
throw new Error(`l'ordinateur ne peut pas doubler dans ${position}`);
}
return decision.take;
}
+49 -4
View File
@@ -17,6 +17,7 @@ import type {
CubeDecision,
Engine,
EngineCube,
EngineMatch,
EvaluatedMove,
HalfMovesCheck,
Level,
@@ -75,13 +76,23 @@ export function createWasmEngine(run: RunCommand): Engine {
const matchIds = new Map<string, string>();
async function matchId(cube: EngineCube): Promise<string> {
// Partie libre, joueur 0 au trait : le propriétaire 0 est le joueur au
// trait, 1 son adversaire, -1 le centre.
// Joueur 0 au trait : le propriétaire 0 est le joueur au trait, 1 son
// adversaire, -1 le centre ; le score 0 est le sien.
const owner = { centered: -1, onRoll: 0, opponent: 1 }[cube.owner];
const key = `${cube.value} ${owner}`;
const args = ["matchid", String(cube.value), String(owner)];
const match = cube.match;
if (match !== undefined) {
args.push(
String(match.length),
String(match.score.onRoll),
String(match.score.opponent),
match.crawford ? "1" : "0",
);
}
const key = args.join(" ");
let id = matchIds.get(key);
if (id === undefined) {
const answer = (await run(["matchid", String(cube.value), String(owner)])) as {
const answer = (await run(args)) as {
match_id: string;
};
id = answer.match_id;
@@ -129,6 +140,32 @@ export function createWasmEngine(run: RunCommand): Engine {
return toCubeDecision((await run(args)) as CliCubeDecision);
},
async levelDouble(position: PositionId, cube: EngineCube, level: Level) {
const args = ["--level", level, "double", position, await matchId(cube)];
return ((await run(args)) as { double: boolean }).double;
},
async levelTake(position: PositionId, cube: EngineCube, level: Level) {
const args = ["--level", level, "take", position, await matchId(cube)];
return ((await run(args)) as { take: boolean }).take;
},
async matchAfterGame(match: EngineMatch, winner: "onRoll" | "opponent", points: number) {
// Le joueur au trait est le joueur 0 du Match ID.
const id = await matchId({ value: 1, owner: "centered", match });
const answer = (await run([
"aftergame",
id,
winner === "onRoll" ? "0" : "1",
String(points),
])) as { score: [number, number]; match_over: boolean; crawford: boolean };
return {
score: { onRoll: answer.score[0], opponent: answer.score[1] },
over: answer.match_over,
crawford: answer.crawford,
};
},
async analyseMove(
position: PositionId,
cube: EngineCube,
@@ -158,6 +195,14 @@ export function createWasmEngine(run: RunCommand): Engine {
};
},
async rankMoves(position: PositionId, cube: EngineCube, roll: Roll, ply: Ply) {
const args = ["--ply", String(ply), "moves", position, await matchId(cube)];
const answer = (await run([...args, diceText(roll)])) as {
moves: CliEvaluatedMove[];
};
return answer.moves.map(toEvaluatedMove);
},
async analyseCube(
position: PositionId,
cube: EngineCube,
+60 -23
View File
@@ -5,21 +5,53 @@
// 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";
import {
illustratedMove,
MovePractice,
shouldTake,
tutorialBoard,
} from "./domain/learning/tutorial.js";
import { createWasmEngine } from "./engine/wasm-engine.js";
import { startEngineWorker } from "./engine/worker-run.js";
import { type EngineWorker, startEngineWorker } from "./engine/worker-run.js";
import { createGameSession } from "./storage/game-session.js";
import { memoryStorage, openGameLibrary } from "./storage/open-library.js";
import { createSettingsStore } from "./storage/settings-store.js";
import { startApp } from "./ui/app.js";
import { startUpdates } from "./ui/updates.js";
// Ce fichier est servi sous app/ ; le Worker et le service worker sont à la
// racine du site.
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);
@@ -33,15 +65,12 @@ const opening = (async () => {
indexedDB: attempt(() => window.indexedDB),
localStorage,
});
// Le niveau de l'ordinateur : celui de Game.start (STANDARD_PLY).
const session = createGameSession(
library,
{
start: (setup) => Game.start(engine, setup === undefined ? {} : { setup }),
resume: (journal) => Game.resume(engine, journal),
},
"2ply",
);
// Le niveau et le match de chaque partie viennent de l'écran « Nouvelle
// partie » (ou de la partie précédente du match).
const session = createGameSession(library, {
start: (newGame) => Game.start(engine, newGame ?? {}),
resume: (journal) => Game.resume(engine, journal),
});
const saved = await session.restore();
return { library, session, saved };
})();
@@ -61,7 +90,7 @@ startApp({
stored = opened;
}),
]),
newGame: (setup) => storage().session.newGame(setup),
newGame: (newGame) => storage().session.newGame(newGame),
positions: {
read: (link) => readPosition(engine, link),
linkOf: (game) => linkOf(engine, game.state),
@@ -96,20 +125,28 @@ startApp({
saveBoard: (board) => settings.saveBoard(board),
loadSettings: () => settings.loadSettings(),
saveSettings: (json) => settings.saveSettings(json),
// Le tutoriel : positions, coups illustrés et gestes jugés par le
// domaine, sur le moteur.
tutorial: {
board: (position, onRoll) => tutorialBoard(engine, position, onRoll),
illustrate: (position, roll, move) => illustratedMove(engine, position, roll, move),
practice: (position, roll, expected) =>
MovePractice.start(engine, position, roll, expected),
shouldTake: (position) => shouldTake(engine, position),
},
// Hors ligne : le service worker met tout le site en cache. Le navigateur
// le refuse hors https (sauf localhost) : l'application marche alors sans.
// Il est enregistré une fois le moteur chargé : si le Worker et le service
// worker téléchargent les données du moteur (8,7 Mo) en même temps,
// Chromium fait échouer l'installation (« Cache.add() encountered a
// 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),
});
// Hors ligne : le service worker met tout le site en cache. Le navigateur le
// refuse hors https (sauf localhost) : l'application marche alors sans.
// Il est enregistré une fois le moteur chargé : si le Worker et le service
// worker téléchargent les données du moteur (8,7 Mo) en même temps, Chromium
// fait échouer l'installation (« Cache.add() encountered a 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.
if ("serviceWorker" in navigator) {
const sw = new URL("../sw.js", import.meta.url);
worker.ready.then(() => navigator.serviceWorker.register(sw)).catch(() => {});
}
/** La valeur, ou undefined si l'obtenir lève une exception. */
function attempt<T>(get: () => T): T | undefined {
try {
+10 -14
View File
@@ -9,15 +9,15 @@
// quelle, sur un stockage en mémoire.
import { switchToBranch } from "../domain/game/branches.js";
import type { Level } from "../domain/game/engine.js";
import type { GameJournal } from "../domain/game/events.js";
import type { Game, GameSetup } from "../domain/game/game.js";
import type { Game, NewGame } from "../domain/game/game.js";
import { levelOf } from "../domain/game/saved-game.js";
import { type GameLibrary, newGameId } from "./game-library.js";
/** Comment créer une partie : commencée, ou reprise d'un journal (erreur si
* le journal est illisible). */
export interface GameFactory {
readonly start: (setup?: GameSetup) => Game;
readonly start: (newGame?: NewGame) => Game;
readonly resume: (journal: GameJournal) => Game;
}
@@ -27,10 +27,10 @@ export interface GameSession {
/** La partie en cours enregistrée, reprise ; null s'il n'y en a pas ou
* si elle est illisible. */
restore(): Promise<Game | null>;
/** Une nouvelle partie (depuis la position de setup si elle est donnée) ;
/** Une nouvelle partie (niveau, match, position de départ éventuels) ;
* celle qu'elle remplace reste reprenable par resumePrevious, jusqu'au
* prochain changement de partie. */
newGame(setup?: GameSetup): Game;
newGame(newGame?: NewGame): Game;
/** Enregistre la partie à l'écran (sans attendre la fin de l'écriture ;
* un échec est ignoré : la partie continue). */
save(journal: GameJournal): void;
@@ -42,11 +42,7 @@ export interface GameSession {
open(id: string, branchHead?: string): Promise<Game>;
}
export function createGameSession(
library: GameLibrary,
games: GameFactory,
level: Level,
): GameSession {
export function createGameSession(library: GameLibrary, games: GameFactory): GameSession {
let shownId: string | null = null;
let previousId: string | null = null;
@@ -80,14 +76,14 @@ export function createGameSession(
return null; // partie illisible : on en commencera une nouvelle
}
},
newGame(setup) {
newGame(newGame) {
previousId = shownId;
shownId = newGameId();
return games.start(setup);
return games.start(newGame);
},
save(journal) {
if (shownId !== null) {
library.save(shownId, journal, { level }).catch(() => {});
library.save(shownId, journal, { level: levelOf(journal) }).catch(() => {});
}
},
async resumePrevious() {
@@ -123,7 +119,7 @@ export function createGameSession(
if (branchHead === undefined) {
await library.setCurrent(id);
} else {
await library.save(id, journal, { level: saved.summary.level });
await library.save(id, journal, { level: levelOf(journal) });
}
return game;
},
+55
View File
@@ -0,0 +1,55 @@
// Les textes de l'interface du conseil du maître (ligne, feuille
// d'explication, menu). Les mots et les phrases du maître eux-mêmes sont
// dans docs/maitre/phrases.md (une seule source, ADR 0008).
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
import type { CubeAction } from "../domain/game/engine.js";
export const ADVICE_TEXTS = {
/** Interrupteur du menu, comme sur l'écran « Nouvelle partie ». */
menuSwitch: "Conseil du maître",
lineLabel: (line: string) => `Conseil du maître : ${line} Toucher pour l'explication.`,
/** Paysage : la pastille du mot, au lecteur d'écran. */
chipLabel: (line: string) => `Conseil du maître : ${line} Toucher pour l'explication.`,
sheetLabel: "Explication du maître",
yourMove: "Votre coup",
masterMove: "Le coup du maître",
choiceLabel: "Coup montré sur le plateau",
legendMine: (move: string) =>
`Votre coup, ${move}, joué pion par pion sur la position d'avant.`,
legendBest: (move: string) =>
`Le coup du maître, ${move}, joué pion par pion sur la position d'avant votre coup.`,
legendSame: (move: string) => `${move}, joué pion par pion sur la position d'avant.`,
legendCube: "La position au moment de la décision.",
replay: "Rejouer le coup",
numbers: "Voir les chiffres du moteur",
you: "(vous)",
moveNote: (ply: number) =>
`Écart d'équité à ${ply} plis, par rapport au meilleur coup. Un écart de 0,03 ou moins reste un bon coup.`,
cubeNote: (ply: number) =>
`Équités de celui qui double, pour un videau de 1, à ${ply} plis. Écart de votre décision : `,
cubeRows: {
noDouble: "Pas de double",
doubleTake: "Double, prend",
doublePass: "Double, passe",
},
glossary: (term: string) => `En savoir plus : « ${term} »`,
close: "Fermer",
missing: "Le maître n'a pas d'explication pour ce coup.",
} as const;
/** La fiche du glossaire d'une décision de videau, et son titre. */
export const CUBE_GLOSSARY: Readonly<Record<CubeAction, { file: string; term: string }>> = {
double: { file: "doubler", term: "Doubler" },
noDouble: { file: "doubler", term: "Doubler" },
take: { file: "prendre", term: "Prendre" },
pass: { file: "passer", term: "Passer" },
};
/** Un nombre à la française, signé, à trois décimales : « −0,123 ». */
export function formatEquity(value: number): string {
const text = Math.abs(value).toFixed(3).replace(".", ",");
return value < -0.0005 ? `−${text}` : value > 0.0005 ? `+${text}` : text;
}
+152
View File
@@ -0,0 +1,152 @@
// Le conseil du maître pendant la partie : quelles décisions du joueur
// analyser, quand montrer la ligne, quand l'effacer (charte, 1d « La ligne
// du maître »). Sans DOM : testé sous Node (app/tests/advice-tracker.test.ts).
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
//
// Les analyses partent dès que la décision est jouée, sans être attendues :
// l'application les confie à un second moteur (main.ts), et le tour de
// l'ordinateur continue sur le premier. Chaque résultat est rattaché à sa
// décision dans le journal, pour le bilan (1e). La ligne apparaît quand
// l'analyse attendue est prête, si le joueur n'a rien fait entre-temps ;
// sinon elle n'apparaît pas, et le coup reste commenté dans le journal.
import { playerDecisions } from "../domain/analysis/analyse.js";
import type { Advice } from "../domain/game/advice.js";
import type { GameEvent, GameJournal } from "../domain/game/events.js";
import type { Game } from "../domain/game/game.js";
/** Analyse une décision du joueur (null : rien à en dire). */
export type Analyse = (journal: GameJournal, eventId: string) => Promise<Advice | null>;
export interface AdviceTracker {
/** Le conseil à montrer maintenant (null : rien). */
readonly shown: Advice | null;
/** Après une action du joueur sur game : before est la ligne de la
* partie avant l'action. Les décisions ajoutées sont analysées. */
track(game: Game, before: readonly GameEvent[]): void;
/** L'action suivante du joueur : la ligne s'efface, et une analyse
* encore attendue ne s'affichera pas. */
clear(): void;
}
export interface AdviceTrackerOptions {
readonly analyse: Analyse;
/** Un conseil a été rattaché au journal de game (à enregistrer). */
readonly recorded: (game: Game) => void;
/** Le conseil à montrer a changé. */
readonly changed: () => void;
}
export function createAdviceTracker(options: AdviceTrackerOptions): AdviceTracker {
let shown: Advice | null = null;
// Chaque attente de ligne a son jeton : clear() l'annule.
let waiting: object | null = null;
let tracked: Game | null = null;
let pending = new Map<string, Promise<Advice | null>>();
/** Le conseil d'une décision : déjà rattaché, en cours, ou à demander. */
function adviceOf(game: Game, id: string): Promise<Advice | null> {
const known = game.advice[id];
if (known !== undefined) {
return Promise.resolve(known);
}
let running = pending.get(id);
if (running === undefined) {
running = options
.analyse(game.journal, id)
.catch(() => null)
.then((advice) => {
if (advice !== null && game.journal.events.some((e) => e.id === id)) {
game.recordAdvice(advice);
options.recorded(game);
}
return advice;
});
pending.set(id, running);
}
return running;
}
return {
get shown() {
return shown;
},
track(game, before) {
if (game !== tracked) {
tracked = game;
pending = new Map();
}
const known = new Set(before.map((e) => e.id));
const line = game.events;
const added = line.filter((e) => !known.has(e.id));
for (const decision of playerDecisions(added)) {
void adviceOf(game, decision.id);
}
const candidates = toShow(line, added);
if (candidates.length === 0) {
return;
}
const token = {};
waiting = token;
void Promise.all(candidates.map((id) => adviceOf(game, id))).then((results) => {
if (waiting !== token || tracked !== game) {
return; // trop tard : le joueur a déjà agi
}
waiting = null;
shown = choose(results);
if (shown !== null) {
options.changed();
}
});
},
clear() {
waiting = null;
if (shown !== null) {
shown = null;
options.changed();
}
},
};
}
/**
* Les décisions dont la ligne peut parler après une action : le coup du
* joueur et, juste avant lui, son jet sans doubler (il aurait pu doubler) ;
* ou son double, ou sa réponse au double de l'ordinateur. Un jet seul ne
* montre rien : le joueur réfléchit alors à son coup.
*/
function toShow(line: readonly GameEvent[], added: readonly GameEvent[]): string[] {
const cube = added.find(
(e) =>
(e.type === "CubeOffered" || e.type === "CubeTaken" || e.type === "CubeDropped") &&
e.side === "player",
);
if (cube !== undefined) {
return [cube.id];
}
const move = added.find((e) => e.type === "MovePlayed" && e.side === "player");
if (move?.type !== "MovePlayed") {
return [];
}
const index = line.findIndex((e) => e.id === move.id);
const roll = line
.slice(0, index)
.findLast((e) => e.type === "DiceRolled" && e.side === "player");
const ids = move.halfMoves.length > 0 ? [move.id] : [];
return roll === undefined ? ids : [roll.id, ...ids];
}
/** Ce que la ligne montre : un double manqué avant le jet s'il était une
* faute, sinon le conseil sur le coup ou sur la décision de videau. */
function choose(results: readonly (Advice | null)[]): Advice | null {
const advices = results.filter((a): a is Advice => a !== null);
const missedDouble = advices.find(
(a) => a.kind === "cube" && a.action === "noDouble" && a.skill !== "none",
);
if (missedDouble !== undefined) {
return missedDouble;
}
return advices.find((a) => !(a.kind === "cube" && a.action === "noDouble")) ?? null;
}
+409
View File
@@ -0,0 +1,409 @@
// La feuille d'explication du maître (#/explication/<événement>), qui monte
// du bas au-dessus du plateau voilé (charte, 1d « La feuille
// d'explication ») : le mot et le contexte, le coup du maître en titre, sa
// phrase, « Votre coup » et « Le coup du maître » joués pion par pion sur la
// position d'avant, puis les chiffres du moteur, repliés.
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
import { type MasterPhrases, masterWords } from "../domain/analysis/phrases.js";
import {
type Advice,
type CubeAdvice,
type MoveAdvice,
masterCubeAction,
type Verdict,
} from "../domain/game/advice.js";
import type { Game, MoveSteps } from "../domain/game/game.js";
import { ADVICE_TEXTS, CUBE_GLOSSARY, formatEquity } from "./advice-texts.js";
import { boardModel, moveTrace, placeOf } from "./board-model.js";
import { checkerSpot, NO_MARKS, renderBoard, slideChecker } from "./board-view.js";
import { boardGeometry, PORTRAIT_ASPECT } from "./geometry.js";
import { type IconName, icon } from "./icons.js";
import { routeHash } from "./router.js";
const SLIDE_MS = 160;
const PAUSE_MS = 120;
/** Le pictogramme d'une appréciation (interaction.md, pictogrammes). */
export const VERDICT_ICONS: Readonly<Record<Verdict, IconName>> = {
good: "verdictGood",
doubtful: "verdictDoubtful",
bad: "verdictBad",
veryBad: "verdictBad",
};
export interface AdviceViewOptions {
/** Le voile de la feuille (index.html) ; la feuille est construite ici. */
readonly veil: HTMLElement;
readonly game: () => Game | null;
readonly phrases: () => Promise<MasterPhrases>;
readonly reducedMotion: MediaQueryList;
/** Fermer : quitter l'adresse de la feuille. */
readonly close: () => void;
}
export interface AdviceView {
/** Ouvre la feuille sur le conseil de l'événement eventId. */
show(eventId: string): Promise<void>;
hide(): void;
}
type Shown = "mine" | "best";
function element<K extends keyof HTMLElementTagNameMap>(
parent: HTMLElement,
tag: K,
options: { id?: string; className?: string; text?: string } = {},
): HTMLElementTagNameMap[K] {
const child = document.createElement(tag);
if (options.id !== undefined) {
child.id = options.id;
}
if (options.className !== undefined) {
child.className = options.className;
}
if (options.text !== undefined) {
child.textContent = options.text;
}
parent.append(child);
return child;
}
export function createAdviceView(options: AdviceViewOptions): AdviceView {
const { veil } = options;
const sheet = element(veil, "div", { id: "explain-sheet", className: "sheet explain-sheet" });
sheet.setAttribute("role", "dialog");
sheet.setAttribute("aria-modal", "true");
sheet.setAttribute("aria-labelledby", "explain-title");
veil.addEventListener("click", (event) => {
if (event.target === veil) {
options.close();
}
});
// Chaque ouverture a son numéro : une ouverture plus récente, ou la
// fermeture, arrête l'animation en cours.
let opened = 0;
// Chaque coup joué sur le plateau de la feuille a aussi le sien.
let playing = 0;
async function show(eventId: string): Promise<void> {
const id = ++opened;
const game = options.game();
const advice = game?.advice[eventId] ?? null;
sheet.replaceChildren();
veil.hidden = false;
const head = element(sheet, "div", { className: "sheet-head" });
element(head, "span", { className: "grip" }).setAttribute("aria-hidden", "true");
const verdictLine = element(head, "p", {
id: "explain-verdict",
className: "explain-verdict",
});
const close = element(head, "button", { id: "explain-close", className: "icon-button" });
close.type = "button";
close.append(icon("close"));
close.setAttribute("aria-label", ADVICE_TEXTS.close);
close.onclick = () => options.close();
const title = element(sheet, "h2", { id: "explain-title", className: "explain-title" });
if (game === null || advice === null) {
title.textContent = ADVICE_TEXTS.sheetLabel;
element(sheet, "p", { className: "explain-phrase", text: ADVICE_TEXTS.missing });
closeButton();
close.focus();
return;
}
const words = masterWords(await options.phrases(), advice);
if (id !== opened) {
return;
}
verdictLine.dataset.verdict = words.verdict;
verdictLine.append(icon(VERDICT_ICONS[words.verdict]));
element(verdictLine, "span", { text: `${words.word} · ${words.context}` });
title.textContent = words.title;
// Le titre dit déjà le coup du maître : la phrase ne le répète pas
// (et disparaît si elle ne disait que lui).
const rest = words.phrase.startsWith(`${words.title}.`)
? words.phrase.slice(words.title.length + 1).trim()
: words.phrase;
if (rest !== "") {
element(sheet, "p", { id: "explain-phrase", className: "explain-phrase", text: rest });
}
const body = element(sheet, "div", { className: "explain-body" });
const boardBox = element(body, "div", { className: "explain-board-box" });
if (advice.kind === "move") {
await showMoves(id, game, advice, body, boardBox);
} else {
await showCube(id, game, advice, boardBox);
}
if (id !== opened) {
return;
}
numbers(advice);
if (advice.kind === "cube") {
const entry = CUBE_GLOSSARY[masterCubeAction(advice)];
const link = element(sheet, "a", {
id: "explain-glossary",
className: "link-button explain-glossary",
text: ADVICE_TEXTS.glossary(entry.term),
});
link.href = routeHash({ screen: "glossary", term: entry.file });
}
closeButton();
close.focus();
}
function closeButton(): void {
const actions = element(sheet, "div", { className: "explain-actions" });
const done = element(actions, "button", {
id: "explain-done",
className: "secondary",
text: ADVICE_TEXTS.close,
});
done.type = "button";
done.onclick = () => options.close();
}
/** Le plateau de la feuille, dans son cadre. */
function boardSvg(box: HTMLElement): SVGSVGElement {
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
svg.id = "explain-board";
svg.setAttribute("class", "board explain-board");
svg.setAttribute("role", "img");
box.append(svg);
return svg;
}
function draw(svg: SVGSVGElement, steps: MoveSteps, index: number, traced: boolean): void {
const boards = steps.boards[index];
if (boards === undefined) {
return;
}
const width = svg.getBoundingClientRect().width || 358;
renderBoard(
svg,
boardGeometry("portrait", PORTRAIT_ASPECT, width / 640),
boardModel(boards),
{
...NO_MARKS,
dice: [],
cube: null,
band: null,
controls: null,
trace: traced ? moveTrace(steps.side, steps.halfMoves, steps.hits) : null,
},
);
// Les flèches des traces renvoient à leur pointe par identifiant :
// celui du plateau de jeu serait pris à sa place.
for (const marker of svg.querySelectorAll("marker[id]")) {
marker.id = `explain-${marker.id}`;
}
for (const path of svg.querySelectorAll("[marker-end]")) {
const ref = path.getAttribute("marker-end") ?? "";
path.setAttribute("marker-end", ref.replace("url(#", "url(#explain-"));
}
}
/** Joue un coup pion par pion (sans mouvement si le système le
* demande), puis laisse ses traces. */
async function animate(svg: SVGSVGElement, steps: MoveSteps): Promise<void> {
const id = ++playing;
if (!options.reducedMotion.matches) {
for (const [i, halfMove] of steps.halfMoves.entries()) {
if (id !== playing) {
return;
}
if (i > 0) {
await new Promise((resolve) => setTimeout(resolve, PAUSE_MS));
}
const from = placeOf(steps.side, halfMove.from);
const to = placeOf(steps.side, halfMove.to);
draw(svg, steps, i, false);
const start = checkerSpot(svg, from);
const victim = steps.hits[i] === true ? checkerSpot(svg, to) : null;
draw(svg, steps, i + 1, false);
await Promise.all([
start && slideChecker(svg, to, start, SLIDE_MS),
victim && slideChecker(svg, "bar-computer", victim, SLIDE_MS),
]);
}
}
if (id === playing) {
draw(svg, steps, steps.halfMoves.length, true);
}
}
async function showMoves(
id: number,
game: Game,
advice: MoveAdvice,
body: HTMLElement,
boardBox: HTMLElement,
): Promise<void> {
const same = advice.rank === 1;
const [mine, best] = await Promise.all([
game.stepsBefore(advice.eventId, "player", advice.played.halfMoves),
same ? null : game.stepsBefore(advice.eventId, "player", advice.best.halfMoves),
]);
if (id !== opened) {
return;
}
const legend = element(body, "p", { id: "explain-legend", className: "explain-legend" });
const svg = boardSvg(boardBox);
if (best === null) {
svg.dataset.move = "mine";
svg.setAttribute("aria-label", ADVICE_TEXTS.legendSame(advice.played.notation));
legend.textContent = ADVICE_TEXTS.legendSame(advice.played.notation);
draw(svg, mine, 0, false);
void animate(svg, mine);
return;
}
const choice = element(body, "div", { id: "explain-choice", className: "segmented" });
choice.setAttribute("role", "radiogroup");
choice.setAttribute("aria-label", ADVICE_TEXTS.choiceLabel);
body.prepend(choice);
const buttons: Record<Shown, HTMLButtonElement> = {
mine: element(choice, "button", {
id: "explain-mine",
className: "segment",
text: ADVICE_TEXTS.yourMove,
}),
best: element(choice, "button", {
id: "explain-best",
className: "segment",
text: ADVICE_TEXTS.masterMove,
}),
};
const select = (shown: Shown) => {
for (const key of ["mine", "best"] as const) {
buttons[key].setAttribute("aria-checked", String(key === shown));
}
const notation = shown === "mine" ? advice.played.notation : advice.best.notation;
const text =
shown === "mine"
? ADVICE_TEXTS.legendMine(notation)
: ADVICE_TEXTS.legendBest(notation);
legend.textContent = text;
svg.setAttribute("aria-label", text);
svg.dataset.move = shown;
const steps = shown === "mine" ? mine : best;
draw(svg, steps, 0, false);
void animate(svg, steps);
};
for (const key of ["mine", "best"] as const) {
const button = buttons[key];
button.type = "button";
button.setAttribute("role", "radio");
button.onclick = () => select(key);
}
// Flèches gauche et droite entre les deux segments.
choice.addEventListener("keydown", (event) => {
if (event.key === "ArrowLeft" || event.key === "ArrowRight") {
event.preventDefault();
const next = buttons.mine.getAttribute("aria-checked") === "true" ? "best" : "mine";
select(next);
buttons[next].focus();
}
});
// Par défaut, le coup du maître (maquette).
select("best");
}
async function showCube(
id: number,
game: Game,
advice: CubeAdvice,
boardBox: HTMLElement,
): Promise<void> {
const steps = await game.stepsBefore(advice.eventId, "player", []);
if (id !== opened) {
return;
}
const svg = boardSvg(boardBox);
svg.setAttribute("aria-label", ADVICE_TEXTS.legendCube);
draw(svg, steps, 0, false);
element(boardBox, "p", { className: "explain-legend", text: ADVICE_TEXTS.legendCube });
}
/** « Voir les chiffres du moteur », replié par défaut. */
function numbers(advice: Advice): void {
const details = element(sheet, "details", {
id: "explain-numbers",
className: "explain-numbers",
});
element(details, "summary", { text: ADVICE_TEXTS.numbers });
const table = element(details, "div", { className: "explain-table" });
const row = (cells: string[], strong: boolean) => {
for (const text of cells) {
element(table, "span", { className: strong ? "strong" : "", text });
}
};
if (advice.kind === "move") {
// Les écarts du classement se lisent par rapport à son premier
// coup ; votre coup, s'il n'y figure pas, avec l'écart de
// l'analyse du coup joué.
const first = advice.ranking[0]?.equity ?? advice.best.equity;
const listed = advice.ranking.map((move, i) => ({
move,
rank: i + 1,
loss: move.equity - first,
}));
if (!advice.ranking.some((m) => m.notation === advice.played.notation)) {
listed.push({ move: advice.played, rank: advice.rank, loss: advice.equityLoss });
}
for (const { move, rank, loss } of listed) {
const mine = move.notation === advice.played.notation;
row(
[
String(rank),
mine ? `${move.notation} ${ADVICE_TEXTS.you}` : move.notation,
formatEquity(loss),
],
mine,
);
}
element(details, "p", {
className: "explain-note",
text: ADVICE_TEXTS.moveNote(advice.ply),
});
} else {
// Les lignes de votre décision, en gras : doubler mène à l'une des
// deux lignes « Double », selon la réponse de l'ordinateur.
const mine = {
noDouble: ["noDouble"],
double: ["doubleTake", "doublePass"],
take: ["doubleTake"],
pass: ["doublePass"],
}[advice.action];
for (const key of ["noDouble", "doubleTake", "doublePass"] as const) {
const bold = mine.includes(key);
const name = ADVICE_TEXTS.cubeRows[key];
row(
[
"",
bold && mine.length === 1 ? `${name} ${ADVICE_TEXTS.you}` : name,
formatEquity(advice.equities[key]),
],
bold,
);
}
element(details, "p", {
className: "explain-note",
text: `${ADVICE_TEXTS.cubeNote(advice.ply)}${formatEquity(advice.equityLoss)}.`,
});
}
}
return {
show,
hide() {
opened++;
playing++;
if (!veil.hidden) {
veil.hidden = true;
sheet.replaceChildren();
}
},
};
}
+652 -44
View File
File diff suppressed because it is too large Load Diff
+151 -10
View File
@@ -27,6 +27,7 @@ import {
} from "./board-model.js";
import { columnLeft, type Geometry, MAX_STACK, stackY } from "./geometry.js";
import { ICON_PATHS, type IconName } from "./icons.js";
import { MATCH_TEXTS } from "./match-texts.js";
import { TEXTS } from "./texts.js";
/** Un dé affiché, de la couleur du camp qui l'a lancé. */
@@ -49,12 +50,22 @@ export interface BoardControls {
readonly back: "undo" | "replay";
readonly backEnabled: boolean;
readonly menuEnabled: boolean;
/** Le score du match, sous le menu (null : partie libre). */
readonly match: MatchScore | null;
}
/** Le score d'un match, à afficher. */
export interface MatchScore {
readonly length: number;
readonly computer: number;
readonly player: number;
}
/** Ce qui s'ajoute aux pions : dés, videau, mises en évidence. */
export interface BoardDecor {
readonly dice: readonly ShownDie[];
readonly cube: Cube;
/** null : pas de videau (partie Crawford). */
readonly cube: Cube | null;
/** Les pions que le joueur peut déplacer. */
readonly movable: ReadonlySet<Place>;
/** Le pion touché, dont on montre les cases d'arrivée. */
@@ -72,6 +83,21 @@ export interface BoardDecor {
readonly band: string | null;
/** Paysage : la colonne de commandes (null : pas de colonne). */
readonly controls: BoardControls | null;
/** Paysage : le conseil du maître, une pastille avant la consigne dans
* la bande centrale (absent ou null : rien). */
readonly advice?: BoardAdvice | null;
}
/** Le conseil du maître en paysage : son mot, sur une pastille qui mène à
* la feuille d'explication. */
export interface BoardAdvice {
readonly href: string;
/** Appréciation : la couleur et le pictogramme. */
readonly verdict: "good" | "doubtful" | "bad" | "veryBad";
readonly icon: IconName;
readonly word: string;
/** Ce que lit le lecteur d'écran : le mot, la phrase, l'invitation. */
readonly label: string;
}
/** Ce qui ne s'ajoute pas aux pions pendant l'animation d'un coup. */
@@ -166,6 +192,7 @@ export function renderBoard(
// Les pions, dessinés à part puis posés par-dessus les flèches du coup
// de l'ordinateur : une flèche ne cache jamais un pion.
const pieces = document.createElementNS(SVG_NS, "g");
pieces.setAttribute("class", "pieces");
for (const point of board.points) {
drawPoint(svg, pieces, g, hits, board, point.number, decor);
}
@@ -176,11 +203,13 @@ export function renderBoard(
}
svg.append(pieces);
drawLandings(svg, g, board, decor.targets);
const advice = decor.band !== null ? (decor.advice ?? null) : null;
const chip = advice === null ? 0 : adviceChipWidth(g, advice) + 8;
if (decor.band !== null) {
drawBand(svg, g, decor.band);
drawBand(svg, g, decor.band, chip);
}
drawDice(svg, g, decor.dice);
const cube = drawCube(svg, g, decor.cube);
const cube = decor.cube === null ? null : drawCube(svg, g, decor.cube);
if (decor.trace !== null) {
drawTraceRings(svg, g, board, decor.trace);
}
@@ -196,13 +225,16 @@ export function renderBoard(
addAction(g, hits, decor.diceAction, label, { x, y: g.height / 2 }, width);
}
}
if (decor.canDouble) {
if (decor.canDouble && cube !== null && decor.cube !== null) {
cube.classList.add("can-double");
addAction(g, hits, "double", TEXTS.double, cubeCenter(g, decor.cube), g.target);
}
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);
}
@@ -484,12 +516,18 @@ function addRing(
}
/** Paysage : la consigne courte, dans la moitié gauche de la bande
* centrale, sur une pastille accent-soft. */
function drawBand(svg: SVGSVGElement, g: Geometry, text: string): void {
* centrale, sur une pastille accent-soft ; elle laisse la place before à
* gauche (la pastille du conseil du maître). */
function drawBand(svg: SVGSVGElement, g: Geometry, text: string, before = 0): void {
const middle = g.height / 2;
const width = 6 * g.pointWidth - 24;
const width = 6 * g.pointWidth - 24 - before;
const height = Math.max(44, g.bandFont + 20);
const x = g.x0 + 12;
const x = g.x0 + 12 + before;
// Une consigne longue (« Partie Crawford · … ») tient dans la pastille :
// la police se resserre, sans descendre sous 13 px (charte). Largeur
// moyenne d'un caractère d'Alegreya Sans en gras : un peu moins de
// 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,
@@ -505,13 +543,113 @@ function drawBand(svg: SVGSVGElement, g: Geometry, text: string): void {
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
* (ecrans-etape-1.md, 1c). Lu d'une traite par le lecteur d'écran.
*/
function drawMatchBox(svg: Element, g: Geometry, x: number, top: number, match: MatchScore): void {
const line = g.chipFont * 1.2;
const width = g.left - 12;
const group = add(svg, "g", {
class: "match-box",
role: "img",
"aria-label": MATCH_TEXTS.scoreSpoken(match.length, match.computer, match.player),
});
add(group, "rect", {
class: "match-box-frame",
x: x - width / 2,
y: top,
width,
height: 3 * line + 8,
rx: 6,
});
const texts: [string, string][] = [
[String(match.computer), "match-box-score"],
[MATCH_TEXTS.outOf(match.length), "match-box-of"],
[String(match.player), "match-box-score"],
];
for (const [i, [text, cssClass]] of texts.entries()) {
add(
group,
"text",
{
class: cssClass,
x,
y: top + 4 + line * (i + 0.5) + g.chipFont * 0.35,
"font-size": g.chipFont,
"text-anchor": "middle",
"aria-hidden": "true",
},
text,
);
}
}
/** Paysage : la colonne de commandes à gauche, dans le cadre. */
function drawControls(
svg: SVGSVGElement,
@@ -556,6 +694,9 @@ function drawControls(
}
};
control("menu", "menu", TEXTS.menu, top, controls.menuEnabled);
if (controls.match !== null) {
drawMatchBox(svg, g, x, top + g.target / 2 + 6, controls.match);
}
if (controls.back === "replay") {
control("replay", "replay", TEXTS.replayTitle, bottom, controls.backEnabled);
} else {
+32
View File
@@ -0,0 +1,32 @@
// La question de l'aide qui correspond au moment de la partie : le bouton
// « Aide » de l'écran de jeu l'ouvre directement (charte : « fermée par
// défaut sauf celle qui correspond à l'écran d'où l'on vient »).
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
//
// Les questions sont les titres des scénarios de docs/scenarios/ (ADR 0008) ;
// leur adresse en est tirée par slug. Un test (app/tests/ui.test.ts) vérifie
// que chaque titre ci-dessous est bien celui d'un scénario : changer un
// titre dans un .feature demande de le changer ici.
import { routeHash, slug } from "./router.js";
/** Le moment de la partie, vu de l'aide. */
export type HelpMoment = "moving" | "stuck" | "doubled" | "over";
export const HELP_QUESTIONS: Readonly<Record<HelpMoment, string>> = {
moving: "Comment jouer un coup ?",
stuck: "Je ne peux rien jouer avec mes dés",
doubled: "L'ordinateur double : que se passe-t-il si je prends ?",
over: "Comment se termine une partie ?",
};
/** L'adresse de l'aide à ouvrir à ce moment (l'onglet « Jouer » sans
* question particulière : null). */
export function helpHash(moment: HelpMoment | null): string {
return routeHash({
screen: "help",
question: moment === null ? null : slug(HELP_QUESTIONS[moment]),
});
}
+54 -6
View File
@@ -14,10 +14,16 @@ export interface HelpFeature {
}
export interface HelpScenario {
/** Le titre, formulé comme la question du joueur. */
readonly title: string;
/** La réponse : le paragraphe sous le titre. */
readonly description: readonly string[];
/** Les étapes, mot-clé compris : « Quand vous prenez ». */
/** Les étapes, mot-clé compris : « Quand vous prenez ». Elles ne
* s'affichent pas dans l'aide : elles prouvent la réponse. */
readonly steps: readonly string[];
/** La position de l'exemple, donnée par l'étiquette
* @position:<Position ID> du scénario (null sans étiquette). */
readonly position: string | null;
}
const STEP = /^(Étant donnée?s?|Etant donnée?s?|Soit|Quand|Lorsque|Alors|Donc|Et|Mais)\s/;
@@ -27,13 +33,20 @@ const FEATURE = /^Fonctionnalité\s*:\s*(.*)$/;
/**
* Lit un fichier Gherkin en français (fichiers .feature). Les lignes de texte
* libre consécutives forment un paragraphe ; les commentaires (#) sont
* ignorés.
* ignorés ; une étiquette @position:<Position ID> donne la position de
* l'exemple du scénario qui la suit.
*/
export function parseFeature(text: string): HelpFeature {
let title = "";
const description: string[] = [];
const scenarios: { title: string; description: string[]; steps: string[] }[] = [];
const scenarios: {
title: string;
description: string[];
steps: string[];
position: string | null;
}[] = [];
let paragraph: string[] = [];
let tags: string[] = [];
// Le paragraphe en cours rejoint la description de la fonctionnalité ou
// du dernier scénario.
@@ -50,12 +63,23 @@ export function parseFeature(text: string): HelpFeature {
const scenario = SCENARIO.exec(line);
if (line === "" || line.startsWith("#")) {
endParagraph();
} else if (line.startsWith("@")) {
endParagraph();
tags.push(...line.split(/\s+/));
} else if (feature) {
endParagraph();
title = feature[1] ?? "";
tags = [];
} else if (scenario) {
endParagraph();
scenarios.push({ title: scenario[2] ?? "", description: [], steps: [] });
const position = tags.find((tag) => tag.startsWith("@position:"));
scenarios.push({
title: scenario[2] ?? "",
description: [],
steps: [],
position: position === undefined ? null : position.slice("@position:".length),
});
tags = [];
} else if (STEP.test(line) && scenarios.length > 0) {
endParagraph();
scenarios.at(-1)?.steps.push(line);
@@ -74,18 +98,42 @@ export function parseGlossaryIndex(readme: string): string[] {
return [...section.matchAll(/\]\(([a-z0-9-]+\.md)\)/g)].map((match) => match[1] ?? "");
}
/** Une fiche du glossaire : le terme et sa phrase « En bref ». */
/** Une fiche du glossaire : le terme, son nom anglais (celui du code), sa
* phrase « En bref », et ses renvois « Voir aussi ». */
export interface GlossaryEntry {
readonly term: string;
readonly english: string;
readonly brief: string;
/** Les fiches citées par « Voir aussi » : le mot, et la fiche sans .md. */
readonly seeAlso: readonly { readonly term: string; readonly name: string }[];
}
/** Lit une fiche du glossaire ; null si elle n'a pas la forme attendue. */
export function parseGlossaryEntry(text: string): GlossaryEntry | null {
const term = /^term: (.+)$/m.exec(text)?.[1]?.trim();
const english = /^english: (.+)$/m.exec(text)?.[1]?.trim() ?? "";
const brief = /^\*\*En bref\*\* : (.+)$/m.exec(text)?.[1]?.trim();
if (term === undefined || brief === undefined) {
return null;
}
return { term, brief: brief.replaceAll("**", "") };
const seeLine = /^Voir aussi : (.+)$/m.exec(text)?.[1] ?? "";
const seeAlso = [...seeLine.matchAll(/\[([^\]]+)\]\(([a-z0-9-]+)\.md\)/g)].map((m) => ({
term: m[1] ?? "",
name: m[2] ?? "",
}));
return { term, english, brief: brief.replaceAll("**", ""), seeAlso };
}
/** Un texte sans accents ni majuscules, pour la recherche dans le
* glossaire. */
export function searchable(text: string): string {
return text.normalize("NFD").replace(/[̀-ͯ]/g, "").toLowerCase();
}
/** La fiche correspond-elle à la recherche (le terme, son nom anglais ou sa
* phrase « En bref » contiennent chacun des mots cherchés) ? */
export function matchesSearch(entry: GlossaryEntry, query: string): boolean {
const words = searchable(query).split(/\s+/).filter(Boolean);
const haystack = searchable(`${entry.term} ${entry.english} ${entry.brief}`);
return words.every((word) => haystack.includes(word));
}
+114 -21
View File
@@ -8,25 +8,35 @@
//
// Chaque onglet, chaque question et chaque terme a son adresse (ADR 0013,
// amendement) : on les ouvre par de vrais liens, et l'adresse dit ce qui
// est ouvert (show).
// est ouvert (show). Le titre d'un scénario est la question, sa
// description la réponse ; ses étapes, la preuve que l'application fait ce
// que dit la réponse, ne s'affichent pas
// (docs/identite/charte/accueil-tutoriel-aide.md, « Aide »).
import {
type GlossaryEntry,
type HelpFeature,
type HelpScenario,
matchesSearch,
parseFeature,
parseGlossaryEntry,
parseGlossaryIndex,
} from "./help-text.js";
import { HOME_TEXTS } from "./home-texts.js";
import { type HelpRoute, routeHash, slug } from "./router.js";
import { TEXTS } from "./texts.js";
/** Les scénarios montrés dans l'aide, dans l'ordre. */
const FEATURES = [
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",
"docs/scenarios/regler-l-application.feature",
"docs/scenarios/installer-l-application.feature",
"docs/scenarios/decouvrir-l-application.feature",
];
const GLOSSARY = "docs/glossaire/";
@@ -59,6 +69,18 @@ export async function loadGlossaryEntry(file: string): Promise<GlossaryEntry | n
return parseGlossaryEntry(await fetchText(GLOSSARY + file));
}
/** Le commit publié (version.json, écrit par make site), en abrégé. */
async function publishedVersion(): Promise<string | null> {
try {
const version = JSON.parse(await fetchText("version.json")) as { commit?: unknown };
return typeof version.commit === "string" && version.commit !== ""
? version.commit.slice(0, 12)
: null;
} catch {
return null;
}
}
function element<K extends keyof HTMLElementTagNameMap>(
parent: HTMLElement,
tag: K,
@@ -101,7 +123,7 @@ export function createHelpView(container: HTMLElement, tabs: HTMLElement): HelpV
notice.className = "help-notice";
notice.setAttribute("role", "status");
const questions: Question[] = [];
const terms = new Map<string, HTMLElement>();
const terms = new Map<string, { dt: HTMLElement; card: HTMLElement; entry: GlossaryCard }>();
let loading: Promise<boolean> | null = null;
// La question ouverte au dernier affichage : refermée, elle reste en vue.
let lastOpen: Question | null = null;
@@ -126,10 +148,14 @@ export function createHelpView(container: HTMLElement, tabs: HTMLElement): HelpV
for (const paragraph of scenario.description) {
element(answer, "p", paragraph);
}
const steps = element(answer, "ul");
steps.className = "steps";
for (const step of scenario.steps) {
element(steps, "li", step);
if (scenario.position !== null) {
const board = element(answer, "a", HOME_TEXTS.seeOnBoard);
board.className = "see-board";
board.href = routeHash({
screen: "position",
position: scenario.position,
match: null,
});
}
questions.push({ id, section, link, answer });
}
@@ -137,7 +163,7 @@ export function createHelpView(container: HTMLElement, tabs: HTMLElement): HelpV
function renderFeature(feature: HelpFeature): void {
element(play, "h2", feature.title);
for (const paragraph of feature.description) {
element(play, "p", paragraph);
element(play, "p", paragraph).className = "feature-intro";
}
for (const scenario of feature.scenarios) {
renderScenario(play, scenario);
@@ -146,38 +172,103 @@ export function createHelpView(container: HTMLElement, tabs: HTMLElement): HelpV
function renderGlossary(entries: readonly GlossaryCard[]): void {
element(glossary, "h2", TEXTS.glossaryTitle);
const label = element(glossary, "label");
label.className = "glossary-search";
element(label, "span", HOME_TEXTS.glossarySearch).className = "search-label";
const search = element(label, "input");
search.id = "glossary-search";
search.type = "search";
search.autocomplete = "off";
search.spellcheck = false;
const none = element(glossary, "p", HOME_TEXTS.glossaryNone);
none.className = "glossary-none muted";
none.hidden = true;
const list = element(glossary, "dl");
list.className = "glossary";
for (const entry of entries) {
const term = element(list, "dt");
term.id = `glossaire-${entry.name}`;
const link = element(term, "a", entry.term);
const sorted = [...entries].sort((a, b) => a.term.localeCompare(b.term, "fr"));
for (const entry of sorted) {
const card = element(list, "div");
card.className = "term-card";
const dt = element(card, "dt");
dt.id = `glossaire-${entry.name}`;
const link = element(dt, "a", entry.term);
link.href = routeHash({ screen: "glossary", term: entry.name });
element(list, "dd", entry.brief);
terms.set(entry.name, term);
if (entry.english !== "") {
const english = element(dt, "span", entry.english);
english.className = "english";
english.lang = "en";
}
const dd = element(card, "dd");
element(dd, "p", entry.brief);
if (entry.seeAlso.length > 0) {
const see = element(dd, "p");
see.className = "see-also";
see.append(`${HOME_TEXTS.glossarySeeAlso} `);
entry.seeAlso.forEach((other, i) => {
if (i > 0) {
see.append(", ");
}
const a = element(see, "a", other.term);
a.href = routeHash({ screen: "glossary", term: other.name });
});
}
terms.set(entry.name, { dt, card, entry });
}
search.addEventListener("input", () => {
let shown = 0;
for (const { card, entry } of terms.values()) {
const match = matchesSearch(entry, search.value);
card.hidden = !match;
shown += match ? 1 : 0;
}
none.hidden = shown > 0;
});
}
function renderAbout(): void {
function renderAbout(version: string | null): void {
element(about, "h2", TEXTS.aboutTitle);
const text = element(about, "p", TEXTS.about);
text.className = "about";
for (const paragraph of HOME_TEXTS.aboutParagraphs) {
element(about, "p", paragraph).className = "about";
}
const line = element(
about,
"p",
version === null ? HOME_TEXTS.aboutVersionUnknown : HOME_TEXTS.aboutVersion(version),
);
line.id = "about-version";
line.className = "about";
}
function renderTutorialCard(): void {
const card = element(container, "a");
card.id = "help-tutorial";
card.className = "card help-tutorial";
card.href = "#/tutoriel/1";
const text = element(card, "span");
text.className = "help-tutorial-text";
element(text, "strong", HOME_TEXTS.helpTutorialTitle);
element(text, "span", HOME_TEXTS.helpTutorialText).className = "muted";
element(card, "span", HOME_TEXTS.helpTutorialLink).className = "pill";
}
async function load(): Promise<boolean> {
container.replaceChildren(notice);
notice.textContent = TEXTS.helpLoading;
try {
const [features, entries] = await Promise.all([
const [features, entries, version] = await Promise.all([
Promise.all(FEATURES.map(async (url) => parseFeature(await fetchText(url)))),
loadGlossary(),
publishedVersion(),
]);
const intro = element(play, "p", HOME_TEXTS.helpIntro);
intro.className = "help-intro muted";
for (const feature of features) {
renderFeature(feature);
}
renderGlossary(entries);
renderAbout();
renderAbout(version);
notice.textContent = "";
renderTutorialCard();
container.append(play, glossary, about);
container.dataset.loaded = "true";
return true;
@@ -215,10 +306,12 @@ export function createHelpView(container: HTMLElement, tabs: HTMLElement): HelpV
q.section.classList.toggle("open", open);
}
const term = route.screen === "glossary" ? route.term : null;
for (const [name, dt] of terms) {
for (const [name, { dt, card }] of terms) {
dt.classList.toggle("current", name === term);
card.classList.toggle("current", name === term);
if (name === term) {
dt.setAttribute("aria-current", "true");
card.hidden = false;
} else {
dt.removeAttribute("aria-current");
}
@@ -233,7 +326,7 @@ export function createHelpView(container: HTMLElement, tabs: HTMLElement): HelpV
lastOpen = opened;
// La barre haute de l'aide reste en vue (app.css) : ce qu'on
// amène en haut s'arrête 12 px sous elle (scroll-margin-top).
const target = opened?.section ?? (term === null ? undefined : terms.get(term));
const target = opened?.section ?? (term === null ? undefined : terms.get(term)?.card);
if (target !== undefined) {
target.scrollIntoView({ block: "start" });
} else if (closed !== null) {
+64
View File
@@ -0,0 +1,64 @@
// Ce que montre l'accueil, et où s'ouvre l'application : fonctions pures,
// testées sous Node (docs/identite/charte/accueil-tutoriel-aide.md).
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
import { installCardShown, isFirstLaunch, type Settings } from "./settings.js";
/** Les deux choix du premier lancement : « Je découvre » mène au
* tutoriel, « Je connais les règles » à la partie. */
export const WELCOME_LINKS = { discover: "#/tutoriel/1", play: "#/" } as const;
/** Les réglages après l'un des deux choix du premier lancement. */
export function welcomed(): Partial<Settings> {
return { welcomed: true };
}
/** L'adresse où ouvrir l'application lancée sans adresse d'écran (icône de
* l'écran d'accueil, adresse du site) : l'accueil. Une adresse donnée est
* gardée (null). */
export function launchHash(hash: string): string | null {
return hash === "" || hash === "#" ? "#/accueil" : null;
}
/** L'accueil, au premier lancement ou ensuite. */
export interface HomeModel {
/** Premier lancement : le choix « Je découvre » / « Je connais les
* règles », et rien d'autre. */
readonly welcome: boolean;
/** La carte de la partie : « Reprendre la partie » si une partie est en
* cours, sinon « Nouvelle partie » en action principale. */
readonly resume: boolean;
/** L'invitation à installer. */
readonly installCard: boolean;
}
/** Ce que montre l'accueil. playing : une partie est en cours (null : pas
* encore connu, le moteur se charge). */
export function homeModel(
settings: Settings,
playing: boolean | null,
installed: boolean,
): HomeModel {
const welcome = isFirstLaunch(settings);
return {
welcome,
resume: playing !== false,
installCard: !welcome && installCardShown(settings, installed),
};
}
/** La ligne « Installer l'application » du menu : tant que l'application
* n'est pas installée. */
export function installMenuShown(settings: Settings, installed: boolean): boolean {
return !installed && !settings.installed;
}
/** Les réglages après « Plus tard » sur l'invitation à installer. */
export function dismissInstall(settings: Settings): Partial<Settings> {
return {
installDismissals: settings.installDismissals + 1,
installDismissedAt: settings.gamesFinished,
};
}
+125
View File
@@ -0,0 +1,125 @@
// Les textes de l'accueil, du tutoriel, de l'installation et de la mise à
// jour (ADR 0007, point 5 ; docs/identite/charte/accueil-tutoriel-aide.md).
// Première version, à valider par Pierre. Le texte des étapes du tutoriel
// n'est pas ici : il vit dans docs/tutoriel/regles.md.
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
import type { ResultKind } from "../domain/game/engine.js";
import type { InstallPlatform } from "./install.js";
function plural(n: number, one: string, many: string): string {
return `${n} ${n > 1 ? many : one}`;
}
export const HOME_TEXTS = {
// Premier lancement
welcomeTitle: "Bienvenue à la table",
welcomeText:
"Ustagammon vous apprend le backgammon pas à pas, et vous fait jouer contre l'ordinateur, même sans réseau.",
discover: "Je découvre",
discoverDetail: "Les règles en huit petites étapes, plateau à l'appui",
knowRules: "Je connais les règles",
knowRulesDetail: "Une partie tout de suite",
tutorialInMenu: "Le tutoriel reste dans le menu, à tout moment.",
// Accueil
homeLink: "Accueil",
homeTitle: "À vous de jouer",
homeLead: "Reprenez votre partie contre l'ordinateur, ou revoyez les règles.",
gameOverline: "Partie en cours",
noGameOverline: "Pas de partie en cours",
againstComputer: "Contre l'ordinateur",
homePips: (player: number, computer: number) =>
`Compte de points : vous ${player}, l'ordinateur ${computer}`,
homeLoading: "Chargement…",
resumeGame: "Reprendre la partie",
rulesTitle: "Les règles du jeu",
rulesDetail: "Le tutoriel, en huit étapes, plateau à l'appui",
rulesStart: "Commencer",
rulesContinue: (step: number) => `Reprendre à l'étape ${step}`,
rulesAgain: "Revoir",
progress: (played: number, won: number) =>
`${plural(played, "partie jouée", "parties jouées")} · ${plural(won, "gagnée", "gagnées")}`,
footer: "Né à l'Atelier du Huit, à Cugnaux. Logiciel libre, sans publicité ni mise.",
// Installation
installTitle: "Installer Ustagammon",
installCardText: "Sur l'écran d'accueil, en plein écran, sans réseau.",
install: "Installer",
installLater: "Plus tard",
installMenu: "Installer l'application",
installLead:
"Une icône sur votre écran d'accueil, le jeu en plein écran, et tout marche sans réseau.",
installSteps: {
firefox: [
"Touchez le menu du navigateur ⋮, en bas ou en haut de l'écran.",
"Choisissez « Installer » ou « Ajouter à l'écran d'accueil ».",
"Confirmez. Ensuite, ouvrez Ustagammon depuis son icône.",
],
ios: [
"Touchez le bouton de partage, le carré d'où sort une flèche, en bas de Safari.",
"Choisissez « Sur l'écran d'accueil ».",
"Touchez « Ajouter ». Ensuite, ouvrez Ustagammon depuis son icône.",
],
} satisfies Record<InstallPlatform, readonly string[]>,
installUnderstood: "J'ai compris",
installed: "Ustagammon est installé : ouvrez-le depuis son icône.",
// Mise à jour
updateAvailable: "Nouvelle version prête.",
updateApply: "Mettre à jour",
updateCheck: "Rechercher une mise à jour",
updateChecking: "Recherche d'une mise à jour…",
updateLatest: "Vous avez la dernière version.",
updateUnavailable:
"Impossible de chercher une mise à jour : pas de réseau, ou navigateur qui ne le permet pas.",
// Tutoriel
tutorialMenu: "Les règles (tutoriel)",
tutorialProgress: "Étapes du tutoriel",
tutorialStepLink: (n: number, title: string) => `Étape ${n} : ${title}`,
tutorialSkip: "Quitter",
tutorialSkipLabel: "Quitter le tutoriel",
tutorialOverline: (n: number, total: number, practice: boolean) =>
`Les règles · ${n} sur ${total}${practice ? " · à vous" : ""}`,
tutorialEndOverline: "Les règles · fin",
tutorialEndTitle: "Vous connaissez les règles",
back: "Retour",
next: "Continuer",
replay: "Revoir",
replayLabel: "Revoir le coup",
take: "Prendre",
drop: "Passer",
tutorialLoading: "Chargement du tutoriel…",
tutorialFailed: "Le tutoriel n'a pas pu se charger.",
pathLabel: "Le sens du jeu de vos pions, en pointillés",
/** Le résultat d'une partie finie par le coup illustré (étape 7). */
illustratedEnd: (kind: ResultKind) =>
kind === "single"
? "Partie finie : l'ordinateur avait sorti des pions, c'est une partie simple."
: kind === "gammon"
? "Partie finie : l'ordinateur n'a sorti aucun pion, c'est un gammon."
: "Partie finie : il reste un pion de l'ordinateur dans votre camp, c'est un backgammon.",
computerDoubles: "L'ordinateur double.",
// Aide
helpTutorialTitle: "Nouveau au backgammon ?",
helpTutorialText: "Le tutoriel des règles, en huit étapes, plateau à l'appui.",
helpTutorialLink: "Apprendre les règles",
helpIntro:
"Une question en partie ? Elle a sûrement sa réponse ici. Touchez une question pour lire la réponse.",
seeOnBoard: "Voir sur le plateau",
glossarySearch: "Chercher un mot",
glossaryNone: "Aucun mot ne correspond.",
glossarySeeAlso: "Voir aussi :",
aboutVersion: (commit: string) => `Version publiée : ${commit}.`,
aboutVersionUnknown: "Version publiée : inconnue (lancée depuis les sources).",
aboutParagraphs: [
"Ustagammon est un logiciel libre, sous licence GNU GPL version 3 ou ultérieure : vous pouvez l'utiliser, l'étudier, le modifier et le partager.",
"Les textes du tutoriel, de l'aide et du glossaire sont sous licence Creative Commons BY-SA 4.0.",
"L'ordinateur joue et juge les coups avec GNU Backgammon (GPLv3), compilé pour le navigateur. Police Alegreya Sans, sous licence SIL Open Font License.",
"Né à l'Atelier du Huit, café associatif à Cugnaux. Sans publicité, sans compte, sans mise : tout reste sur votre téléphone.",
],
} as const;
+242
View File
@@ -0,0 +1,242 @@
// L'accueil (#/accueil) : au premier lancement, le choix « Je découvre » /
// « Je connais les règles » ; ensuite, la partie en cours, l'invitation à
// installer, les règles et la progression résumée
// (docs/identite/charte/accueil-tutoriel-aide.md, « Accueil »).
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
//
// Les leçons (1g) et la progression détaillée (1h) n'existent pas encore :
// l'accueil n'affiche que ce qui existe.
import type { SideBoards } from "../domain/game/game.js";
import { boardModel, OPENING_BOARDS } from "./board-model.js";
import { NO_MARKS, renderBoard } from "./board-view.js";
import { boardGeometry, PORTRAIT_ASPECT } from "./geometry.js";
import { type HomeModel, WELCOME_LINKS } from "./home-model.js";
import { HOME_TEXTS } from "./home-texts.js";
import { icon } from "./icons.js";
import { TUTORIAL_DONE } from "./settings.js";
import { TEXTS } from "./texts.js";
export interface HomeViewOptions {
readonly section: HTMLElement;
/** « Nouvelle partie » (bouton : une action, pas un écran). */
readonly newGame: () => void;
readonly install: () => void;
readonly dismissInstall: () => void;
/** Un des deux choix du premier lancement a été suivi. */
readonly welcomed: () => void;
}
export interface HomeState {
readonly model: HomeModel;
/** Le plateau de la partie en cours ou de la dernière partie (null :
* jamais joué). */
readonly sides: SideBoards | null;
/** La dernière étape du tutoriel vue. */
readonly tutorialSeen: number;
/** Parties jouées et gagnées (null : pas encore lues). */
readonly progress: { readonly played: number; readonly won: number } | null;
/** Le moteur se charge, ou l'écran est occupé. */
readonly busy: boolean;
}
export interface HomeView {
render(state: HomeState): void;
}
const SVG_NS = "http://www.w3.org/2000/svg";
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;
}
function boardSvg(parent: HTMLElement, id: string, className: string): SVGSVGElement {
const svg = document.createElementNS(SVG_NS, "svg");
svg.id = id;
svg.setAttribute("class", `board ${className}`);
svg.setAttribute("aria-hidden", "true");
parent.append(svg);
return svg;
}
function logo(parent: HTMLElement): void {
for (const [variant, file] of [
["logo-light", "logo-horizontal-couleur.svg"],
["logo-dark", "logo-horizontal-sombre.svg"],
] as const) {
const img = element(parent, "img", { className: variant });
img.src = `icons/${file}`;
img.alt = "Ustagammon";
}
}
function drawBoard(svg: SVGSVGElement, sides: SideBoards, width: number): void {
renderBoard(svg, boardGeometry("portrait", PORTRAIT_ASPECT, width / 640), boardModel(sides), {
...NO_MARKS,
dice: [],
cube: { value: 1, owner: null },
band: null,
controls: null,
});
}
/** Une grande carte-lien du premier lancement : le choix et son détail. */
function choice(parent: HTMLElement, id: string, href: string, label: string, detail: string) {
const link = element(parent, "a", { id, className: "choice" });
link.href = href;
element(link, "strong", { text: label });
element(link, "span", { text: detail });
return link;
}
export function createHomeView(options: HomeViewOptions): HomeView {
const { section } = options;
section.replaceChildren();
// Premier lancement.
const welcome = element(section, "div", { id: "home-welcome", className: "welcome" });
const welcomeLogo = element(welcome, "h1", { className: "welcome-logo" });
logo(welcomeLogo);
const welcomeBoard = boardSvg(welcome, "welcome-board", "welcome-board");
const welcomeText = element(welcome, "div", { className: "welcome-text" });
element(welcomeText, "h2", { text: HOME_TEXTS.welcomeTitle });
element(welcomeText, "p", { text: HOME_TEXTS.welcomeText });
const choices = element(welcome, "div", { className: "welcome-choices" });
const discover = choice(
choices,
"welcome-discover",
WELCOME_LINKS.discover,
HOME_TEXTS.discover,
HOME_TEXTS.discoverDetail,
);
discover.classList.add("primary");
const play = choice(
choices,
"welcome-play",
WELCOME_LINKS.play,
HOME_TEXTS.knowRules,
HOME_TEXTS.knowRulesDetail,
);
element(choices, "p", { className: "caption", text: HOME_TEXTS.tutorialInMenu });
for (const link of [discover, play]) {
link.addEventListener("click", () => options.welcomed());
}
// Ensuite.
const main = element(section, "div", { id: "home-main", className: "home-main" });
const intro = element(main, "div", { className: "home-intro" });
element(intro, "h2", { text: HOME_TEXTS.homeTitle });
element(intro, "p", { className: "muted", text: HOME_TEXTS.homeLead });
const card = element(main, "article", { id: "home-game", className: "card home-game" });
const cardTop = element(card, "div", { className: "home-game-top" });
const miniBox = element(cardTop, "div", { className: "home-mini" });
const mini = boardSvg(miniBox, "home-board", "home-board");
const facts = element(cardTop, "div", { className: "home-facts" });
const gameOverline = element(facts, "span", { className: "overline" });
element(facts, "strong", { text: HOME_TEXTS.againstComputer });
const pips = element(facts, "span", { id: "home-pips", className: "home-pips" });
const actions = element(card, "div", { className: "home-actions" });
const resume = element(actions, "a", {
id: "home-resume",
className: "button",
text: HOME_TEXTS.resumeGame,
});
resume.href = "#/";
const fresh = element(actions, "button", { id: "home-new", text: TEXTS.newGame });
fresh.type = "button";
fresh.addEventListener("click", () => options.newGame());
const install = element(main, "aside", { id: "install-card", className: "install-card" });
install.setAttribute("aria-label", HOME_TEXTS.installTitle);
const symbol = element(install, "img", { className: "install-symbol" });
symbol.src = "icons/symbole-couleur.svg";
symbol.alt = "";
const installText = element(install, "span", { className: "install-text" });
element(installText, "strong", { text: HOME_TEXTS.installTitle });
element(installText, "span", { className: "muted", text: HOME_TEXTS.installCardText });
const installButton = element(install, "button", {
id: "install-card-button",
className: "secondary",
text: HOME_TEXTS.install,
});
installButton.type = "button";
installButton.addEventListener("click", () => options.install());
const later = element(install, "button", { id: "install-later", className: "icon-button" });
later.type = "button";
later.append(icon("close"));
later.setAttribute("aria-label", HOME_TEXTS.installLater);
later.title = HOME_TEXTS.installLater;
later.addEventListener("click", () => options.dismissInstall());
const rules = element(main, "a", { id: "home-rules", className: "card home-rules" });
const rulesText = element(rules, "span", { className: "home-rules-text" });
element(rulesText, "strong", { text: HOME_TEXTS.rulesTitle });
element(rulesText, "span", { className: "muted", text: HOME_TEXTS.rulesDetail });
const rulesAction = element(rules, "span", { className: "pill" });
const progress = element(main, "p", {
id: "home-progress",
className: "caption home-progress",
});
const footer = element(main, "footer", { className: "home-footer" });
const footerSymbol = element(footer, "img");
footerSymbol.src = "icons/symbole-couleur.svg";
footerSymbol.alt = "";
element(footer, "span", { text: HOME_TEXTS.footer });
return {
render(state) {
const { model } = state;
welcome.hidden = !model.welcome;
main.hidden = model.welcome;
if (model.welcome) {
drawBoard(welcomeBoard, OPENING_BOARDS, welcomeBoard.clientWidth || 360);
return;
}
const sides = state.sides ?? OPENING_BOARDS;
drawBoard(mini, sides, miniBox.clientWidth || 132);
gameOverline.textContent = model.resume
? HOME_TEXTS.gameOverline
: HOME_TEXTS.noGameOverline;
pips.textContent = HOME_TEXTS.homePips(sides.player.pipCount, sides.computer.pipCount);
// Une seule action principale : « Reprendre la partie », ou
// « Nouvelle partie » quand il n'y a rien à reprendre.
resume.hidden = !model.resume;
fresh.textContent = TEXTS.newGame;
fresh.classList.toggle("secondary", model.resume);
fresh.disabled = state.busy;
install.hidden = !model.installCard;
const seen = state.tutorialSeen;
rules.href = seen > 0 && seen < TUTORIAL_DONE ? `#/tutoriel/${seen}` : "#/tutoriel/1";
rulesAction.textContent =
seen === 0
? HOME_TEXTS.rulesStart
: seen >= TUTORIAL_DONE
? HOME_TEXTS.rulesAgain
: HOME_TEXTS.rulesContinue(seen);
progress.textContent =
state.progress === null || state.progress.played === 0
? ""
: HOME_TEXTS.progress(state.progress.played, state.progress.won);
},
};
}
+3
View File
@@ -22,6 +22,9 @@ export const ICON_PATHS = {
vibrate: "M9 5h6v14H9zM5 8v8M19 8v8M2 10v4M22 10v4",
theme: "M12 21a9 9 0 1 0 0-18zM12 3a9 9 0 0 1 0 18",
screenOn: "M7 3h10v18H7zM11 18h2",
home: "M4 11l8-7 8 7M6 9.5V20h12V9.5M10 20v-6h4v6",
rules: "M4 5h6a2 2 0 0 1 2 2v13a2 2 0 0 0-2-2H4zM20 5h-6a2 2 0 0 0-2 2v13a2 2 0 0 1 2-2h6z",
install: "M12 4v11M7 10l5 5 5-5M5 20h14",
verdictGood: "M12 21a9 9 0 1 0 0-18a9 9 0 0 0 0 18zM8 12.5l3 3 5-6",
verdictDoubtful:
"M12 21a9 9 0 1 0 0-18a9 9 0 0 0 0 18zM9.5 9.3a2.6 2.6 0 1 1 3.6 2.4c-.7.3-1.1.9-1.1 1.6v.6M12 17.2v.1",
+82
View File
@@ -0,0 +1,82 @@
// L'installation de l'application (docs/identite/charte/accueil-tutoriel-
// aide.md, « Installation ») : l'invite du système quand le navigateur en
// donne une (Chrome, événement beforeinstallprompt), sinon une marche à
// suivre en trois étapes (Firefox, iPhone). Plus rien une fois
// l'application installée.
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
/** La marche à suivre à montrer quand le navigateur n'a pas d'invite. */
export type InstallPlatform = "ios" | "firefox";
/** D'après le navigateur : l'iPhone (et l'iPad) a sa propre marche à
* suivre ; les autres suivent celle de Firefox (menu ⋮, « Installer »). */
export function installPlatform(userAgent: string): InstallPlatform {
return /iPhone|iPad|iPod/.test(userAgent) ? "ios" : "firefox";
}
/** L'événement beforeinstallprompt de Chrome (absent des types du DOM). */
interface InstallPromptEvent extends Event {
readonly prompt: () => Promise<void>;
readonly userChoice: Promise<{ outcome: "accepted" | "dismissed" }>;
}
export interface Installer {
/** L'application tourne installée, ou vient de l'être. */
readonly installed: boolean;
/** Le navigateur a donné son invite d'installation. */
readonly canPrompt: boolean;
/** Ouvre l'invite du système ; « unavailable » s'il n'y en a pas. */
offer(): Promise<"accepted" | "dismissed" | "unavailable">;
}
/**
* Écoute le navigateur : garde l'invite reçue avec beforeinstallprompt (et
* empêche le navigateur de l'afficher de lui-même), et note l'installation
* (display-mode: standalone, événement appinstalled). onChange est appelé à
* chaque changement.
*/
export function startInstaller(onChange: (installedNow: boolean) => void): Installer {
const standalone = window.matchMedia("(display-mode: standalone)");
let installedNow = false;
let deferred: InstallPromptEvent | null = null;
window.addEventListener("beforeinstallprompt", (event) => {
event.preventDefault();
deferred = event as InstallPromptEvent;
onChange(false);
});
window.addEventListener("appinstalled", () => {
installedNow = true;
deferred = null;
onChange(true);
});
standalone.addEventListener("change", () => onChange(false));
return {
get installed() {
return installedNow || standalone.matches;
},
get canPrompt() {
return deferred !== null;
},
async offer() {
const event = deferred;
if (event === null) {
return "unavailable";
}
// Une invite ne sert qu'une fois.
deferred = null;
try {
await event.prompt();
const choice = await event.userChoice;
return choice.outcome;
} catch {
return "unavailable";
} finally {
onChange(false);
}
},
};
}
+99
View File
@@ -0,0 +1,99 @@
// Les textes de l'écran « Nouvelle partie » et du match (ADR 0007, point 5 ;
// docs/identite/charte/ecrans-etape-1.md, chapitre 1c). Les noms et
// descriptions des niveaux sont ceux de la charte ; le reste est une
// première version, à valider par Pierre.
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
import type { ComputerLevel } from "../domain/game/level.js";
function points(n: number): string {
return n > 1 ? `${n} points` : `${n} point`;
}
export const MATCH_TEXTS = {
// Écran « Nouvelle partie »
title: "Nouvelle partie",
close: "Fermer sans rien changer",
kindLabel: "Type de partie",
free: "Partie libre",
match: "Match",
/** « Le premier à [3] [5] [7] [11] points ». */
lengthBefore: "Le premier à",
lengthAfter: "points",
lengthLabel: (n: number) => `Match en ${n} points`,
crawfordRule:
"Quand un joueur arrive à un point de la victoire, la partie suivante se joue sans videau : c'est la",
crawfordLink: "règle de Crawford",
jacobyRule:
"Tant que personne n'a doublé, un gammon ou un backgammon ne rapporte qu'un point : c'est la",
jacobyLink: "règle de Jacoby",
levelTitle: "Niveau de l'ordinateur",
levels: {
beginner: {
name: "Débutant",
text: "Joue simplement et laisse passer des occasions. Idéal pour vos premières parties.",
},
amateur: {
name: "Amateur",
text: "Un joueur qui connaît les règles et commet encore des erreurs.",
},
intermediate: {
name: "Intermédiaire",
text: "Un habitué de la table : solide, sans être implacable.",
},
advanced: {
name: "Avancé",
text: "Un bon joueur de club. Peu d'erreurs de pions, un videau sérieux.",
},
expert: { name: "Expert", text: "Très peu d'erreurs. Chaque point se gagne." },
champion: {
name: "Champion",
text: "GNU Backgammon à son meilleur niveau sur ce téléphone.",
},
} satisfies Record<ComputerLevel, { name: string; text: string }>,
duringGame: "Pendant la partie",
advice: "Conseil du maître",
adviceDetail: "Une ligne après chacun de vos coups",
startGame: "Commencer la partie",
startMatch: (n: number) => `Commencer le match en ${n} points`,
kept: "Vos choix sont gardés pour la prochaine fois.",
// Score du match sur l'écran de jeu
/** « 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}`,
/** Paysage : « sur 5 ». */
outOf: (length: number) => `sur ${length}`,
playerScore: (n: number) => `Votre score : ${n}`,
computerScore: (n: number) => `Score de l'ordinateur : ${n}`,
// 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") =>
winner === "player" ? "Vous gagnez le match" : "L'ordinateur gagne le match",
/** « +2 points pour vous. Match en 3 points : vous 2, l'ordinateur 0. » */
matchDetail: (
winner: "player" | "computer",
n: number,
length: number,
player: number,
computer: number,
over: boolean,
) =>
`${points(n)} pour ${winner === "player" ? "vous" : "lui"}. ${
over ? "Score final" : `Match en ${length} points`
} : vous ${player}, l'ordinateur ${computer}.`,
crawfordNext: "La partie suivante est la partie Crawford : personne ne pourra doubler.",
};
+212
View File
@@ -0,0 +1,212 @@
// L'écran « Nouvelle partie » (#/nouvelle-partie) : type de partie (partie
// libre ou match en N points), niveau de l'ordinateur, conseil du maître
// (docs/identite/charte/ecrans-etape-1.md, chapitre 1c).
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
//
// Chaque choix est gardé tout de suite dans les réglages : « Vos choix sont
// gardés pour la prochaine fois ». Fermer l'écran ne touche pas à la partie
// en cours ; seul « Commencer » en lance une.
import { COMPUTER_LEVELS } from "../domain/game/level.js";
import { MATCH_LENGTHS } from "../domain/game/match.js";
import { icon } from "./icons.js";
import { MATCH_TEXTS } from "./match-texts.js";
import { routeHash } from "./router.js";
import type { Settings } from "./settings.js";
export interface NewGameViewOptions {
readonly section: HTMLElement;
readonly settings: () => Settings;
/** Un choix a changé : le garder. */
readonly change: (changes: Partial<Settings>) => void;
/** « Commencer la partie » ou « Commencer le match ». */
readonly start: () => void;
}
export interface NewGameView {
/** La croix : un lien « Retour » (l'écran de jeu le branche). */
readonly closeLink: HTMLAnchorElement;
/** Redessine d'après les réglages ; busy : le moteur n'est pas prêt. */
render(busy: boolean): void;
/** Le premier élément à recevoir le focus à l'ouverture. */
focus(): void;
}
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;
}
/** Un bouton qui fait partie d'un groupe de choix exclusifs. */
function radio(parent: HTMLElement, id: string, className: string, text?: string) {
const button = element(parent, "button", {
id,
className,
...(text === undefined ? {} : { text }),
});
button.type = "button";
button.setAttribute("role", "radio");
return button;
}
/** Une phrase sur une règle, terminée par le lien vers sa fiche. */
function rule(parent: HTMLElement, id: string, text: string, link: string, term: string) {
const p = element(parent, "p", { id, className: "new-game-rule" });
p.append(`${text} `);
const a = element(p, "a", { text: link });
a.href = routeHash({ screen: "glossary", term });
p.append(".");
return p;
}
export function createNewGameView(options: NewGameViewOptions): NewGameView {
const { section } = options;
section.replaceChildren();
section.setAttribute("aria-labelledby", "new-game-title");
const head = element(section, "div", { className: "new-game-head" });
const closeLink = element(head, "a", { id: "new-game-close", className: "icon-button" });
closeLink.href = "#/";
closeLink.setAttribute("aria-label", MATCH_TEXTS.close);
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(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);
const match = radio(kinds, "new-game-match", "segment", MATCH_TEXTS.match);
free.addEventListener("click", () => options.change({ gameKind: "free" }));
match.addEventListener("click", () => options.change({ gameKind: "match" }));
const matchOptions = element(kindColumn, "div", {
id: "new-game-match-options",
className: "new-game-options",
});
const lengths = element(matchOptions, "div", { className: "lengths" });
lengths.setAttribute("role", "radiogroup");
element(lengths, "span", { text: MATCH_TEXTS.lengthBefore });
const lengthButtons = MATCH_LENGTHS.map((n) => {
const button = radio(lengths, `new-game-length-${n}`, "length", String(n));
button.setAttribute("aria-label", MATCH_TEXTS.lengthLabel(n));
button.addEventListener("click", () => options.change({ matchLength: n }));
return button;
});
element(lengths, "span", { text: MATCH_TEXTS.lengthAfter });
rule(
matchOptions,
"new-game-crawford",
MATCH_TEXTS.crawfordRule,
MATCH_TEXTS.crawfordLink,
"regle-de-crawford",
);
const freeOptions = element(kindColumn, "div", {
id: "new-game-free-options",
className: "new-game-options",
});
rule(
freeOptions,
"new-game-jacoby",
MATCH_TEXTS.jacobyRule,
MATCH_TEXTS.jacobyLink,
"regle-de-jacoby",
);
// 2. Niveau de l'ordinateur : six barres de hauteur croissante.
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) => {
const button = radio(bars, `new-game-level-${level}`, `level level-${i + 1}`);
button.setAttribute("aria-label", MATCH_TEXTS.levels[level].name);
button.dataset.level = level;
element(button, "span", { className: "level-bar" });
button.addEventListener("click", () => options.change({ level }));
return button;
});
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(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" });
element(adviceLabel, "span", { text: MATCH_TEXTS.advice });
element(adviceLabel, "small", { text: MATCH_TEXTS.adviceDetail });
element(advice, "span", { className: "switch" }).setAttribute("aria-hidden", "true");
advice.addEventListener("click", () =>
options.change({ masterAdvice: !options.settings().masterAdvice }),
);
// 4. Barre d'action collée en bas.
const actions = element(section, "div", { className: "new-game-actions" });
const start = element(actions, "button", { id: "new-game-start" });
start.type = "button";
start.addEventListener("click", () => options.start());
element(actions, "p", { className: "new-game-kept", text: MATCH_TEXTS.kept });
return {
closeLink,
render(busy) {
const s = options.settings();
const isMatch = s.gameKind === "match";
free.setAttribute("aria-checked", String(!isMatch));
match.setAttribute("aria-checked", String(isMatch));
matchOptions.hidden = !isMatch;
freeOptions.hidden = isMatch;
for (const [i, button] of lengthButtons.entries()) {
button.setAttribute("aria-checked", String(MATCH_LENGTHS[i] === s.matchLength));
}
const chosen = COMPUTER_LEVELS.indexOf(s.level);
for (const [i, button] of levelButtons.entries()) {
button.setAttribute("aria-checked", String(i === chosen));
button.classList.toggle("reached", i <= chosen);
}
levelName.textContent = MATCH_TEXTS.levels[s.level].name;
levelText.textContent = MATCH_TEXTS.levels[s.level].text;
advice.setAttribute("aria-checked", String(s.masterAdvice));
start.textContent = isMatch
? MATCH_TEXTS.startMatch(s.matchLength)
: MATCH_TEXTS.startGame;
start.disabled = busy;
},
focus() {
(section.querySelector<HTMLElement>("[aria-checked='true']") ?? start).focus();
},
};
}
+51 -11
View File
@@ -10,7 +10,7 @@
export type Route =
/** #/ : la partie en cours. */
| { readonly screen: "game" }
/** #/accueil (écran à venir, étape 0e.5). */
/** #/accueil. */
| { readonly screen: "home" }
/** #/aide, et une question ouverte : #/aide/<identifiant>. */
| { readonly screen: "help"; readonly question: string | null }
@@ -18,12 +18,22 @@ export type Route =
| { readonly screen: "glossary"; readonly term: string | null }
/** #/a-propos. */
| { readonly screen: "about" }
/** #/tutoriel, et une étape : #/tutoriel/<n> (écran à venir). */
| { readonly screen: "tutorial"; readonly step: number | null }
/** #/tutoriel, une étape : #/tutoriel/<n>, et son écran « à vous » :
* #/tutoriel/<n>/a-vous. */
| { readonly screen: "tutorial"; readonly step: number | null; readonly practice: boolean }
/** #/tutoriel/fin : la fin du tutoriel. */
| { readonly screen: "tutorialEnd" }
/** #/installer : la marche à suivre pour installer l'application. */
| { readonly screen: "install" }
/** #/parties, et une partie : #/parties/<identifiant>. */
| { readonly screen: "games"; readonly game: string | null }
/** #/menu. */
| { 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. */
@@ -58,6 +68,9 @@ export function parseRoute(hash: string): Route {
if (head === "position") {
return positionRoute(rest.join("/")) ?? unknown;
}
if (head === "tutoriel") {
return tutorialRoute(rest) ?? unknown;
}
if (rest.length > 1) {
return unknown;
}
@@ -78,20 +91,37 @@ export function parseRoute(hash: string): Route {
return name === null ? { screen: "about" } : unknown;
case "menu":
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 "tutoriel": {
if (name === null) {
return { screen: "tutorial", step: null };
}
const step = Number(name);
return /^[1-9][0-9]?$/.test(name) ? { screen: "tutorial", step } : unknown;
}
case "installer":
return name === null ? { screen: "install" } : unknown;
default:
return unknown;
}
}
/** #/tutoriel, #/tutoriel/<n>, #/tutoriel/<n>/a-vous, #/tutoriel/fin. */
function tutorialRoute(rest: readonly string[]): Route | null {
const [name, part, ...more] = rest;
if (name === undefined) {
return { screen: "tutorial", step: null, practice: false };
}
if (more.length > 0) {
return null;
}
if (name === "fin") {
return part === undefined ? { screen: "tutorialEnd" } : null;
}
if (!/^[1-9][0-9]?$/.test(name) || (part !== undefined && part !== "a-vous")) {
return null;
}
return { screen: "tutorial", step: Number(name), practice: part === "a-vous" };
}
/**
* #/position/... : le Position ID (14 caractères), puis, après une barre, le
* Match ID. Chaque identifiant est encodé avec encodeURIComponent, mais une
@@ -134,11 +164,21 @@ export function routeHash(route: Route): string {
case "about":
return "#/a-propos";
case "tutorial":
return route.step === null ? "#/tutoriel" : `#/tutoriel/${route.step}`;
return route.step === null
? "#/tutoriel"
: `#/tutoriel/${route.step}${route.practice ? "/a-vous" : ""}`;
case "tutorialEnd":
return "#/tutoriel/fin";
case "install":
return "#/installer";
case "games":
return route.game === null ? "#/parties" : `#/parties/${route.game}`;
case "menu":
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
+124 -5
View File
@@ -3,6 +3,14 @@
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
//
// Format durable (ADR 0007, amendement du 2026-11-03) : un champ ajouté a
// toujours une valeur par défaut, et un réglage enregistré par une version
// précédente se relit sans perte.
import type { NewGame } from "../domain/game/game.js";
import { COMPUTER_LEVELS, type ComputerLevel, ENGINE_LEVELS } from "../domain/game/level.js";
import { firstGameOf, MATCH_LENGTHS } from "../domain/game/match.js";
export type Theme = "auto" | "light" | "dark";
@@ -15,18 +23,60 @@ export interface Settings {
/** Parties commencées : la consigne est longue pendant les trois
* premières, courte ensuite. */
readonly gamesStarted: number;
/** Parties terminées (l'invitation à installer vient après la
* première). */
readonly gamesFinished: number;
/** Le choix du premier lancement (« Je découvre », « Je connais les
* règles ») a été fait : il ne revient plus. */
readonly welcomed: boolean;
/** La dernière étape du tutoriel déjà vue (0 : aucune ; TUTORIAL_DONE :
* la fin). La barre de progression ne mène qu'aux étapes vues. */
readonly tutorialSeen: number;
/** Combien de fois « Plus tard » a masqué l'invitation à installer, et
* le nombre de parties terminées à la dernière fois. */
readonly installDismissals: number;
readonly installDismissedAt: number;
/** L'application a été installée (événement appinstalled). */
readonly installed: boolean;
/** Les choix de l'écran « Nouvelle partie », gardés pour la suivante :
* partie libre ou match, longueur du match, niveau de l'ordinateur,
* conseil du maître. */
readonly gameKind: GameKind;
readonly matchLength: number;
readonly level: ComputerLevel;
readonly masterAdvice: boolean;
}
/** Partie libre ou match. */
export type GameKind = "free" | "match";
export const DEFAULT_SETTINGS: Settings = {
vibrations: true,
keepScreenOn: true,
theme: "auto",
gamesStarted: 0,
gamesFinished: 0,
welcomed: false,
tutorialSeen: 0,
installDismissals: 0,
installDismissedAt: 0,
installed: false,
gameKind: "free",
matchLength: 5,
level: "intermediate",
masterAdvice: true,
};
/** Nombre de parties pendant lesquelles la consigne garde sa forme longue. */
export const LONG_PROMPT_GAMES = 3;
/** tutorialSeen quand la fin du tutoriel a été vue. */
export const TUTORIAL_DONE = 99;
/** Parties terminées de plus avant que l'invitation à installer, masquée
* par « Plus tard », revienne (une seule fois). */
export const INSTALL_AGAIN_AFTER = 3;
/** Les réglages lus (JSON), complétés par les valeurs par défaut ; une
* valeur illisible garde sa valeur par défaut. */
export function parseSettings(text: string | null): Settings {
@@ -39,19 +89,54 @@ export function parseSettings(text: string | null): Settings {
} catch {
// Réglages illisibles : valeurs par défaut.
}
const bool = (key: "vibrations" | "keepScreenOn") =>
typeof raw[key] === "boolean" ? (raw[key] as boolean) : DEFAULT_SETTINGS[key];
const bool = (
key: "vibrations" | "keepScreenOn" | "welcomed" | "installed" | "masterAdvice",
) => (typeof raw[key] === "boolean" ? (raw[key] as boolean) : DEFAULT_SETTINGS[key]);
const count = (
key:
| "gamesStarted"
| "gamesFinished"
| "tutorialSeen"
| "installDismissals"
| "installDismissedAt",
) => {
const value = raw[key];
return typeof value === "number" && Number.isInteger(value) && value >= 0
? value
: DEFAULT_SETTINGS[key];
};
const theme = raw.theme === "light" || raw.theme === "dark" ? raw.theme : "auto";
const games = raw.gamesStarted;
return {
vibrations: bool("vibrations"),
keepScreenOn: bool("keepScreenOn"),
theme,
gamesStarted:
typeof games === "number" && Number.isInteger(games) && games >= 0 ? games : 0,
gamesStarted: count("gamesStarted"),
gamesFinished: count("gamesFinished"),
welcomed: bool("welcomed"),
tutorialSeen: count("tutorialSeen"),
installDismissals: count("installDismissals"),
installDismissedAt: count("installDismissedAt"),
installed: bool("installed"),
gameKind: raw.gameKind === "match" ? "match" : "free",
matchLength: (MATCH_LENGTHS as readonly unknown[]).includes(raw.matchLength)
? (raw.matchLength as number)
: DEFAULT_SETTINGS.matchLength,
level: (COMPUTER_LEVELS as readonly unknown[]).includes(raw.level)
? (raw.level as ComputerLevel)
: DEFAULT_SETTINGS.level,
masterAdvice: bool("masterAdvice"),
};
}
/** La partie que lancent les choix de l'écran « Nouvelle partie » : le
* niveau de gnubg du niveau choisi, et la première partie d'un match. */
export function chosenGame(settings: Settings): NewGame {
const level = ENGINE_LEVELS[settings.level];
return settings.gameKind === "match"
? { level, match: firstGameOf(settings.matchLength) }
: { level };
}
/** Le thème suivant, dans l'ordre du menu : automatique, clair, sombre. */
export function nextTheme(theme: Theme): Theme {
return theme === "auto" ? "light" : theme === "light" ? "dark" : "auto";
@@ -61,3 +146,37 @@ export function nextTheme(theme: Theme): Theme {
export function longPrompts(settings: Settings): boolean {
return settings.gamesStarted <= LONG_PROMPT_GAMES;
}
/**
* Premier lancement : rien n'a encore été fait dans l'application (le choix
* d'accueil, une partie terminée, une étape du tutoriel). La partie
* commencée d'office au chargement compte pour une : jusqu'à une partie
* commencée, c'est encore le premier lancement.
*/
export function isFirstLaunch(settings: Settings): boolean {
return (
!settings.welcomed &&
settings.gamesStarted <= 1 &&
settings.gamesFinished === 0 &&
settings.tutorialSeen === 0
);
}
/**
* L'accueil montre-t-il l'invitation à installer ? Jamais une fois
* l'application installée ; à partir de la première partie terminée ;
* « Plus tard » la masque, et elle ne revient qu'une fois, après
* INSTALL_AGAIN_AFTER parties terminées de plus.
*/
export function installCardShown(settings: Settings, installed: boolean): boolean {
if (installed || settings.installed || settings.gamesFinished < 1) {
return false;
}
if (settings.installDismissals === 0) {
return true;
}
return (
settings.installDismissals === 1 &&
settings.gamesFinished >= settings.installDismissedAt + INSTALL_AGAIN_AFTER
);
}
-1
View File
@@ -164,7 +164,6 @@ export const TEXTS = {
// Adresses (ADR 0013)
unknownAddress: "Cette adresse ne mène à aucun écran : voici votre partie.",
screenNotYet: "Cet écran n'existe pas encore : voici votre partie.",
// Lien vers une position (ADR 0013)
copyPositionLink: "Copier le lien de la position",
+96
View File
@@ -0,0 +1,96 @@
// Le parcours du tutoriel : l'écran d'avant, l'écran d'après, et la barre
// de progression (charte : « les segments sont des liens vers les étapes
// déjà vues, pas vers les suivantes »). Fonctions pures, testées sous Node.
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
import type { Route } from "./router.js";
import { TUTORIAL_DONE } from "./settings.js";
import type { Tutorial } from "./tutorial-text.js";
/** Un écran du tutoriel : une étape (explication ou « à vous »), ou la fin. */
export type TutorialPlace = { readonly step: number; readonly practice: boolean } | "end";
/** L'écran que désigne une adresse, ramené à un écran qui existe : une
* étape hors du tutoriel mène à la première, un « à vous » absent à
* l'explication de son étape. */
export function placeOf(tutorial: Tutorial, route: Route): TutorialPlace {
if (route.screen === "tutorialEnd") {
return "end";
}
if (route.screen !== "tutorial" || route.step === null) {
return { step: 1, practice: false };
}
const step = tutorial.steps[route.step - 1];
if (step === undefined) {
return { step: 1, practice: false };
}
return { step: step.number, practice: route.practice && step.practice !== null };
}
/** L'adresse d'un écran du tutoriel. */
export function placeRoute(place: TutorialPlace): Route {
return place === "end"
? { screen: "tutorialEnd" }
: { screen: "tutorial", step: place.step, practice: place.practice };
}
/** L'écran suivant (la fin après la dernière étape). */
export function nextPlace(tutorial: Tutorial, place: TutorialPlace): TutorialPlace {
if (place === "end") {
return "end";
}
const step = tutorial.steps[place.step - 1];
if (!place.practice && step?.practice != null) {
return { step: place.step, practice: true };
}
return place.step >= tutorial.steps.length ? "end" : { step: place.step + 1, practice: false };
}
/** L'écran précédent (null avant la première étape). */
export function previousPlace(tutorial: Tutorial, place: TutorialPlace): TutorialPlace | null {
if (place === "end") {
const last = tutorial.steps.at(-1);
return last === undefined ? null : { step: last.number, practice: last.practice !== null };
}
if (place.practice) {
return { step: place.step, practice: false };
}
const before = tutorial.steps[place.step - 2];
return before === undefined
? null
: { step: before.number, practice: before.practice !== null };
}
/** Ce que retient la progression quand on arrive sur un écran : la
* dernière étape vue (TUTORIAL_DONE à la fin). */
export function seenAfter(seen: number, place: TutorialPlace): number {
return place === "end" ? TUTORIAL_DONE : Math.max(seen, place.step);
}
/** Un segment de la barre de progression. */
export interface ProgressSegment {
readonly step: number;
/** Étape faite ou en cours : le segment est en brique. */
readonly done: boolean;
/** Étape déjà vue : le segment est un lien vers elle. */
readonly link: boolean;
readonly current: boolean;
}
/** La barre de progression, sur l'écran place, quand les étapes jusqu'à
* seen ont déjà été vues. */
export function progress(total: number, place: TutorialPlace, seen: number): ProgressSegment[] {
const current = place === "end" ? total + 1 : place.step;
const reached = Math.max(seen, current);
return Array.from({ length: total }, (_, i) => {
const step = i + 1;
return {
step,
done: step <= current,
link: step <= reached && step !== current,
current: step === current,
};
});
}
+204
View File
@@ -0,0 +1,204 @@
// Lecture du texte du tutoriel (docs/tutoriel/regles.md, format décrit dans
// docs/tutoriel/README.md), copié tel quel dans le site par make site
// (ADR 0008 : une seule source). Fonction pure, testée sous Node.
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
/** Un morceau de texte : simple, en gras, ou lien vers une fiche du
* glossaire (nom de fichier sans .md). */
export interface Inline {
readonly text: string;
readonly strong?: boolean;
readonly term?: string;
}
export type Paragraph = readonly Inline[];
/** Un écran du tutoriel : explication, ou « à vous ». */
export interface TutorialScreen {
/** Identifiant de position de gnubg, vu du camp au trait. */
readonly position: string;
readonly onRoll: "player" | "computer";
/** Le jet du coup illustré (explication) ou à jouer (« à vous »). */
readonly roll: readonly [number, number] | null;
/** Explication : le coup illustré, en notation de gnubg. */
readonly move: string | null;
/** Explication : le sens du jeu dessiné en pointillés. */
readonly path: boolean;
/** « À vous » : les coups justes, en notation de gnubg. */
readonly expected: readonly string[];
/** « À vous » de videau : la bonne réponse au double. */
readonly answer: "take" | "pass" | null;
readonly right: Paragraph;
readonly wrong: Paragraph;
readonly paragraphs: readonly Paragraph[];
}
export interface TutorialStep {
readonly number: number;
readonly title: string;
readonly explain: TutorialScreen;
readonly practice: TutorialScreen | null;
}
export interface Tutorial {
readonly steps: readonly TutorialStep[];
readonly end: { readonly action: string; readonly paragraphs: readonly Paragraph[] };
}
/** Lit les liens [mot](fiche.md) et le **gras** d'une ligne de texte. */
export function parseInline(text: string): Paragraph {
const parts: Inline[] = [];
const pattern = /\[([^\]]+)\]\(([a-z0-9-]+)\.md\)|\*\*([^*]+)\*\*/g;
let last = 0;
for (const match of text.matchAll(pattern)) {
const index = match.index ?? 0;
if (index > last) {
parts.push({ text: text.slice(last, index) });
}
if (match[1] !== undefined && match[2] !== undefined) {
parts.push({ text: match[1], term: match[2] });
} else {
parts.push({ text: match[3] ?? "", strong: true });
}
last = index + match[0].length;
}
if (last < text.length) {
parts.push({ text: text.slice(last) });
}
return parts;
}
interface RawScreen {
keys: Map<string, string[]>;
paragraphs: string[];
}
function emptyScreen(): RawScreen {
return { keys: new Map(), paragraphs: [] };
}
function rollOf(text: string | undefined, where: string): readonly [number, number] | null {
if (text === undefined) {
return null;
}
const match = /^([1-6])-([1-6])$/.exec(text);
if (match === null) {
throw new Error(`${where} : jet illisible « ${text} »`);
}
return [Number(match[1]), Number(match[2])];
}
function screenOf(raw: RawScreen, where: string): TutorialScreen {
const one = (key: string) => raw.keys.get(key)?.[0];
const position = one("position");
if (position === undefined) {
throw new Error(`${where} : position absente`);
}
const trait = one("trait");
if (trait !== undefined && trait !== "ordinateur" && trait !== "joueur") {
throw new Error(`${where} : trait inconnu « ${trait} »`);
}
const answer = one("réponse");
if (answer !== undefined && answer !== "prendre" && answer !== "passer") {
throw new Error(`${where} : réponse inconnue « ${answer} »`);
}
return {
position,
onRoll: trait === "ordinateur" ? "computer" : "player",
roll: rollOf(one("jet"), where),
move: one("coup") ?? null,
path: one("chemin") === "oui",
expected: raw.keys.get("attendu") ?? [],
answer: answer === undefined ? null : answer === "prendre" ? "take" : "pass",
right: parseInline(one("juste") ?? ""),
wrong: parseInline(one("refus") ?? ""),
paragraphs: raw.paragraphs.map(parseInline),
};
}
/** Lit le texte du tutoriel. Erreur si un écran est incomplet ou si les
* étapes ne se suivent pas. */
export function parseTutorial(text: string): Tutorial {
const steps: {
number: number;
title: string;
explain: RawScreen;
practice: RawScreen | null;
}[] = [];
let end: RawScreen | null = null;
let current: RawScreen | null = null;
let paragraph: string[] = [];
const endParagraph = () => {
if (paragraph.length > 0 && current !== null) {
current.paragraphs.push(paragraph.join(" "));
}
paragraph = [];
};
for (const raw of text.split("\n")) {
const line = raw.trim();
const step = /^## (\d+)\. (.+)$/.exec(line);
const key = /^- ([a-zéè]+): (.+)$/.exec(line);
if (line === "") {
endParagraph();
} else if (step !== null) {
endParagraph();
current = emptyScreen();
steps.push({
number: Number(step[1]),
title: step[2] ?? "",
explain: current,
practice: null,
});
} else if (line === "## Fin") {
endParagraph();
current = emptyScreen();
end = current;
} else if (line === "### À vous") {
endParagraph();
const last = steps.at(-1);
if (last === undefined) {
throw new Error("« À vous » hors d'une étape");
}
current = emptyScreen();
last.practice = current;
} else if (current === null) {
// Notice avant la première étape : non affichée.
} else if (key !== null && paragraph.length === 0 && current.paragraphs.length === 0) {
const values = current.keys.get(key[1] ?? "") ?? [];
values.push((key[2] ?? "").trim());
current.keys.set(key[1] ?? "", values);
} else {
paragraph.push(line);
}
}
endParagraph();
steps.forEach((step, i) => {
if (step.number !== i + 1) {
throw new Error(`étape ${step.number} à la place de l'étape ${i + 1}`);
}
});
if (end === null) {
throw new Error("écran de fin absent");
}
const finish: RawScreen = end;
return {
steps: steps.map((step) => ({
number: step.number,
title: step.title,
explain: screenOf(step.explain, `étape ${step.number}`),
practice:
step.practice === null
? null
: screenOf(step.practice, `étape ${step.number}, à vous`),
})),
end: {
action: finish.keys.get("action")?.[0] ?? "",
paragraphs: finish.paragraphs.map(parseInline),
},
};
}
+734
View File
@@ -0,0 +1,734 @@
// L'écran du tutoriel (#/tutoriel/<n>, #/tutoriel/<n>/a-vous,
// #/tutoriel/fin) : la barre de progression, le titre, le plateau, le texte,
// et les écrans « à vous » où le joueur fait le geste sur le vrai plateau
// (docs/identite/charte/accueil-tutoriel-aide.md, « Tutoriel des règles »).
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
//
// Le texte vient de docs/tutoriel/regles.md (copie du site) ; les plateaux,
// les coups illustrés et le jugement des gestes viennent du domaine, branché
// sur le moteur (TutorialAccess, assemblé dans main.ts). Aucune règle n'est
// écrite ici.
import type { MoveSteps } from "../domain/game/game.js";
import type { HalfMove } from "../domain/game/position.js";
import type { Roll } from "../domain/game/roll.js";
import type { Side } from "../domain/game/side.js";
import type { PracticeVerdict, TutorialBoard } from "../domain/learning/tutorial.js";
import {
boardModel,
moveTargets,
moveTrace,
onlyHalfMove,
type Place,
placeOf as placeOfChecker,
} from "./board-model.js";
import {
type BoardDecor,
checkerSpot,
NO_MARKS,
renderBoard,
type ShownDie,
slideChecker,
} from "./board-view.js";
import { boardGeometry, columnLeft, type Geometry, PORTRAIT_ASPECT } from "./geometry.js";
import { HOME_TEXTS } from "./home-texts.js";
import { icon } from "./icons.js";
import { type Route, routeHash } from "./router.js";
import {
nextPlace,
placeOf,
placeRoute,
previousPlace,
progress,
type TutorialPlace,
} from "./tutorial-nav.js";
import {
type Paragraph,
parseTutorial,
type Tutorial,
type TutorialScreen,
} from "./tutorial-text.js";
/** Le coup illustré se joue tout seul après une seconde. */
const AUTOPLAY_MS = 1000;
const SLIDE_MS = 160;
const PAUSE_MS = 120;
/** Un pion refusé reste un instant à sa place avant de revenir. */
const REFUSED_MS = 450;
const SOURCE = "docs/tutoriel/regles.md";
/** Un écran « à vous » où l'on joue un coup (MovePractice du domaine). */
export interface PracticeSession {
board(): Promise<{
player: TutorialBoard["sides"]["player"];
computer: TutorialBoard["sides"]["computer"];
}>;
nextHalfMoves(): Promise<HalfMove[]>;
play(halfMove: HalfMove): Promise<PracticeVerdict>;
reset(): void;
readonly diceToPlay: readonly { value: number; used: boolean }[];
}
/** Ce que le tutoriel demande au domaine et au moteur. */
export interface TutorialAccess {
board(position: string, onRoll: Side): Promise<TutorialBoard>;
illustrate(
position: string,
roll: Roll,
move: string,
): Promise<{ steps: MoveSteps; after: TutorialBoard }>;
practice(position: string, roll: Roll, expected: readonly string[]): Promise<PracticeSession>;
shouldTake(position: string): Promise<boolean>;
}
export interface TutorialViewOptions {
readonly section: HTMLElement;
readonly access: TutorialAccess;
readonly engineReady: Promise<unknown>;
/** La dernière étape vue, et son enregistrement à l'arrivée sur un
* écran. */
readonly seen: () => number;
readonly visit: (place: TutorialPlace) => void;
/** Un geste juste : la vibration « pion posé ». */
readonly placed: () => void;
readonly reducedMotion: MediaQueryList;
/** L'écran est-il en paysage (plateau à gauche, texte à droite) ? */
readonly landscape: () => boolean;
/** L'adresse a désigné un écran qui n'existe pas : la remplacer. */
readonly replace: (hash: string) => void;
}
export interface TutorialView {
show(route: Route): Promise<void>;
/** La taille de l'écran a changé : redessiner le plateau. */
relayout(): void;
/** L'écran est quitté : arrêter le coup illustré. */
hide(): void;
}
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;
}
/** Un paragraphe du tutoriel : texte, gras, liens vers le glossaire. */
export function fillParagraph(target: HTMLElement, paragraph: Paragraph): void {
target.replaceChildren();
for (const part of paragraph) {
if (part.term !== undefined) {
const link = document.createElement("a");
link.href = routeHash({ screen: "glossary", term: part.term });
link.textContent = part.text;
target.append(link);
} else if (part.strong === true) {
const strong = document.createElement("strong");
strong.textContent = part.text;
target.append(strong);
} else {
target.append(part.text);
}
}
}
const SVG_NS = "http://www.w3.org/2000/svg";
/** Le sens du jeu des pions du joueur, en pointillés : du haut à droite
* (flèche 24) vers la gauche, puis en bas jusqu'à la flèche 1 et la sortie.
* Tracé sous les pions et sous les numéros des flèches, qu'il ne cache
* jamais. pxPerUnit : pixels à l'écran par unité du dessin (le liseré
* ivoire fait 8 px, les points 4 px, quelle que soit la taille). */
function drawPath(svg: SVGSVGElement, g: Geometry, pxPerUnit: number): void {
const center = (column: number) => columnLeft(g, column) + g.pointWidth / 2;
const top = g.height * 0.3;
const bottom = g.height * 0.7;
const right = center(11);
const left = center(0) - g.pointWidth * 0.25;
const turn = g.pointWidth * 0.6;
const end = g.trayLeft + g.tray * 0.25;
const d =
`M ${right} ${top} L ${left + turn} ${top} Q ${left} ${top} ${left} ${top + turn} ` +
`L ${left} ${bottom - turn} Q ${left} ${bottom} ${left + turn} ${bottom} L ${end} ${bottom}`;
const group = document.createElementNS(SVG_NS, "g");
group.setAttribute("class", "tutorial-path");
group.setAttribute("role", "img");
group.setAttribute("aria-label", HOME_TEXTS.pathLabel);
const px = (n: number) => n / pxPerUnit;
const width = px(4);
for (const [className, stroke] of [
["path-edge", px(8)],
["path-dots", width],
] as const) {
const path = document.createElementNS(SVG_NS, "path");
path.setAttribute("class", className);
path.setAttribute("d", d);
path.setAttribute("stroke-width", String(stroke));
if (className === "path-dots") {
path.setAttribute("stroke-dasharray", `0.1 ${px(10)}`);
}
group.append(path);
}
const head = document.createElementNS(SVG_NS, "path");
head.setAttribute("class", "path-head");
const size = px(7);
head.setAttribute(
"d",
`M ${end - size} ${bottom - size} L ${end} ${bottom} L ${end - size} ${bottom + size}`,
);
head.setAttribute("stroke-width", String(px(3)));
group.append(head);
// Sous les pions, et sous les numéros des flèches, remontés au-dessus
// du tracé ; les zones à toucher restent devant tout.
const pieces = svg.querySelector(":scope > g.pieces");
svg.insertBefore(group, pieces);
for (const number of svg.querySelectorAll(":scope > .point-number")) {
svg.insertBefore(number, pieces);
}
}
/** Crée l'écran du tutoriel dans section (vide dans index.html). */
export function createTutorialView(options: TutorialViewOptions): TutorialView {
const { section, access } = options;
section.replaceChildren();
const head = element(section, "header", { className: "tutorial-head" });
const bar = element(head, "nav", { id: "tutorial-progress", className: "tutorial-progress" });
bar.setAttribute("aria-label", HOME_TEXTS.tutorialProgress);
const skip = element(head, "a", { id: "tutorial-skip", className: "tutorial-skip" });
skip.href = "#/";
skip.textContent = HOME_TEXTS.tutorialSkip;
skip.setAttribute("aria-label", HOME_TEXTS.tutorialSkipLabel);
const overline = element(section, "p", { id: "tutorial-overline", className: "overline" });
const title = element(section, "h2", { id: "tutorial-title", className: "tutorial-title" });
const boardBox = element(section, "div", { className: "tutorial-board-box" });
const svg = document.createElementNS(SVG_NS, "svg");
svg.id = "tutorial-board";
svg.setAttribute("class", "board tutorial-board");
svg.setAttribute("role", "img");
boardBox.append(svg);
const replay = element(boardBox, "button", { id: "tutorial-replay", className: "icon-button" });
replay.type = "button";
replay.append(icon("replay"));
replay.setAttribute("aria-label", HOME_TEXTS.replayLabel);
replay.title = HOME_TEXTS.replayLabel;
// Le texte, la consigne « à vous » et la réponse : en paysage, la seule
// partie qui défile si l'écran est trop bas (app.css).
const body = element(section, "div", { className: "tutorial-body" });
const text = element(body, "div", { id: "tutorial-text", className: "tutorial-text" });
const task = element(body, "p", { id: "tutorial-task", className: "tutorial-task" });
const feedback = element(body, "p", {
id: "tutorial-feedback",
className: "tutorial-feedback",
});
feedback.setAttribute("role", "status");
const cube = element(section, "div", { id: "tutorial-cube", className: "tutorial-cube" });
const drop = element(cube, "button", {
id: "tutorial-drop",
className: "secondary",
text: HOME_TEXTS.drop,
});
drop.type = "button";
const take = element(cube, "button", {
id: "tutorial-take",
className: "secondary",
text: HOME_TEXTS.take,
});
take.type = "button";
const nav = element(section, "div", { className: "tutorial-nav" });
const back = element(nav, "a", {
id: "tutorial-back",
className: "button secondary",
text: HOME_TEXTS.back,
});
const next = element(nav, "a", {
id: "tutorial-next",
className: "button",
text: HOME_TEXTS.next,
});
let tutorial: Promise<Tutorial> | null = null;
// Chaque affichage a son numéro : un affichage dépassé ne touche plus
// à l'écran (le joueur est déjà ailleurs).
let shown = 0;
let autoplay: ReturnType<typeof setTimeout> | null = null;
// Un coup illustré se joue, ou un geste est jugé : les touchers
// attendent (data-busy, pour les tests dans un navigateur).
let busy = false;
const setBusy = (value: boolean) => {
busy = value;
section.toggleAttribute("data-busy", value);
};
// L'écran affiché.
let screen: TutorialScreen | null = null;
let place: TutorialPlace | null = null;
let boards: TutorialBoard | null = null;
let illustration: { steps: MoveSteps; after: TutorialBoard } | null = null;
let illustrated = false;
let practice: PracticeSession | null = null;
let targets = new Map<Place, Map<Place, HalfMove>>();
let selected: Place | null = null;
let solved = false;
const load = () => {
tutorial ??= fetch(SOURCE)
.then((response) => {
if (!response.ok) {
throw new Error(`${SOURCE} : ${response.status}`);
}
return response.text();
})
.then(parseTutorial)
.catch((error: unknown) => {
tutorial = null;
throw error;
});
return tutorial;
};
/** En paysage, le plateau occupe toute la hauteur, à gauche de la
* colonne du texte (app.css), avec la géométrie du jeu en paysage. */
function geometry(): Geometry {
const box = svg.getBoundingClientRect();
if (options.landscape()) {
const width = box.width || window.innerWidth;
const height = box.height || window.innerHeight;
return boardGeometry("landscape", width / height, height / 520);
}
const width = box.width || Math.min(window.innerWidth, 640);
return boardGeometry("portrait", PORTRAIT_ASPECT, width / 640);
}
function shownDice(): ShownDie[] {
if (practice !== null) {
return practice.diceToPlay.map((die) => ({
value: die.value,
side: "player",
blocked: false,
used: die.used,
}));
}
const roll = screen?.move != null ? screen.roll : null;
if (roll === null || roll === undefined) {
return [];
}
const [a, b] = roll;
return [a, b].map((value) => ({
value,
side: "player" as const,
blocked: false,
used: false,
}));
}
/** Redessine le plateau dans son état courant. */
function draw(sides: TutorialBoard["sides"] | null = null): void {
const current = sides ?? (illustrated ? illustration?.after.sides : null) ?? boards?.sides;
if (current === undefined || current === null) {
return;
}
const g = geometry();
const decor: BoardDecor = {
...NO_MARKS,
dice: shownDice(),
cube: { value: 1, owner: null },
band: null,
controls: null,
movable: practice !== null && !solved && !busy ? new Set(targets.keys()) : new Set(),
selected,
targets: new Set(selected === null ? [] : (targets.get(selected)?.keys() ?? [])),
trace:
illustrated && illustration !== null
? moveTrace("player", illustration.steps.halfMoves, illustration.steps.hits)
: null,
};
renderBoard(svg, g, boardModel(current), decor);
if (screen?.path === true) {
drawPath(svg, g, (svg.getBoundingClientRect().width || g.width) / g.width);
}
}
function clearTimer(): void {
if (autoplay !== null) {
clearTimeout(autoplay);
autoplay = null;
}
}
/** Le coup illustré, pion par pion (160 ms par pion, 120 ms entre deux),
* puis ses traces ; sans mouvement si le système le demande. */
async function playIllustration(id: number): Promise<void> {
const shownMove = illustration;
if (shownMove === null || id !== shown) {
return;
}
setBusy(true);
replay.disabled = true;
illustrated = false;
try {
if (!options.reducedMotion.matches) {
const { steps } = shownMove;
for (const [i, halfMove] of steps.halfMoves.entries()) {
if (id !== shown) {
return;
}
if (i > 0) {
await new Promise((resolve) => setTimeout(resolve, PAUSE_MS));
}
const from = placeOfChecker("player", halfMove.from);
const to = placeOfChecker("player", halfMove.to);
draw(steps.boards[i] ?? null);
const start = checkerSpot(svg, from);
const victim = steps.hits[i] === true ? checkerSpot(svg, to) : null;
draw(steps.boards[i + 1] ?? null);
await Promise.all([
start && slideChecker(svg, to, start, SLIDE_MS),
victim && slideChecker(svg, "bar-computer", victim, SLIDE_MS),
]);
}
}
} finally {
if (id === shown) {
setBusy(false);
illustrated = true;
replay.disabled = false;
draw();
const result = shownMove.after.result;
feedback.textContent =
result === null ? "" : HOME_TEXTS.illustratedEnd(result.kind);
feedback.dataset.verdict = result === null ? "" : "info";
}
}
}
async function refreshTargets(): Promise<void> {
targets =
practice === null || solved ? new Map() : moveTargets(await practice.nextHalfMoves());
if (selected !== null && !targets.has(selected)) {
selected = null;
}
}
function setVerdict(verdict: "right" | "wrong", words: Paragraph): void {
feedback.dataset.verdict = verdict;
fillParagraph(feedback, words);
if (verdict === "right") {
feedback.prepend(icon("verdictGood"));
}
}
function markSolved(): void {
solved = true;
section.toggleAttribute("data-solved", true);
next.classList.remove("secondary");
take.disabled = true;
drop.disabled = true;
options.placed();
}
async function playHalfMove(halfMove: HalfMove): Promise<void> {
const session = practice;
const current = screen;
if (session === null || current === null || busy || solved) {
return;
}
const id = shown;
setBusy(true);
selected = null;
try {
const verdict = await session.play(halfMove);
if (id !== shown) {
return;
}
draw(await session.board());
if (verdict === "right") {
markSolved();
setVerdict("right", current.right);
} else if (verdict === "wrong") {
// Le pion revient, et une phrase dit pourquoi.
await new Promise((resolve) => setTimeout(resolve, REFUSED_MS));
if (id !== shown) {
return;
}
session.reset();
setVerdict("wrong", current.wrong);
} else {
feedback.textContent = "";
feedback.dataset.verdict = "";
}
await refreshTargets();
} finally {
if (id === shown) {
setBusy(false);
draw(practice === null ? null : await practice.board());
}
}
}
svg.addEventListener("click", (event) => {
if (practice === null || busy || solved) {
return;
}
const target = event.target as Element | null;
const placeName = target
?.closest("[data-place]")
?.getAttribute("data-place") as Place | null;
if (placeName == null) {
return;
}
const halfMove = selected === null ? undefined : targets.get(selected)?.get(placeName);
if (halfMove !== undefined) {
void playHalfMove(halfMove);
return;
}
const only = placeName === selected ? null : onlyHalfMove(targets, placeName);
if (only !== null) {
void playHalfMove(only);
return;
}
selected = targets.has(placeName) && placeName !== selected ? placeName : null;
void practice.board().then((sides) => draw(sides));
});
replay.addEventListener("click", () => {
clearTimer();
void playIllustration(shown);
});
async function answer(takes: boolean): Promise<void> {
const current = screen;
if (current === null || solved || busy) {
return;
}
const id = shown;
setBusy(true);
try {
const right = (await access.shouldTake(current.position)) === takes;
if (id !== shown) {
return;
}
if (right) {
markSolved();
}
setVerdict(right ? "right" : "wrong", right ? current.right : current.wrong);
} finally {
setBusy(false);
}
}
take.addEventListener("click", () => void answer(true));
drop.addEventListener("click", () => void answer(false));
function renderProgress(total: number, at: TutorialPlace, steps: Tutorial["steps"]): void {
bar.replaceChildren();
for (const segment of progress(total, at, options.seen())) {
const stepTitle = steps[segment.step - 1]?.title ?? "";
const label = HOME_TEXTS.tutorialStepLink(segment.step, stepTitle);
const item = segment.link
? document.createElement("a")
: document.createElement("span");
item.className = `segment${segment.done ? " done" : ""}`;
if (item instanceof HTMLAnchorElement) {
item.href = routeHash({ screen: "tutorial", step: segment.step, practice: false });
item.setAttribute("aria-label", label);
} else {
item.setAttribute("aria-hidden", "true");
}
if (segment.current) {
item.setAttribute("aria-current", "step");
}
bar.append(item);
}
bar.setAttribute(
"aria-description",
at === "end"
? HOME_TEXTS.tutorialEndOverline
: HOME_TEXTS.tutorialOverline(at.step, total, at.practice),
);
}
return {
async show(route) {
const id = ++shown;
// Prêt (pour les tests dans un navigateur) une fois ce plateau dessiné.
delete section.dataset.ready;
clearTimer();
setBusy(false);
practice = null;
illustration = null;
illustrated = false;
screen = null;
boards = null;
selected = null;
targets = new Map();
solved = false;
section.toggleAttribute("data-solved", false);
feedback.textContent = "";
feedback.dataset.verdict = "";
task.hidden = true;
cube.hidden = true;
replay.hidden = true;
take.disabled = false;
drop.disabled = false;
let loaded: Tutorial;
try {
if (tutorial === null) {
title.textContent = HOME_TEXTS.tutorialLoading;
text.replaceChildren();
svg.replaceChildren();
}
loaded = await load();
} catch {
if (id === shown) {
title.textContent = HOME_TEXTS.tutorialFailed;
}
return;
}
if (id !== shown) {
return;
}
const at = placeOf(loaded, route);
const wanted = routeHash(placeRoute(at));
if (routeHash(route) !== wanted) {
options.replace(wanted);
return;
}
place = at;
options.visit(at);
const total = loaded.steps.length;
renderProgress(total, at, loaded.steps);
const before = previousPlace(loaded, at);
back.href = before === null ? "#/accueil" : routeHash(placeRoute(before));
if (at === "end") {
overline.textContent = HOME_TEXTS.tutorialEndOverline;
title.textContent = HOME_TEXTS.tutorialEndTitle;
text.replaceChildren();
for (const paragraph of loaded.end.paragraphs) {
fillParagraph(element(text, "p"), paragraph);
}
next.textContent = loaded.end.action;
next.href = "#/";
next.classList.remove("secondary");
screen = loaded.steps[0]?.explain ?? null;
} else {
const step = loaded.steps[at.step - 1];
const current = at.practice ? step?.practice : step?.explain;
if (step === undefined || current == null) {
return;
}
screen = { ...current, path: current.path && !at.practice };
overline.textContent = HOME_TEXTS.tutorialOverline(at.step, total, at.practice);
title.textContent = step.title;
text.replaceChildren();
const paragraphs = at.practice ? [] : current.paragraphs;
for (const paragraph of paragraphs) {
fillParagraph(element(text, "p"), paragraph);
}
if (at.practice) {
task.hidden = false;
task.replaceChildren();
for (const [i, paragraph] of current.paragraphs.entries()) {
if (i > 0) {
task.append(" ");
}
const span = document.createElement("span");
fillParagraph(span, paragraph);
task.append(span);
}
}
const after = nextPlace(loaded, at);
next.textContent = HOME_TEXTS.next;
next.href = routeHash(placeRoute(after));
next.classList.toggle("secondary", at.practice);
}
const currentScreen = screen;
if (currentScreen === null) {
return;
}
// Le plateau : la position, puis le coup illustré ou le geste à
// faire, tous lus par le moteur.
try {
await options.engineReady;
const shownBoard = await access.board(currentScreen.position, currentScreen.onRoll);
if (id !== shown) {
return;
}
boards = shownBoard;
if (
at !== "end" &&
at.practice &&
currentScreen.roll !== null &&
currentScreen.expected.length > 0
) {
practice = await access.practice(
currentScreen.position,
currentScreen.roll,
currentScreen.expected,
);
if (id !== shown) {
return;
}
await refreshTargets();
} else if (at !== "end" && at.practice && currentScreen.answer !== null) {
cube.hidden = false;
feedback.textContent = HOME_TEXTS.computerDoubles;
feedback.dataset.verdict = "info";
} else if (
at !== "end" &&
currentScreen.move !== null &&
currentScreen.roll !== null
) {
illustration = await access.illustrate(
currentScreen.position,
currentScreen.roll,
currentScreen.move,
);
if (id !== shown) {
return;
}
replay.hidden = false;
replay.disabled = true;
autoplay = setTimeout(() => void playIllustration(id), AUTOPLAY_MS);
}
if (id !== shown) {
return;
}
draw(practice === null ? null : await practice.board());
section.dataset.ready = String(id);
} catch (error) {
if (id === shown) {
feedback.textContent = `${HOME_TEXTS.tutorialFailed} ${error instanceof Error ? error.message : ""}`;
}
}
},
relayout() {
if (place === null || busy) {
return;
}
if (practice !== null) {
void practice.board().then((sides) => draw(sides));
} else {
draw();
}
},
hide() {
shown++;
clearTimer();
setBusy(false);
},
};
}
+150
View File
@@ -0,0 +1,150 @@
// La mise à jour de l'application : le service worker (web/sw.js) installe
// chaque nouvelle version publiée dans son propre cache, puis attend
// l'accord du joueur. L'application affiche alors, entre deux tours, le
// bandeau « Nouvelle version prête. » en bas de l'écran ; « Mettre à jour »
// active la nouvelle version et recharge la page, qui reprend la partie
// enregistrée.
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
export interface Updates {
/** Une nouvelle version est installée et attend l'accord du joueur. */
readonly available: boolean;
/** « Rechercher une mise à jour » : demande au navigateur de relire le
* service worker publié. found : une nouvelle version attend (elle a pu
* demander de télécharger tout le site) ; latest : rien de nouveau ;
* unavailable : pas de service worker (http hors localhost, navigateur
* qui le refuse) ou réseau absent. */
check(): Promise<"found" | "latest" | "unavailable">;
/** Active la nouvelle version : la page se recharge dès qu'elle a pris
* la main. */
apply(): void;
}
/** Message au service worker en attente : prendre la main (sw.js). */
export const SKIP_WAITING = "skip-waiting";
/**
* Est-on entre deux tours ? Oui quand la partie attend un lancer de dés
* (aucun pion déplacé) ou qu'elle est finie ; non dès que des dés sont à
* jouer, qu'un double attend sa réponse, ou au jet d'ouverture. Fonction
* pure, testée sous Node.
*/
export function betweenTurns(phase: string, pendingHalfMoves: number): boolean {
return phase === "over" || (phase === "rolling" && pendingHalfMoves === 0);
}
/**
* Le bandeau « Nouvelle version prête. » est-il montré ? Quand une version
* attend, seulement entre deux tours (jamais pendant un coup en cours, ni
* quand l'ordinateur joue), et jamais par-dessus le bandeau « Reprendre
* l'ancienne », qui occupe la même place. Fonction pure, testée sous Node.
*/
export function updateBannerShown(
available: boolean,
betweenTwoTurns: boolean,
otherBanner: boolean,
): boolean {
return available && betweenTwoTurns && !otherBanner;
}
/**
* Enregistre le service worker une fois ready résolue (le moteur chargé :
* voir main.ts), et suit ses mises à jour. onAvailable est appelé quand une
* nouvelle version attend.
*/
export function startUpdates(
script: URL,
ready: Promise<unknown>,
onAvailable: () => void,
): Updates {
const container = "serviceWorker" in navigator ? navigator.serviceWorker : null;
let registration: ServiceWorkerRegistration | null = null;
let waiting: ServiceWorker | null = null;
/** Une version installée attend, et une version plus ancienne a la
* main : c'est une mise à jour (à la toute première installation,
* aucune page n'est encore contrôlée, et rien n'attend). */
const notice = (worker: ServiceWorker | null) => {
if (worker !== null && worker.state === "installed" && container?.controller) {
waiting = worker;
onAvailable();
}
};
/** Suit une version en cours d'installation ; résolue quand elle est
* installée (vrai) ou abandonnée (faux). */
const follow = (worker: ServiceWorker): Promise<boolean> =>
new Promise((resolve) => {
const settle = () => {
if (worker.state === "installed" || worker.state === "activated") {
notice(worker);
resolve(true);
} else if (worker.state === "redundant") {
resolve(false);
}
};
worker.addEventListener("statechange", settle);
settle();
});
if (container !== null) {
ready
.then(() => container.register(script))
.then((reg) => {
registration = reg;
notice(reg.waiting);
if (reg.installing !== null) {
void follow(reg.installing);
}
reg.addEventListener("updatefound", () => {
if (reg.installing !== null) {
void follow(reg.installing);
}
});
})
.catch(() => {
// Refusé (http hors localhost) : l'application marche sans.
});
}
return {
get available() {
return waiting !== null;
},
async check() {
const reg = registration;
if (reg === null) {
return "unavailable";
}
try {
await reg.update();
} catch {
return "unavailable";
}
if (reg.waiting !== null) {
notice(reg.waiting);
return "found";
}
if (reg.installing !== null) {
return (await follow(reg.installing)) ? "found" : "latest";
}
return "latest";
},
apply() {
const worker = waiting ?? registration?.waiting ?? null;
if (container === null || worker === null) {
return;
}
let reloading = false;
container.addEventListener("controllerchange", () => {
if (!reloading) {
reloading = true;
location.reload();
}
});
worker.postMessage({ type: SKIP_WAITING });
},
};
}
+141
View File
@@ -0,0 +1,141 @@
// Le conseil du maître pendant la partie (app/src/ui/advice-tracker.ts) :
// les analyses ne retardent pas le tour de l'ordinateur, la ligne
// n'apparaît qu'après la décision et pas si le joueur a déjà agi, et
// chaque conseil est rattaché au journal.
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
import assert from "node:assert/strict";
import { test } from "node:test";
import { analyseDecision } from "../src/domain/analysis/analyse.js";
import type { Advice } from "../src/domain/game/advice.js";
import type { GameJournal } from "../src/domain/game/events.js";
import { Game } from "../src/domain/game/game.js";
import { parseHalfMoves, START_POSITION } from "../src/domain/game/position.js";
import { fixedDice } from "../src/domain/game/roll.js";
import { createAdviceTracker } from "../src/ui/advice-tracker.js";
import { DOUBLE_TAKE_POSITION } from "./support/advice.js";
import { loadNodeEngine } from "./support/node-engine.js";
/** Une analyse qu'on libère à la main. */
function heldAnalyses() {
const held: { id: string; release: () => Promise<void> }[] = [];
const analyse = (journal: GameJournal, eventId: string) =>
new Promise<Advice | null>((resolve) => {
held.push({
id: eventId,
release: async () => {
resolve(await analyseDecision(engine, journal, eventId));
await new Promise((r) => setTimeout(r, 0));
},
});
});
return { held, analyse };
}
let engine: Awaited<ReturnType<typeof loadNodeEngine>>;
async function opening(dice: number[]): Promise<Game> {
engine = await loadNodeEngine();
return Game.start(engine, {
setup: { position: START_POSITION, firstTurn: "player" },
dice: fixedDice(dice),
});
}
async function play(game: Game, move: string): Promise<void> {
for (const halfMove of parseHalfMoves(move)) {
assert.ok(await game.playHalfMove(halfMove));
}
await game.confirmMove();
}
test("le tour de l'ordinateur n'attend pas l'analyse ; la ligne vient ensuite", async () => {
const game = await opening([3, 1, 6, 5, 4, 2]);
const { held, analyse } = heldAnalyses();
let saved = 0;
let changes = 0;
const tracker = createAdviceTracker({
analyse,
recorded: () => saved++,
changed: () => changes++,
});
let before = game.events;
await game.roll();
tracker.track(game, before);
// Un jet seul ne montre rien : le joueur réfléchit.
assert.equal(held.length, 1, "le jet est analysé (aurait-il fallu doubler ?)");
before = game.events;
await play(game, "24/23 13/10");
tracker.track(game, before);
// Les analyses sont en attente : l'ordinateur joue quand même.
await game.playComputerTurn();
assert.equal(game.state.turn, "player");
assert.equal(tracker.shown, null);
for (const h of held) {
await h.release();
}
const shown = tracker.shown as Advice | null;
assert.equal(shown?.kind, "move");
assert.equal(shown?.skill, "veryBad");
assert.equal(changes, 1);
// Le coup est rattaché au journal (le jet aussi s'il y avait à dire).
assert.ok(saved >= 1);
assert.ok(game.advice[shown?.eventId ?? ""] !== undefined);
// L'action suivante du joueur efface la ligne.
tracker.clear();
assert.equal(tracker.shown, null);
});
test("si le joueur a déjà agi, la ligne n'apparaît pas, mais le conseil reste au journal", async () => {
const game = await opening([3, 1, 6, 5]);
const { held, analyse } = heldAnalyses();
const tracker = createAdviceTracker({ analyse, recorded: () => {}, changed: () => {} });
await game.roll();
const before = game.events;
await play(game, "8/5 6/5");
tracker.track(game, before);
tracker.clear(); // le joueur a relancé avant l'analyse
for (const h of held) {
await h.release();
}
assert.equal(tracker.shown, null);
const move = game.events.findLast((e) => e.type === "MovePlayed");
assert.equal(game.advice[move?.id ?? ""]?.skill, "none");
});
test("un double manqué avant le jet passe avant le coup dans la ligne", async () => {
engine = await loadNodeEngine();
const game = Game.start(engine, {
setup: { position: DOUBLE_TAKE_POSITION, firstTurn: "player" },
dice: fixedDice([6, 5]),
});
const tracker = createAdviceTracker({
analyse: (journal, id) => analyseDecision(engine, journal, id),
recorded: () => {},
changed: () => {},
});
let before = game.events;
await game.roll();
tracker.track(game, before);
const best = await engine.bestMove(
game.state.position,
{ value: 1, owner: "centered" },
[6, 5],
0,
);
assert.ok(best !== null);
before = game.events;
for (const halfMove of best.halfMoves) {
assert.ok(await game.playHalfMove(halfMove));
}
await game.confirmMove();
tracker.track(game, before);
for (let i = 0; i < 50 && tracker.shown === null; i++) {
await new Promise((r) => setTimeout(r, 20));
}
const shown = tracker.shown as Advice | null;
assert.equal(shown?.kind, "cube");
assert.equal(shown?.kind === "cube" ? shown.action : null, "noDouble");
});
+273
View File
@@ -0,0 +1,273 @@
// Le conseil du maître (étape 1d) : analyses du moteur sur des décisions
// connues, phrases rendues sur ces analyses, conseil rattaché au journal et
// gardé à l'enregistrement.
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
import assert from "node:assert/strict";
import { test } from "node:test";
import {
LINE_LIMIT,
masterLine,
masterWords,
PHRASE_KEYS,
} from "../src/domain/analysis/phrases.js";
import { ADVICE_VERSION, type Advice, type MoveAdvice } from "../src/domain/game/advice.js";
import { Game } from "../src/domain/game/game.js";
import { createGameLibrary } from "../src/storage/game-library.js";
import { createKeyValueBackend } from "../src/storage/key-value-backend.js";
import { memoryStorage } from "../src/storage/open-library.js";
import {
adviceOn,
loadPhrases,
playerAnswers,
playerDoubles,
playerRollsWithoutDoubling,
playOpening,
} from "./support/advice.js";
import { loadNodeEngine } from "./support/node-engine.js";
const phrases = loadPhrases();
test("la source des phrases a toutes ses sections, sans point d'exclamation", () => {
for (const key of PHRASE_KEYS) {
assert.ok((phrases.get(key)?.length ?? 0) > 0, `section ${key} absente ou vide`);
}
for (const [key, templates] of phrases) {
assert.ok(PHRASE_KEYS.includes(key), `section ${key} inconnue`);
for (const template of templates) {
assert.ok(!template.includes("!"), `« ! » dans ${key} : ${template}`);
}
}
});
// Des décisions réelles, analysées par le moteur à 2 plis. Les coups
// d'ouverture et leurs écarts viennent du classement de gnubg.
const cases: { name: string; play: () => Promise<Advice>; word: string }[] = [
{
name: "3-1 joué 8/5 6/5 : le meilleur coup",
play: async () =>
adviceOn(
await loadNodeEngine(),
await playOpening(await loadNodeEngine(), [3, 1], "8/5 6/5"),
),
word: "Bon coup",
},
{
name: "2-1 joué 13/11 6/5 : tout près du meilleur",
play: async () =>
adviceOn(
await loadNodeEngine(),
await playOpening(await loadNodeEngine(), [2, 1], "13/11 6/5"),
),
word: "Bon coup",
},
{
name: "2-1 joué 13/10 : imprécision",
play: async () =>
adviceOn(
await loadNodeEngine(),
await playOpening(await loadNodeEngine(), [2, 1], "13/11 11/10"),
),
word: "Imprécision",
},
{
name: "2-1 joué 24/22 6/5 : erreur",
play: async () =>
adviceOn(
await loadNodeEngine(),
await playOpening(await loadNodeEngine(), [2, 1], "24/22 6/5"),
),
word: "Erreur",
},
{
name: "3-1 joué 24/23 13/10 : grosse erreur",
play: async () =>
adviceOn(
await loadNodeEngine(),
await playOpening(await loadNodeEngine(), [3, 1], "24/23 13/10"),
),
word: "Grosse erreur",
},
{
name: "double dans une position de double et prise",
play: async () =>
adviceOn(await loadNodeEngine(), await playerDoubles(await loadNodeEngine())),
word: "Bonne décision",
},
{
name: "lancer sans doubler dans une position de double",
play: async () =>
adviceOn(
await loadNodeEngine(),
await playerRollsWithoutDoubling(await loadNodeEngine()),
),
word: "Erreur",
},
{
name: "prendre un double juste",
play: async () =>
adviceOn(await loadNodeEngine(), await playerAnswers(await loadNodeEngine(), true)),
word: "Bonne décision",
},
{
name: "passer un double qu'il fallait prendre",
play: async () =>
adviceOn(await loadNodeEngine(), await playerAnswers(await loadNodeEngine(), false)),
word: "Erreur",
},
];
const analysed: Advice[] = [];
for (const c of cases) {
test(`conseil : ${c.name}`, async () => {
const advice = await c.play();
analysed.push(advice);
const words = masterWords(phrases, advice);
assert.equal(words.word, c.word, JSON.stringify(advice));
// Chaque gabarit tourne avec l'identifiant de l'événement : la ligne
// est rendue pour plusieurs identifiants.
for (let id = 1; id <= 40; id++) {
const line = masterLine(masterWords(phrases, { ...advice, eventId: String(id) }));
assert.ok(line.length < LINE_LIMIT, `trop long (${line.length}) : ${line}`);
assert.ok(!line.includes("!"), line);
assert.ok(!/[{}]/.test(line), `repère non rempli : ${line}`);
}
});
}
test("le coup du maître est nommé, et le fait qui distingue les coups vient du plateau", async () => {
const engine = await loadNodeEngine();
const best = (await adviceOn(
engine,
await playOpening(engine, [3, 1], "8/5 6/5"),
)) as MoveAdvice;
assert.equal(best.kind, "move");
assert.equal(best.version, ADVICE_VERSION);
assert.equal(best.rank, 1);
assert.equal(best.opening, true);
assert.deepEqual(best.playedFacts, { hits: 0, madePoints: [5] });
assert.equal(best.ranking[0]?.notation, "8/5 6/5");
const words = masterWords(phrases, best);
assert.equal(words.title, "C'est aussi le coup que j'aurais joué");
assert.equal(words.context, "votre 3-1 d'ouverture");
const worst = (await adviceOn(
engine,
await playOpening(engine, [3, 1], "24/23 13/10"),
)) as MoveAdvice;
assert.equal(worst.skill, "veryBad");
assert.equal(worst.best.notation, "8/5 6/5");
assert.deepEqual(worst.bestFacts.madePoints, [5]);
const said = masterWords(phrases, worst);
assert.equal(said.title, "J'aurais joué 8/5 6/5");
assert.equal(said.phrase, "J'aurais joué 8/5 6/5. Mon coup fait votre point 5.");
});
test("le rang n'est cité que pour un bon coup non meilleur ou une imprécision, au 2e ou 3e choix", async () => {
const engine = await loadNodeEngine();
const worst = (await adviceOn(
engine,
await playOpening(engine, [3, 1], "24/23 13/10"),
)) as MoveAdvice;
for (const skill of ["none", "doubtful", "bad", "veryBad"] as const) {
for (const rank of [2, 3, 4, 7, 15]) {
const { phrase } = masterWords(phrases, { ...worst, skill, rank });
const cited = (skill === "none" || skill === "doubtful") && rank <= 3;
if (cited) {
assert.ok(phrase.includes(`le ${rank}e choix`), `${skill} ${rank} : ${phrase}`);
} else {
assert.ok(!phrase.includes("choix"), `${skill} ${rank} : ${phrase}`);
assert.equal(phrase, "J'aurais joué 8/5 6/5. Mon coup fait votre point 5.");
}
}
}
});
test("décisions de videau : la décision du maître et le contexte", async () => {
const engine = await loadNodeEngine();
const missed = await adviceOn(engine, await playerRollsWithoutDoubling(engine));
const words = masterWords(phrases, missed);
assert.equal(words.phrase, "Ici, j'aurais doublé.");
assert.equal(words.title, "J'aurais doublé");
assert.equal(words.context, "avant votre jet");
const passed = masterWords(phrases, await adviceOn(engine, await playerAnswers(engine, false)));
assert.equal(passed.phrase, "Ici, j'aurais pris.");
assert.equal(passed.context, "le double de l'ordinateur");
});
test("tous les gabarits, remplis avec les plus longues notations, restent sous la limite", () => {
const long = "bar/22* 13/9*(2) 6/off";
const move = (equity: number) => ({ notation: long, halfMoves: [], equity, ply: 2 });
for (const skill of ["none", "doubtful", "bad", "veryBad"] as const) {
for (const rank of [1, 2, 23]) {
for (const facts of [
{ hits: 0, madePoints: [] },
{ hits: 1, madePoints: [4, 5, 7] },
{ hits: 3, madePoints: [] },
]) {
const advice: MoveAdvice = {
version: ADVICE_VERSION,
kind: "move",
eventId: "7",
ply: 2,
position: "4HPwATDgc/ABMA",
roll: [6, 6],
opening: false,
moveCount: 87,
rank,
played: move(-0.2),
best: move(0),
playedFacts: rank === 1 ? facts : { hits: 0, madePoints: [] },
bestFacts: facts,
ranking: [],
equityLoss: rank === 1 ? 0 : -0.2,
skill: rank === 1 ? "none" : skill,
};
for (let id = 0; id < 20; id++) {
const line = masterLine(
masterWords(phrases, { ...advice, eventId: String(id) }),
);
assert.ok(line.length < LINE_LIMIT, `trop long (${line.length}) : ${line}`);
}
}
}
}
});
test("le conseil est rattaché au journal et gardé à l'enregistrement", async () => {
const engine = await loadNodeEngine();
const played = await playOpening(engine, [3, 1], "8/5 6/5");
const advice = await adviceOn(engine, played);
played.game.recordAdvice(advice);
// Le tour de l'ordinateur, puis l'enregistrement : le conseil reste.
await played.game.playComputerTurn();
assert.deepEqual(played.game.advice[played.eventId], advice);
const library = createGameLibrary(createKeyValueBackend(memoryStorage()));
await library.save("g", played.game.journal, { level: "2ply" });
const loaded = await library.load("g");
assert.ok(loaded !== null);
const resumed = Game.resume(engine, loaded.journal);
assert.deepEqual(resumed.advice[played.eventId], advice);
// Revenir en arrière garde le conseil, rattaché à son événement.
resumed.goBackTo(played.eventId);
assert.deepEqual(resumed.advice[played.eventId], advice);
});
test("migration : un journal sans conseil, ou avec un conseil d'une autre version", async () => {
const engine = await loadNodeEngine();
const played = await playOpening(engine, [3, 1], "8/5 6/5");
const { advice: _, ...withoutAdvice } = played.game.journal;
assert.deepEqual(Game.resume(engine, withoutAdvice).advice, {});
const advice = await adviceOn(engine, played);
const future = { ...advice, version: ADVICE_VERSION + 1 };
const orphan = { ...advice, eventId: "999" };
const resumed = Game.resume(engine, {
...played.game.journal,
advice: { [played.eventId]: future, "999": orphan },
});
assert.deepEqual(resumed.advice, {});
assert.throws(() => resumed.recordAdvice(orphan), /introuvable/);
});
+5 -5
View File
@@ -52,13 +52,13 @@ async function playToTheEnd(game: Game, engine: Awaited<ReturnType<typeof loadNo
return actions;
}
for (const [seed, ply] of [
[1, 0],
[7, 2],
for (const [seed, level] of [
[1, "0ply"],
[7, "2ply"],
] as const) {
test(`partie complète, dés ${seed}, ordinateur à ${ply} pli(s)`, async () => {
test(`partie complète, dés ${seed}, ordinateur au niveau ${level}`, async () => {
const engine = await loadNodeEngine();
const game = Game.start(engine, { dice: seededDice(seed), ply });
const game = Game.start(engine, { dice: seededDice(seed), level });
await playToTheEnd(game, engine);
const events = game.events;
+210 -16
View File
@@ -10,14 +10,24 @@ import type {
Board,
CubeDecision,
Engine,
EngineCube,
HalfMovesCheck,
Level,
Move,
} from "../src/domain/game/engine.js";
import { appendEvent, currentLine, EMPTY_JOURNAL, goBackTo } from "../src/domain/game/events.js";
import {
appendEvent,
currentLine,
EMPTY_JOURNAL,
EVENT_VERSION,
type GameJournal,
goBackTo,
migrateJournal,
} from "../src/domain/game/events.js";
import { Game } from "../src/domain/game/game.js";
import { formatHalfMoves, parseHalfMoves, START_POSITION } from "../src/domain/game/position.js";
import { fixedDice } from "../src/domain/game/roll.js";
import { replay } from "../src/domain/game/state.js";
import { DEFAULT_LEVEL, replay } from "../src/domain/game/state.js";
/** Un faux moteur : chaque réponse est réglable, et les positions sont des
* textes sans signification. */
@@ -51,7 +61,19 @@ function fakeEngine(overrides: Partial<Engine> = {}): Engine {
board: async () => notOver,
levelMove: async () => move,
levelCubeDecision: async () => noDouble,
levelDouble: async () => false,
levelTake: async () => true,
matchAfterGame: async (match, winner, points) => {
const score = { ...match.score };
score[winner] += points;
return {
score,
over: score[winner] >= match.length,
crawford: score[winner] === match.length - 1,
};
},
analyseMove: async () => null,
rankMoves: async () => [],
analyseCube: async () => ({
analysed: false,
equities: null,
@@ -237,10 +259,8 @@ test("le joueur ne peut pas laisser passer son tour s'il a un coup", async () =>
assert.equal(game.state.phase, "moving");
});
test("l'ordinateur double si le moteur le recommande ; le joueur prend", async () => {
const engine = fakeEngine({
cubeDecision: async () => ({ available: true, double: true, take: true }),
});
test("l'ordinateur double si le moteur le dit à son niveau ; le joueur prend", async () => {
const engine = fakeEngine({ levelDouble: async () => true });
const game = Game.start(engine, { setup: { position: "X", firstTurn: "computer" } });
await game.playComputerTurn();
assert.equal(game.state.phase, "doubled");
@@ -251,22 +271,18 @@ test("l'ordinateur double si le moteur le recommande ; le joueur prend", async (
});
test("le joueur passe : il perd la partie à la valeur d'avant le double", async () => {
const engine = fakeEngine({
cubeDecision: async () => ({ available: true, double: true, take: true }),
});
const engine = fakeEngine({ levelDouble: async () => true });
const game = Game.start(engine, {
setup: { position: "X", firstTurn: "computer", cube: { value: 2, owner: "computer" } },
});
await game.playComputerTurn();
game.drop();
await game.drop();
assert.equal(game.state.phase, "over");
assert.deepEqual(game.state.result, { winner: "computer", kind: "single", points: 2 });
});
test("le joueur double : l'ordinateur passe si le moteur le dit", async () => {
const engine = fakeEngine({
cubeDecision: async () => ({ available: true, double: true, take: false }),
});
test("le joueur double : l'ordinateur passe si le moteur le dit à son niveau", async () => {
const engine = fakeEngine({ levelTake: async () => false });
const game = Game.start(engine, { setup: { position: "X", firstTurn: "player" } });
await game.double();
assert.deepEqual(
@@ -283,7 +299,7 @@ test("le joueur ne peut pas doubler si le videau est chez l'ordinateur", async (
const game = Game.start(engine, {
setup: { position: "X", firstTurn: "player", cube: { value: 2, owner: "computer" } },
});
await assert.rejects(game.double(), /videau/);
await assert.rejects(game.double(), /pas doubler/);
assert.equal(game.events.length, 1);
});
@@ -339,7 +355,7 @@ test("les événements sont immuables et versionnés ; le rejeu est déterminist
});
journal = appendEvent(journal, { type: "DiceRolled", side: null, roll: [5, 2] });
const [started, rolled] = currentLine(journal);
assert.equal(started?.version, 1);
assert.equal(started?.version, 2);
assert.equal(started?.parent, null);
assert.equal(rolled?.parent, started?.id);
assert.throws(() => {
@@ -413,3 +429,181 @@ test("le plateau affiché est donné du point de vue de chaque camp", async () =
assert.equal(swapped.player.pipCount, 2);
assert.equal(swapped.computer.pipCount, 1);
});
// ------------------------------------------------------------- Match (1c)
test("une partie de match garde son niveau et son match, et les donne au moteur vus du camp au trait", async () => {
const cubes: EngineCube[] = [];
const levels: Level[] = [];
const engine = fakeEngine({
levelDouble: async (_position, cube, level) => {
cubes.push(cube);
levels.push(level);
return false;
},
});
const match = { length: 5, score: { player: 1, computer: 3 }, crawford: false };
const game = Game.start(engine, {
dice: fixedDice([2, 5, 3, 1, 4, 2]),
level: "intermediate",
match,
});
const start = game.events[0];
assert.ok(start?.type === "GameStarted");
assert.equal(start.level, "intermediate");
assert.deepEqual(start.match, match);
assert.equal(game.state.level, "intermediate");
// L'ordinateur commence (2-5) et joue, puis le joueur ; ensuite
// l'ordinateur pense à doubler, et le match lui est donné vu de lui.
await game.playComputerTurn();
await game.roll();
await game.playHalfMove({ from: 8, to: 5 });
await game.playHalfMove({ from: 6, to: 5 });
await game.confirmMove();
await game.playComputerTurn();
assert.deepEqual(levels, ["intermediate"]);
assert.deepEqual(cubes[0]?.match, {
length: 5,
score: { onRoll: 3, opponent: 1 },
crawford: false,
});
});
test("une partie de match finie : le moteur dit le score, la fin du match et la partie Crawford suivante", async () => {
const winning: Board = {
onRoll: { points: [], bar: 0, off: 15, pipCount: 0 },
opponent: { points: [], bar: 0, off: 0, pipCount: 100 },
swappedPosition: "SWAPPED",
result: { winner: "onRoll", kind: "gammon", points: 2 },
};
const engine = fakeEngine({ board: async () => winning });
const game = Game.start(engine, {
dice: fixedDice([5, 2]),
level: "advanced",
match: { length: 3, score: { player: 0, computer: 0 }, crawford: false },
});
assert.equal(game.nextGame, null);
await game.playHalfMove({ from: 8, to: 5 });
await game.playHalfMove({ from: 6, to: 5 });
await game.confirmMove();
const end = game.events.at(-1);
assert.ok(end?.type === "GameEnded");
assert.deepEqual(end.match, { score: { player: 2, computer: 0 }, over: false, crawford: true });
const next = game.nextGame;
assert.deepEqual(next, {
level: "advanced",
match: { length: 3, score: { player: 2, computer: 0 }, crawford: true },
});
// La partie Crawford, gagnée : le match est fini, plus de partie suivante.
const crawford = Game.start(engine, { dice: fixedDice([5, 2]), ...(game.nextGame ?? {}) });
await crawford.playHalfMove({ from: 8, to: 5 });
await crawford.playHalfMove({ from: 6, to: 5 });
await crawford.confirmMove();
assert.deepEqual(crawford.state.matchAfter, {
score: { player: 4, computer: 0 },
over: true,
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 () => {
const engine = fakeEngine({ levelDouble: async () => true });
const game = Game.start(engine, {
dice: fixedDice([2, 5, 3, 1]),
match: { length: 3, score: { player: 0, computer: 1 }, crawford: false },
});
// L'ordinateur joue son jet d'ouverture, le joueur son coup, puis
// l'ordinateur double.
await game.playComputerTurn();
await game.roll();
await game.playHalfMove({ from: 8, to: 5 });
await game.playHalfMove({ from: 6, to: 5 });
await game.confirmMove();
await game.playComputerTurn();
assert.equal(game.state.phase, "doubled");
await game.drop();
assert.deepEqual(game.state.matchAfter, {
score: { player: 0, computer: 2 },
over: false,
crawford: true,
});
});
test("une partie libre n'a ni match ni partie suivante", () => {
const game = Game.start(fakeEngine(), { dice: fixedDice([3, 1]) });
assert.equal(game.state.match, null);
assert.equal(game.state.level, DEFAULT_LEVEL);
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.
const v1 = {
events: [
{
id: "1",
parent: null,
version: 1,
type: "GameStarted",
position: START_POSITION,
cube: { value: 1, owner: null },
firstTurn: null,
},
{ id: "2", parent: "1", version: 1, type: "DiceRolled", side: null, roll: [3, 1] },
{
id: "3",
parent: "2",
version: 1,
type: "MovePlayed",
side: "player",
halfMoves: [
{ from: 8, to: 5 },
{ from: 6, to: 5 },
],
position: "SWAPPED",
},
{ id: "4", parent: "1", version: 1, type: "DiceRolled", side: null, roll: [5, 2] },
],
head: "3",
} as unknown as GameJournal;
const before = replay(currentLine(v1));
const migrated = migrateJournal(v1);
assert.ok(migrated.events.every((event) => event.version === EVENT_VERSION));
assert.equal(migrated.head, "3");
const start = migrated.events[0];
assert.ok(start?.type === "GameStarted");
assert.equal(start.level, "2ply");
assert.equal(start.match, undefined);
// Rien d'autre ne change : mêmes événements, même arbre, même état.
const strip = (journal: GameJournal) =>
journal.events.map(({ version: _, ...event }) => {
const { level: _level, ...rest } = event as typeof event & { level?: unknown };
return rest;
});
assert.deepEqual(strip(migrated), strip(v1));
assert.deepEqual(replay(currentLine(migrated)), { ...before, level: "2ply" });
// Une version courante passe telle quelle.
assert.equal(migrateJournal(migrated), migrated);
const resumed = Game.resume(fakeEngine(), JSON.parse(JSON.stringify(v1)));
assert.equal(resumed.state.level, "2ply");
assert.equal(resumed.state.match, null);
assert.ok(resumed.journal.events.every((event) => event.version === EVENT_VERSION));
// La partie continue en version 2.
await resumed.playComputerTurn();
assert.ok(resumed.journal.events.every((event) => event.version === EVENT_VERSION));
});
+133
View File
@@ -0,0 +1,133 @@
// Tests de l'accueil et de l'invitation à installer, sans navigateur :
// premier lancement, règles d'apparition de la carte d'installation,
// réglages enregistrés par une version précédente.
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { test } from "node:test";
import { installPlatform } from "../src/ui/install.js";
import {
DEFAULT_SETTINGS,
INSTALL_AGAIN_AFTER,
installCardShown,
isFirstLaunch,
parseSettings,
type Settings,
TUTORIAL_DONE,
} from "../src/ui/settings.js";
import { nextPlace, placeOf, previousPlace, progress, seenAfter } from "../src/ui/tutorial-nav.js";
import { parseTutorial } from "../src/ui/tutorial-text.js";
const root = new URL("../../../", import.meta.url);
test("réglages d'une version précédente : les nouveaux champs prennent leur valeur par défaut", () => {
const old = parseSettings('{"vibrations": false, "theme": "dark", "gamesStarted": 4}');
assert.equal(old.welcomed, false);
assert.equal(old.gamesFinished, 0);
assert.equal(old.tutorialSeen, 0);
assert.equal(old.installed, false);
const saved = parseSettings(
'{"welcomed": true, "gamesFinished": 2, "tutorialSeen": 5, "installDismissals": 1, ' +
'"installDismissedAt": 2, "installed": true}',
);
assert.deepEqual(saved, {
...DEFAULT_SETTINGS,
welcomed: true,
gamesFinished: 2,
tutorialSeen: 5,
installDismissals: 1,
installDismissedAt: 2,
installed: true,
});
assert.equal(parseSettings('{"tutorialSeen": -3, "welcomed": "oui"}').tutorialSeen, 0);
});
test("premier lancement : tant que rien n'a été fait, une seule fois", () => {
assert.ok(isFirstLaunch(DEFAULT_SETTINGS));
// La partie commencée d'office au chargement ne compte pas.
assert.ok(isFirstLaunch({ ...DEFAULT_SETTINGS, gamesStarted: 1 }));
assert.ok(!isFirstLaunch({ ...DEFAULT_SETTINGS, welcomed: true }));
assert.ok(!isFirstLaunch({ ...DEFAULT_SETTINGS, gamesStarted: 2 }));
assert.ok(!isFirstLaunch({ ...DEFAULT_SETTINGS, gamesFinished: 1 }));
assert.ok(!isFirstLaunch({ ...DEFAULT_SETTINGS, tutorialSeen: 1 }));
});
test("invitation à installer : après la première partie terminée, une seule relance", () => {
const at = (changes: Partial<Settings>, installed = false) =>
installCardShown({ ...DEFAULT_SETTINGS, ...changes }, installed);
assert.ok(!at({}), "jamais avant la première partie terminée");
assert.ok(at({ gamesFinished: 1 }));
assert.ok(!at({ gamesFinished: 1 }, true), "jamais une fois installée");
assert.ok(!at({ gamesFinished: 1, installed: true }));
// « Plus tard » : masquée, puis de retour après trois parties de plus.
const later = { installDismissals: 1, installDismissedAt: 1 };
assert.equal(INSTALL_AGAIN_AFTER, 3);
assert.ok(!at({ ...later, gamesFinished: 1 }));
assert.ok(!at({ ...later, gamesFinished: 3 }));
assert.ok(at({ ...later, gamesFinished: 4 }));
// Une seconde fois « Plus tard » : plus jamais sur l'accueil.
assert.ok(!at({ installDismissals: 2, installDismissedAt: 4, gamesFinished: 40 }));
});
test("tutoriel : écran suivant, précédent, adresse ramenée à un écran qui existe", () => {
const tutorial = parseTutorial(readFileSync(new URL("docs/tutoriel/regles.md", root), "utf8"));
const explain = (step: number) => ({ step, practice: false });
const practice = (step: number) => ({ step, practice: true });
assert.deepEqual(nextPlace(tutorial, explain(2)), explain(3));
assert.deepEqual(nextPlace(tutorial, explain(3)), practice(3));
assert.deepEqual(nextPlace(tutorial, practice(3)), explain(4));
assert.equal(nextPlace(tutorial, practice(8)), "end");
assert.equal(previousPlace(tutorial, explain(1)), null);
assert.deepEqual(previousPlace(tutorial, explain(4)), practice(3));
assert.deepEqual(previousPlace(tutorial, explain(3)), explain(2));
assert.deepEqual(previousPlace(tutorial, "end"), practice(8));
assert.deepEqual(
placeOf(tutorial, { screen: "tutorial", step: null, practice: false }),
explain(1),
);
assert.deepEqual(
placeOf(tutorial, { screen: "tutorial", step: 2, practice: true }),
explain(2),
);
assert.deepEqual(
placeOf(tutorial, { screen: "tutorial", step: 42, practice: false }),
explain(1),
);
assert.equal(placeOf(tutorial, { screen: "tutorialEnd" }), "end");
assert.equal(seenAfter(5, explain(3)), 5);
assert.equal(seenAfter(0, "end"), TUTORIAL_DONE);
// La barre : des liens vers les étapes vues seulement, pas l'actuelle.
const bar = progress(8, explain(3), 5);
assert.deepEqual(
bar.filter((s) => s.link).map((s) => s.step),
[1, 2, 4, 5],
);
assert.deepEqual(
bar.filter((s) => s.done).map((s) => s.step),
[1, 2, 3],
);
assert.deepEqual(
bar.filter((s) => s.current).map((s) => s.step),
[3],
);
});
test("la marche à suivre pour installer dépend du navigateur", () => {
const iphone =
"Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 " +
"(KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1";
const ipad = "Mozilla/5.0 (iPad; CPU OS 16_0 like Mac OS X) AppleWebKit/605.1.15";
const firefox = "Mozilla/5.0 (Android 14; Mobile; rv:131.0) Gecko/131.0 Firefox/131.0";
const chrome =
"Mozilla/5.0 (Linux; Android 14) AppleWebKit/537.36 (KHTML, like Gecko) " +
"Chrome/130.0 Mobile Safari/537.36";
assert.equal(installPlatform(iphone), "ios");
assert.equal(installPlatform(ipad), "ios");
assert.equal(installPlatform(firefox), "firefox");
// Sans invite du système, Chrome suit la marche à suivre de Firefox
// (menu ⋮, « Installer »), la plus proche de la sienne.
assert.equal(installPlatform(chrome), "firefox");
});
+5 -1
View File
@@ -218,14 +218,18 @@ function sources(dir: string): string[] {
}
test("aucune boîte native du navigateur (confirm, alert, prompt) dans l'application", () => {
// Une méthode d'un autre objet ne compte pas : event.prompt() est l'invite
// d'installation de Chrome (install.ts), pas une boîte de dialogue.
for (const path of sources("app/src/")) {
const text = read(path);
assert.doesNotMatch(
text,
/\b(window\.)?(confirm|alert|prompt)\s*\(/,
/(^|[^.\w])(window\.)?(confirm|alert|prompt)\s*\(/m,
`${path} appelle une boîte native`,
);
}
assert.match("window.alert(1)", /(^|[^.\w])(window\.)?(confirm|alert|prompt)\s*\(/m);
assert.match("x = confirm('?')", /(^|[^.\w])(window\.)?(confirm|alert|prompt)\s*\(/m);
});
test("les deux blocs du thème sombre de app.css sont identiques", () => {
+128
View File
@@ -0,0 +1,128 @@
// Tests du choix de la partie et du match, sans navigateur : réglages de
// l'écran « Nouvelle partie », correspondance des niveaux avec ceux de
// GNU Backgammon, résumé d'une partie de match enregistrée, adaptateur du
// moteur (Match ID d'un match) sur le vrai moteur.
//
// 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 { Game } from "../src/domain/game/game.js";
import { COMPUTER_LEVELS, computerLevelOf, ENGINE_LEVELS } from "../src/domain/game/level.js";
import { firstGameOf, MATCH_LENGTHS, nextGameOf } from "../src/domain/game/match.js";
import { START_POSITION } from "../src/domain/game/position.js";
import { fixedDice } from "../src/domain/game/roll.js";
import { levelOf, summarizeGame } from "../src/domain/game/saved-game.js";
import { chosenGame, DEFAULT_SETTINGS, parseSettings } from "../src/ui/settings.js";
import { loadNodeEngine } from "./support/node-engine.js";
test("les six niveaux montrés au joueur, du plus faible au plus fort, et leur niveau de gnubg", () => {
assert.deepEqual(COMPUTER_LEVELS, [
"beginner",
"amateur",
"intermediate",
"advanced",
"expert",
"champion",
]);
assert.deepEqual(
COMPUTER_LEVELS.map((level) => ENGINE_LEVELS[level]),
["beginner", "casual", "intermediate", "advanced", "0ply", "2ply"],
);
assert.equal(computerLevelOf("casual"), "amateur");
assert.equal(computerLevelOf("3ply"), null);
});
test("réglages de la nouvelle partie : partie libre, Intermédiaire, conseil activé par défaut ; valeurs illisibles ignorées", () => {
assert.equal(DEFAULT_SETTINGS.gameKind, "free");
assert.equal(DEFAULT_SETTINGS.level, "intermediate");
assert.equal(DEFAULT_SETTINGS.masterAdvice, true);
// Réglages d'avant l'étape 1c : les valeurs par défaut.
const old = parseSettings('{"vibrations": false, "gamesStarted": 4}');
assert.equal(old.gameKind, "free");
assert.equal(old.matchLength, 5);
assert.equal(old.level, "intermediate");
const saved = parseSettings(
'{"gameKind": "match", "matchLength": 7, "level": "champion", "masterAdvice": false}',
);
assert.deepEqual(
[saved.gameKind, saved.matchLength, saved.level, saved.masterAdvice],
["match", 7, "champion", false],
);
const wrong = parseSettings('{"gameKind": "x", "matchLength": 4, "level": "4ply"}');
assert.deepEqual([wrong.gameKind, wrong.matchLength, wrong.level], ["free", 5, "intermediate"]);
assert.deepEqual([...MATCH_LENGTHS], [3, 5, 7, 11]);
});
test("la partie choisie : le niveau de gnubg, et la première partie d'un match à 0-0", () => {
assert.deepEqual(chosenGame(DEFAULT_SETTINGS), { level: "intermediate" });
assert.deepEqual(chosenGame({ ...DEFAULT_SETTINGS, gameKind: "match", level: "expert" }), {
level: "0ply",
match: { length: 5, score: { player: 0, computer: 0 }, crawford: false },
});
assert.throws(() => firstGameOf(0), /longueur/);
const match = firstGameOf(3);
assert.equal(
nextGameOf(match, { score: { player: 3, computer: 0 }, over: true, crawford: false }),
null,
);
assert.deepEqual(
nextGameOf(match, { score: { player: 2, computer: 0 }, over: false, crawford: true }),
{ length: 3, score: { player: 2, computer: 0 }, crawford: true },
);
});
test("le résumé d'une partie de match enregistrée porte le match ; celui d'une partie libre n'en a pas", async () => {
const engine = await loadNodeEngine();
const times = { startedAt: 1, updatedAt: 2 };
const free = Game.start(engine, { dice: fixedDice([3, 1]), level: "beginner" });
assert.equal(levelOf(free.journal), "beginner");
const freeSummary = summarizeGame("a", free.journal, { level: "beginner" }, times);
assert.equal(freeSummary.match, undefined);
assert.equal(freeSummary.level, "beginner");
const match = Game.start(engine, {
dice: fixedDice([3, 1]),
level: "2ply",
match: { length: 5, score: { player: 4, computer: 2 }, crawford: true },
});
const summary = summarizeGame("b", match.journal, { level: levelOf(match.journal) }, times);
assert.deepEqual(summary.match, {
length: 5,
score: { player: 4, computer: 2 },
crawford: true,
over: false,
});
});
test("adaptateur : le Match ID d'un match code la longueur, le score vu du joueur au trait et Crawford", async () => {
const engine = await loadNodeEngine();
const id = await engine.encodeMatch({
value: 1,
owner: "centered",
match: { length: 5, score: { onRoll: 4, opponent: 2 }, crawford: true },
});
const decoded = await engine.decodeMatch(id);
assert.deepEqual(
[decoded.matchLength, decoded.score, decoded.crawford, decoded.jacoby],
[5, [4, 2], true, false],
);
// En partie Crawford, le moteur refuse le videau au joueur au trait.
const crawford = await engine.cubeDecision(
START_POSITION,
{
value: 1,
owner: "centered",
match: { length: 5, score: { onRoll: 2, opponent: 4 }, crawford: true },
},
0,
);
assert.equal(crawford.available, false);
const after = await engine.matchAfterGame(
{ length: 5, score: { onRoll: 3, opponent: 2 }, crawford: false },
"onRoll",
1,
);
assert.deepEqual(after, { score: { onRoll: 4, opponent: 2 }, over: false, crawford: true });
});
+11 -2
View File
@@ -28,11 +28,16 @@ const TABLE: [string, Route][] = [
["#/glossaire", { screen: "glossary", term: null }],
["#/glossaire/videau", { screen: "glossary", term: "videau" }],
["#/a-propos", { screen: "about" }],
["#/tutoriel", { screen: "tutorial", step: null }],
["#/tutoriel/3", { screen: "tutorial", step: 3 }],
["#/tutoriel", { screen: "tutorial", step: null, practice: false }],
["#/tutoriel/3", { screen: "tutorial", step: 3, practice: false }],
["#/tutoriel/3/a-vous", { screen: "tutorial", step: 3, practice: true }],
["#/tutoriel/fin", { screen: "tutorialEnd" }],
["#/installer", { screen: "install" }],
["#/parties", { screen: "games", game: null }],
["#/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 },
@@ -63,6 +68,10 @@ test("une adresse inconnue est reconnue comme telle", () => {
"#/menu/plus",
"#/tutoriel/0",
"#/tutoriel/x",
"#/tutoriel/3/b",
"#/tutoriel/3/a-vous/x",
"#/tutoriel/fin/a-vous",
"#/installer/x",
"#/a-propos/x",
"#/position",
"#/position/%E0%A4%A",
+181
View File
@@ -0,0 +1,181 @@
// Étapes des scénarios docs/scenarios/decouvrir-l-application.feature et
// installer-l-application.feature (ADR 0008) : l'accueil et l'invitation à
// installer passent par les modules de l'interface qui les décident
// (home-model.ts, settings.ts, install.ts), la partie par le domaine et le
// vrai moteur, le tutoriel par son texte source.
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { BeforeAll, Given, Then, When } from "@cucumber/cucumber";
import type { Engine } from "../../src/domain/game/engine.js";
import { Game } from "../../src/domain/game/game.js";
import {
dismissInstall,
homeModel,
installMenuShown,
launchHash,
WELCOME_LINKS,
welcomed,
} from "../../src/ui/home-model.js";
import { HOME_TEXTS } from "../../src/ui/home-texts.js";
import { installPlatform } from "../../src/ui/install.js";
import { parseRoute } from "../../src/ui/router.js";
import { parseSettings, type Settings } from "../../src/ui/settings.js";
import { placeOf } from "../../src/ui/tutorial-nav.js";
import { parseTutorial } from "../../src/ui/tutorial-text.js";
import { loadNodeEngine } from "../support/node-engine.js";
import { seededDice, step } from "../support/play.js";
const root = new URL("../../../../", import.meta.url);
let engine: Engine;
let settings: Settings;
let game: Game | null = null;
let installed = false;
let route = "";
let installSteps: readonly string[] = [];
BeforeAll(async () => {
engine = await loadNodeEngine();
});
/** Ce que montre l'accueil, la partie étant celle du scénario. */
function home() {
const playing = game === null ? false : game.state.phase !== "over";
return homeModel(settings, playing, installed);
}
// ------------------------------------------------------- Premier lancement
Given("vous ouvrez Ustagammon pour la toute première fois", () => {
settings = parseSettings(null);
game = null;
installed = false;
});
Then("l'accueil vous propose de découvrir les règles ou de jouer tout de suite", () => {
assert.equal(launchHash(""), "#/accueil");
assert.equal(parseRoute(launchHash("") ?? "").screen, "home");
assert.ok(home().welcome);
assert.equal(parseRoute(WELCOME_LINKS.play).screen, "game");
});
When("vous choisissez de découvrir les règles", () => {
settings = { ...settings, ...welcomed() };
route = WELCOME_LINKS.discover;
});
Then("le tutoriel s'ouvre à sa première étape", () => {
const tutorial = parseTutorial(readFileSync(new URL("docs/tutoriel/regles.md", root), "utf8"));
assert.deepEqual(placeOf(tutorial, parseRoute(route)), { step: 1, practice: false });
});
Then("ce choix ne vous est plus proposé", () => {
assert.ok(!home().welcome);
});
Given("vous avez choisi de jouer tout de suite", () => {
settings = { ...parseSettings(null), ...welcomed() };
installed = false;
});
Given("une partie est en cours", () => {
game = Game.start(engine, { dice: seededDice(11), level: "0ply" });
settings = { ...settings, gamesStarted: settings.gamesStarted + 1 };
});
When("vous rouvrez Ustagammon", () => {
route = launchHash("") ?? "";
});
Then("l'accueil vous propose de reprendre votre partie", () => {
assert.equal(parseRoute(route).screen, "home");
assert.ok(home().resume);
});
When("la partie se termine", async () => {
assert.ok(game !== null);
for (let i = 0; game.state.phase !== "over"; i++) {
assert.ok(i < 2000, "la partie ne finit pas");
await step(game, engine);
}
});
Then("l'accueil vous propose une nouvelle partie", () => {
assert.equal(parseRoute(route).screen, "home");
assert.ok(!home().resume);
});
// ------------------------------------------------------------- Installation
Given("votre navigateur ne propose pas lui-même l'installation", () => {
installSteps = [];
});
When("vous demandez à installer l'application avec Firefox", () => {
const firefox = "Mozilla/5.0 (Android 14; Mobile; rv:131.0) Gecko/131.0 Firefox/131.0";
installSteps = HOME_TEXTS.installSteps[installPlatform(firefox)];
});
When("vous demandez à installer l'application sur iPhone", () => {
const iphone =
"Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 " +
"(KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1";
installSteps = HOME_TEXTS.installSteps[installPlatform(iphone)];
});
Then("la marche à suivre compte trois étapes, en commençant par le menu du navigateur", () => {
assert.equal(installSteps.length, 3);
assert.match(installSteps[0] ?? "", /menu du navigateur/);
});
Then("la marche à suivre compte trois étapes, en commençant par le bouton de partage", () => {
assert.equal(installSteps.length, 3);
assert.match(installSteps[0] ?? "", /bouton de partage/);
});
Then("l'accueil ne vous propose pas d'installer l'application", () => {
assert.ok(!home().installCard);
});
Then("l'accueil vous propose d'installer l'application", () => {
assert.ok(home().installCard);
});
When("vous terminez une partie", () => {
settings = { ...settings, gamesFinished: settings.gamesFinished + 1 };
});
When("vous terminez {int} parties de plus", (n: number) => {
settings = { ...settings, gamesFinished: settings.gamesFinished + n };
});
When("vous choisissez « Plus tard »", () => {
settings = { ...settings, ...dismissInstall(settings) };
});
Then("le menu vous le propose toujours", () => {
assert.ok(installMenuShown(settings, installed));
});
Given("vous avez terminé une partie", () => {
settings = { ...parseSettings(null), welcomed: true, gamesFinished: 1 };
installed = false;
assert.ok(home().installCard);
});
When("vous installez l'application", () => {
// L'événement appinstalled du navigateur, retenu dans les réglages.
installed = true;
settings = { ...settings, installed: true };
});
Then("le menu non plus", () => {
assert.ok(!installMenuShown(settings, installed));
// Même dans un onglet ouvert plus tard, hors de l'application installée.
assert.ok(!installMenuShown(settings, false));
assert.ok(!homeModel(settings, true, false).installCard);
});
@@ -0,0 +1,132 @@
// Étapes du scénario docs/scenarios/apprendre-les-regles.feature (ADR 0008) :
// le texte du tutoriel est lu dans sa source (docs/tutoriel/regles.md), et
// chaque geste est jugé par le domaine branché sur le vrai moteur, comme
// dans l'application.
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { BeforeAll, Given, Then, When } from "@cucumber/cucumber";
import type { Engine } from "../../src/domain/game/engine.js";
import {
MovePractice,
type PracticeVerdict,
shouldTake,
tutorialBoard,
} from "../../src/domain/learning/tutorial.js";
import { progress, seenAfter, type TutorialPlace } from "../../src/ui/tutorial-nav.js";
import { parseTutorial, type Tutorial, type TutorialScreen } from "../../src/ui/tutorial-text.js";
import { loadNodeEngine } from "../support/node-engine.js";
const root = new URL("../../../../", import.meta.url);
let engine: Engine;
let tutorial: Tutorial;
let seen = 0;
let place: TutorialPlace = { step: 1, practice: false };
let screen: TutorialScreen;
let practice: MovePractice | null = null;
let verdict: PracticeVerdict = "continue";
let answeredRight = false;
BeforeAll(async () => {
engine = await loadNodeEngine();
tutorial = parseTutorial(readFileSync(new URL("docs/tutoriel/regles.md", root), "utf8"));
});
Given("vous commencez le tutoriel", () => {
seen = 0;
place = { step: 1, practice: false };
seen = seenAfter(seen, place);
});
Then("il compte huit étapes, dont quatre où c'est à vous de jouer", () => {
assert.equal(tutorial.steps.length, 8);
assert.equal(tutorial.steps.filter((s) => s.practice !== null).length, 4);
});
Then("chaque position du tutoriel se lit sur le plateau", async () => {
for (const step of tutorial.steps) {
for (const current of [step.explain, step.practice]) {
if (current !== null) {
const board = await tutorialBoard(engine, current.position, current.onRoll);
assert.equal(board.sides.player.points.length, 24);
}
}
}
});
When("vous arrivez à l'étape {int}", (n: number) => {
for (let step = 1; step <= n; step++) {
place = { step, practice: false };
seen = seenAfter(seen, place);
}
});
Then("la barre de progression vous ramène aux étapes 1 et 2, mais pas aux suivantes", () => {
const links = progress(tutorial.steps.length, place, seen)
.filter((segment) => segment.link)
.map((segment) => segment.step);
assert.deepEqual(links, [1, 2]);
});
Given("vous êtes à l'écran « à vous » de l'étape {int}", async (n: number) => {
const found = tutorial.steps[n - 1]?.practice;
assert.ok(found != null, `l'étape ${n} n'a pas d'écran « à vous »`);
screen = found;
practice =
screen.roll === null || screen.expected.length === 0
? null
: await MovePractice.start(engine, screen.position, screen.roll, screen.expected);
verdict = "continue";
answeredRight = false;
});
When(
"vous déplacez un pion de la flèche {int} à la flèche {int}",
async (from: number, to: number) => {
assert.ok(practice !== null);
verdict = await practice.play({ from, to });
if (verdict === "wrong") {
practice.reset();
}
},
);
Then("le pion revient, et une phrase vous dit pourquoi", () => {
assert.equal(verdict, "wrong");
assert.deepEqual(practice?.pendingHalfMoves, []);
assert.ok(screen.wrong.map((part) => part.text).join("").length > 20);
});
When("vous réunissez deux pions sur la flèche 5", async () => {
assert.ok(practice !== null);
verdict = await practice.play({ from: 8, to: 5 });
assert.equal(verdict, "continue");
verdict = await practice.play({ from: 6, to: 5 });
});
Then("le tutoriel salue votre coup", () => {
assert.equal(verdict, "right");
assert.ok(screen.right.length > 0);
});
When("vous répondez « Passer »", async () => {
answeredRight = (await shouldTake(engine, screen.position)) === false;
});
When("vous répondez « Prendre »", async () => {
answeredRight = (await shouldTake(engine, screen.position)) === true;
});
Then("le maître dit qu'il aurait pris", () => {
assert.ok(!answeredRight);
assert.match(screen.wrong.map((part) => part.text).join(""), /Le maître prendrait/);
});
Then("le tutoriel salue votre réponse", () => {
assert.ok(answeredRight);
assert.match(screen.right.map((part) => part.text).join(""), /Le maître prend aussi/);
});
@@ -0,0 +1,145 @@
// Étapes du scénario docs/scenarios/comprendre-mes-coups.feature (ADR 0008) :
// les décisions passent par le domaine et le vrai moteur, le conseil par
// l'analyse du domaine, ses mots par la source des phrases du maître, et
// la ligne par le suivi du conseil de l'interface (advice-tracker.ts) avec
// le réglage de l'application.
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
import assert from "node:assert/strict";
import { Before, BeforeAll, Given, setDefaultTimeout, Then, When } from "@cucumber/cucumber";
import { analyseDecision } from "../../src/domain/analysis/analyse.js";
import { masterWords } from "../../src/domain/analysis/phrases.js";
import type { Advice } from "../../src/domain/game/advice.js";
import type { Engine } from "../../src/domain/game/engine.js";
import { Game } from "../../src/domain/game/game.js";
import { parseHalfMoves, START_POSITION } from "../../src/domain/game/position.js";
import { fixedDice } from "../../src/domain/game/roll.js";
import { createAdviceTracker } from "../../src/ui/advice-tracker.js";
import { parseSettings, type Settings } from "../../src/ui/settings.js";
import { DOUBLE_TAKE_POSITION, loadPhrases } from "../support/advice.js";
import { loadNodeEngine } from "../support/node-engine.js";
setDefaultTimeout(30_000);
const phrases = loadPhrases();
let engine: Engine;
let game: Game;
let settings: Settings;
let tracker: ReturnType<typeof createAdviceTracker>;
let ready: Promise<void>;
let markReady: () => void;
BeforeAll(async () => {
engine = await loadNodeEngine();
});
Before(() => {
settings = parseSettings(null);
ready = new Promise((resolve) => {
markReady = resolve;
});
tracker = createAdviceTracker({
analyse: (journal, eventId) => analyseDecision(engine, journal, eventId),
recorded: () => {},
changed: () => markReady(),
});
});
/** Joue une décision du joueur comme l'écran de jeu : la ligne d'avant
* s'efface, la décision part à l'analyse. */
async function decide(action: (g: Game) => Promise<void> | void): Promise<void> {
tracker.clear();
const before = game.events;
await action(game);
tracker.track(game, before);
}
/** Ce que montre la ligne du maître (null : rien), comme l'écran : le
* conseil prêt, si le réglage le permet. */
async function line(): Promise<{ word: string; phrase: string; advice: Advice } | null> {
await Promise.race([ready, new Promise((resolve) => setTimeout(resolve, 10_000))]);
const advice = tracker.shown;
if (!settings.masterAdvice || advice === null) {
return null;
}
const words = masterWords(phrases, advice);
return { word: words.word, phrase: words.phrase, advice };
}
Given("vous avez {int}-{int} à jouer à la position de départ", async (a: number, b: number) => {
game = Game.start(engine, {
setup: { position: START_POSITION, firstTurn: "player" },
dice: fixedDice([a, b, 6, 5]),
});
await decide((g) => g.roll());
});
Given("vous avez coupé le conseil du maître", () => {
settings = { ...settings, masterAdvice: false };
});
When("vous validez le coup {}", async (move: string) => {
await decide(async (g) => {
for (const halfMove of parseHalfMoves(move)) {
assert.ok(await g.playHalfMove(halfMove), `demi-coup refusé : ${move}`);
}
await g.confirmMove();
});
// L'ordinateur joue aussitôt, sans attendre l'analyse.
await game.playComputerTurn();
});
Then("le maître dit « {}. »", async (word: string) => {
const shown = await line();
assert.equal(shown?.word, word);
});
Then("sa phrase dit que {} fait votre point {int}", async (move: string, point: number) => {
const shown = await line();
assert.ok(shown?.phrase.includes(`${move} fait votre point ${point}.`), shown?.phrase);
});
Then("sa phrase commence par « {} »", async (start: string) => {
const shown = await line();
assert.ok(shown?.phrase.startsWith(start), shown?.phrase);
});
Then("sa phrase dit « {} »", async (phrase: string) => {
const shown = await line();
assert.equal(shown?.phrase, phrase);
});
Then("l'explication peut montrer votre coup et le coup du maître sur le plateau", async () => {
const shown = await line();
assert.ok(shown !== null && shown.advice.kind === "move");
const advice = shown.advice;
const mine = await game.stepsBefore(advice.eventId, "player", advice.played.halfMoves);
const best = await game.stepsBefore(advice.eventId, "player", advice.best.halfMoves);
// Les deux coups partent de la même position, celle d'avant le coup.
assert.deepEqual(mine.boards[0], best.boards[0]);
assert.equal(best.halfMoves.length, 2);
assert.notDeepEqual(mine.boards.at(-1), best.boards.at(-1));
});
Then("le maître ne dit rien", async () => {
assert.equal(await line(), null);
// Le coup est tout de même analysé, pour le bilan de fin de partie.
const move = game.events.find((e) => e.type === "MovePlayed" && e.side === "player");
assert.ok(game.advice[move?.id ?? ""] !== undefined);
});
Given("l'ordinateur vous propose de doubler dans une position où il faut prendre", async () => {
game = Game.start(engine, {
setup: { position: DOUBLE_TAKE_POSITION, firstTurn: "computer" },
level: "2ply",
dice: fixedDice([]),
});
await game.playComputerTurn();
assert.equal(game.state.phase, "doubled");
});
When("vous passez son double", async () => {
await decide((g) => g.drop());
});
+324
View File
@@ -0,0 +1,324 @@
// Étapes du scénario docs/scenarios/jouer-un-match.feature (ADR 0008) : le
// choix de la partie passe par les réglages de l'écran « Nouvelle partie »
// (settings.ts) et la session d'enregistrement ; les parties et le match,
// par le domaine branché sur le vrai moteur, avec des dés imposés.
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
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";
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";
setDefaultTimeout(120_000);
// Course : le joueur au trait n'a plus que deux dames, sur ses points 6 et
// 5 ; son adversaire a ses quinze dames sur son point 1, aucune sortie. Avec
// 6-5, le joueur au trait gagne un gammon.
const GAMMON_NEXT_ROLL = "/38AAABQAAAAAA";
// La même, mais l'adversaire a déjà sorti quatre dames : le joueur au trait
// gagne une partie simple avec 6-5.
const SINGLE_NEXT_ROLL = "/wcAAAAFAAAAAA";
let engine: Engine;
let settings: Settings;
let session: GameSession;
let game: Game;
BeforeAll(async () => {
engine = await loadNodeEngine();
});
/** Joue le jet du joueur : le premier coup légal, demi-coup par demi-coup. */
async function playFirstMove(g: Game): Promise<void> {
await g.roll();
const state = g.state;
assert.ok(state.roll !== null);
const [move] = await engine.legalMoves(state.position, state.roll);
assert.ok(move !== undefined);
for (const halfMove of move.halfMoves) {
assert.ok(await g.playHalfMove(halfMove));
}
await g.confirmMove();
}
// ------------------------------------------------------------ Le niveau
Given("vous ouvrez l'écran « Nouvelle partie » pour la première fois", () => {
settings = parseSettings(null);
session = createGameSession(createGameLibrary(createKeyValueBackend(memoryStorage())), {
start: (newGame) => Game.start(engine, { dice: fixedDice([3, 1, 4, 2]), ...newGame }),
resume: (journal) => Game.resume(engine, journal),
});
});
Then(
"une partie libre contre un ordinateur Intermédiaire, avec le conseil du maître, est proposée",
() => {
assert.equal(settings.gameKind, "free");
assert.equal(settings.level, "intermediate");
assert.equal(settings.masterAdvice, true);
assert.deepEqual(chosenGame(settings), { level: "intermediate" });
},
);
When("vous choisissez le niveau Champion et commencez la partie", () => {
settings = { ...settings, level: "champion" };
game = session.newGame(chosenGame(settings));
session.save(game.journal);
});
Then("l'ordinateur joue au niveau {int} plis de GNU Backgammon", async (plies: number) => {
assert.equal(game.state.level, `${plies}ply`);
// Le joueur commence avec 3-1 ; l'ordinateur répond avec 4-2, et joue
// le coup que le moteur donne pour ce niveau.
await game.playHalfMove({ from: 8, to: 5 });
await game.playHalfMove({ from: 6, to: 5 });
await game.confirmMove();
const before = game.state;
await game.playComputerTurn();
const played = game.events.findLast((e) => e.type === "MovePlayed");
assert.ok(played?.type === "MovePlayed" && played.side === "computer");
const roll = game.events.findLast((e) => e.type === "DiceRolled");
assert.ok(roll?.type === "DiceRolled");
const expected = await engine.levelMove(
before.position,
engineCube(before, "computer"),
roll.roll,
"2ply",
);
assert.deepEqual(played.halfMoves, expected?.halfMoves ?? []);
session.save(game.journal);
});
Then("la partie reprise garde ce niveau", async () => {
const resumed = await session.restore();
assert.ok(resumed !== null);
assert.equal(resumed.state.level, "2ply");
});
Then("vos choix sont proposés à la prochaine nouvelle partie", () => {
const again = parseSettings(JSON.stringify(settings));
assert.equal(again.level, "champion");
assert.equal(chosenGame(again).level, "2ply");
});
// ------------------------------------------------------- Un match entier
// Les parties du match, dans l'ordre.
let games: Game[] = [];
Given("vous commencez un match en {int} points contre un ordinateur Débutant", (n: number) => {
settings = { ...parseSettings(null), gameKind: "match", matchLength: n, level: "beginner" };
games = [];
});
When("le match se joue jusqu'au bout, partie après partie", async () => {
// Des dés reproductibles, partagés par toutes les parties du match.
const dice = seededDice(3);
let next: NewGame | null = chosenGame(settings);
while (next !== null) {
assert.ok(games.length < 20, "le match ne finit pas");
const g = Game.start(engine, { dice, ...next });
await playToTheEnd(g, engine);
games.push(g);
next = g.nextGame;
}
});
Then("le match se termine dès qu'un joueur atteint {int} points", (n: number) => {
const last = games.at(-1)?.state;
assert.ok(last?.matchAfter);
assert.equal(last.matchAfter.over, true);
const { player, computer } = last.matchAfter.score;
assert.ok(Math.max(player, computer) >= n);
// Avant la dernière partie, personne n'avait encore les points.
for (const g of games.slice(0, -1)) {
assert.equal(g.state.matchAfter?.over, false);
const score = g.state.matchAfter?.score;
assert.ok(score !== undefined && Math.max(score.player, score.computer) < n);
}
});
Then("chaque partie commence avec le score laissé par la précédente", () => {
assert.deepEqual(games[0]?.state.match?.score, { player: 0, computer: 0 });
for (let i = 1; i < games.length; i++) {
assert.deepEqual(games[i]?.state.match?.score, games[i - 1]?.state.matchAfter?.score);
}
});
Then("la partie Crawford s'est jouée sans videau", () => {
const crawford = games.filter((g) => g.state.match?.crawford === true);
assert.equal(crawford.length, 1, "une partie Crawford, et une seule");
for (const g of crawford) {
assert.ok(!g.events.some((e) => e.type === "CubeOffered"));
assert.equal(g.state.cube.value, 1);
}
});
// ------------------------------------------------------------- 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;
Given("un match en 3 points commence et que l'ordinateur va gagner un gammon", () => {
game = Game.start(engine, {
setup: { position: GAMMON_NEXT_ROLL, firstTurn: "computer" },
level: "beginner",
match: firstGameOf(3),
dice: fixedDice([6, 5]),
});
});
When("l'ordinateur gagne la partie", async () => {
await game.playComputerTurn();
assert.deepEqual(game.state.result, { winner: "computer", kind: "gammon", points: 2 });
});
Then("l'ordinateur mène le match {int} à {int}", (computer: number, player: number) => {
assert.deepEqual(game.state.matchAfter?.score, { player, computer });
});
Then("la partie suivante est la partie Crawford", () => {
assert.equal(game.nextGame?.match?.crawford, true);
});
When("la partie Crawford commence", () => {
const next = game.nextGame;
assert.ok(next?.match !== undefined);
crawfordGame = Game.start(engine, {
...next,
setup: { position: SINGLE_NEXT_ROLL, firstTurn: "player" },
dice: fixedDice([6, 5]),
});
});
Then("personne ne peut doubler pendant cette partie", async () => {
assert.equal(await crawfordGame.canDouble(), false);
// L'ordinateur non plus, dans la même position vue de lui.
const state = crawfordGame.state;
const board = await engine.board(state.position, engineCube(state, "player"));
assert.equal(
await engine.levelDouble(board.swappedPosition, engineCube(state, "computer"), state.level),
false,
);
});
When("vous gagnez la partie Crawford", async () => {
await playFirstMove(crawfordGame);
assert.deepEqual(crawfordGame.state.matchAfter, {
score: { player: 1, computer: 2 },
over: false,
crawford: false,
});
});
Then("vous pouvez de nouveau doubler dans la partie suivante", async () => {
const next = crawfordGame.nextGame;
assert.ok(next?.match !== undefined);
const match: Match = next.match;
assert.equal(match.crawford, false);
const after = Game.start(engine, {
...next,
setup: { position: START_POSITION, firstTurn: "player" },
});
assert.equal(await after.canDouble(), true);
});
// ------------------------------------------------------- La règle Jacoby
Given("vous allez gagner un gammon sans que personne ait doublé", () => {
// La position est choisie à chaque partie, ci-dessous.
});
When("vous gagnez une partie libre", async () => {
game = Game.start(engine, {
setup: { position: GAMMON_NEXT_ROLL, firstTurn: "player" },
dice: fixedDice([6, 5]),
});
await playFirstMove(game);
});
When("vous gagnez la même partie dans un match", async () => {
game = Game.start(engine, {
setup: { position: GAMMON_NEXT_ROLL, firstTurn: "player" },
match: firstGameOf(5),
dice: fixedDice([6, 5]),
});
await playFirstMove(game);
});
Then("vous marquez {int} point(s)", (points: number) => {
assert.equal(game.state.result?.winner, "player");
assert.equal(game.state.result?.kind, "gammon");
assert.equal(game.state.result?.points, points);
});
+2 -2
View File
@@ -426,8 +426,8 @@ When("vous prenez", () => {
game.take();
});
When("vous passez", () => {
game.drop();
When("vous passez", async () => {
await game.drop();
});
Then("la partie continue à valeur doublée", () => {
+2 -2
View File
@@ -42,7 +42,7 @@ Before(async () => {
/** Commence une partie, comme l'application : un nouvel identifiant. */
function startGame(): void {
game = Game.start(engine, { dice: seededDice(nextSeed), ply: 0 });
game = Game.start(engine, { dice: seededDice(nextSeed), level: "0ply" });
shownId = `partie-${nextSeed++}`;
}
@@ -58,7 +58,7 @@ async function reopen(): Promise<void> {
library = (await openGameLibrary({ localStorage: storage })).library;
const saved = await library.current();
assert.ok(saved !== null, "aucune partie en cours");
game = Game.resume(engine, saved.journal, { ply: 0 });
game = Game.resume(engine, saved.journal, { level: "0ply" });
shownId = saved.summary.id;
}
+5 -10
View File
@@ -45,15 +45,10 @@ BeforeAll(async () => {
Before(() => {
library = createGameLibrary(createKeyValueBackend(memoryStorage()));
session = createGameSession(
library,
{
start: (setup) =>
Game.start(engine, { dice: seededDice(7), ply: 0, ...(setup ? { setup } : {}) }),
resume: (journal) => Game.resume(engine, journal, { ply: 0 }),
},
"0ply",
);
session = createGameSession(library, {
start: (newGame) => Game.start(engine, { dice: seededDice(7), level: "0ply", ...newGame }),
resume: (journal) => Game.resume(engine, journal, { level: "0ply" }),
});
shared = null;
failure = null;
});
@@ -131,7 +126,7 @@ Then("votre partie n'a pas changé", () => {
When("vous choisissez « Jouer à partir d'ici »", () => {
assert.ok(shared !== null);
game = session.newGame(setupOf(shared));
game = session.newGame({ setup: setupOf(shared) });
session.save(game.journal);
});
+49 -8
View File
@@ -20,6 +20,7 @@ import { memoryStorage } from "../../src/storage/open-library.js";
import { BANNER_MS } from "../../src/ui/app.js";
import { createHaptics } from "../../src/ui/haptics.js";
import { nextTheme, parseSettings, type Settings } from "../../src/ui/settings.js";
import { betweenTurns, updateBannerShown } from "../../src/ui/updates.js";
import { loadNodeEngine } from "../support/node-engine.js";
setDefaultTimeout(30_000);
@@ -47,14 +48,10 @@ let oldId: string | null;
Given("une partie est en cours depuis quelques coups", async () => {
library = createGameLibrary(createKeyValueBackend(memoryStorage()));
let dice = fixedDice([3, 1, 6, 4, 5, 2]);
session = createGameSession(
library,
{
start: () => Game.start(engine, { dice }),
resume: (journal) => Game.resume(engine, journal, { dice }),
},
"2ply",
);
session = createGameSession(library, {
start: () => Game.start(engine, { dice }),
resume: (journal) => Game.resume(engine, journal, { dice }),
});
game = session.newGame();
dice = () => 1 + Math.floor(Math.random() * 6);
for (const halfMove of parseHalfMoves("8/5 6/5")) {
@@ -163,3 +160,47 @@ Then("il passe à clair, puis à sombre, puis revient à automatique", () => {
}
assert.deepEqual(seen, ["auto", "light", "dark", "auto"]);
});
// ------------------------------------------------------------ Mise à jour
// Le bandeau dépend de deux choses : une version attend (le service worker
// l'a installée), et la partie est entre deux tours. La partie est celle du
// domaine, sur le vrai moteur.
let updateAvailable = false;
Given("une nouvelle version d'Ustagammon est disponible", () => {
updateAvailable = true;
});
const shownNow = (otherBanner = false) =>
updateBannerShown(
updateAvailable,
betweenTurns(game.state.phase, game.pendingHalfMoves.length),
otherBanner,
);
Given("vous avez des dés à jouer", () => {
// Jet d'ouverture 3-1 : vous commencez.
game = Game.start(engine, { dice: fixedDice([3, 1, 6, 4, 5, 2]), level: "0ply" });
assert.equal(game.state.turn, "player");
assert.equal(game.state.phase, "moving");
});
When("vous déplacez un pion de votre coup", async () => {
assert.ok(await game.playHalfMove({ from: 8, to: 5 }));
});
Then("l'application ne vous propose pas encore de mettre à jour", () => {
assert.ok(!shownNow());
});
When("vous avez joué votre coup", async () => {
assert.ok(await game.playHalfMove({ from: 6, to: 5 }));
await game.confirmMove();
});
Then("l'application vous propose de mettre à jour", () => {
assert.ok(shownNow());
// Jamais par-dessus le bandeau « Reprendre l'ancienne ».
assert.ok(!shownNow(true));
});
+4 -8
View File
@@ -226,14 +226,10 @@ test("session : « Reprendre l'ancienne » par l'identifiant, et reprise d'une b
const games = library();
// Game.start et Game.resume n'interrogent pas le moteur.
const engine = {} as Engine;
const session = createGameSession(
games,
{
start: () => Game.start(engine, { dice: fixedDice([5, 2]) }),
resume: (j) => Game.resume(engine, j),
},
"2ply",
);
const session = createGameSession(games, {
start: () => Game.start(engine, { dice: fixedDice([5, 2]) }),
resume: (j) => Game.resume(engine, j),
});
assert.equal(await session.restore(), null);
assert.equal(await session.resumePrevious(), null);
+108
View File
@@ -0,0 +1,108 @@
// Des décisions du joueur dans des positions connues, jouées par le domaine
// sur le vrai moteur, puis analysées : de quoi tester le conseil du maître
// (tests unitaires et scénarios).
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
import { readFileSync } from "node:fs";
import { analyseDecision } from "../../src/domain/analysis/analyse.js";
import { type MasterPhrases, parsePhrases } from "../../src/domain/analysis/phrases.js";
import type { Advice } from "../../src/domain/game/advice.js";
import type { Engine } from "../../src/domain/game/engine.js";
import { Game } from "../../src/domain/game/game.js";
import { parseHalfMoves, START_POSITION } from "../../src/domain/game/position.js";
import { fixedDice } from "../../src/domain/game/roll.js";
const root = new URL("../../../../", import.meta.url);
/** Les phrases du maître, lues dans leur source. */
export function loadPhrases(): MasterPhrases {
return parsePhrases(readFileSync(new URL("docs/maitre/phrases.md", root), "utf8"));
}
/** Une position de double et prise (videau au centre), vue de celui qui a
* la main : doubler y est juste, et prendre aussi (tests du moteur, 1a). */
export const DOUBLE_TAKE_POSITION = "6O5uAAC4uzsAAA";
/** Le joueur, au trait à la position de départ, lance dice et joue move ;
* la partie et l'événement du coup. */
export async function playOpening(
engine: Engine,
dice: readonly [number, number],
move: string,
): Promise<{ game: Game; eventId: string }> {
const game = Game.start(engine, {
setup: { position: START_POSITION, firstTurn: "player" },
// Le jet du joueur, puis celui de l'ordinateur s'il joue ensuite.
dice: fixedDice([...dice, 6, 5]),
});
await game.roll();
for (const halfMove of parseHalfMoves(move)) {
if (!(await game.playHalfMove(halfMove))) {
throw new Error(`demi-coup refusé : ${move}`);
}
}
await game.confirmMove();
const played = game.events.findLast((e) => e.type === "MovePlayed");
return { game, eventId: played?.id ?? "" };
}
/** Le joueur, au trait dans la position de double et prise, double ;
* l'ordinateur répond. */
export async function playerDoubles(engine: Engine): Promise<{ game: Game; eventId: string }> {
const game = Game.start(engine, {
setup: { position: DOUBLE_TAKE_POSITION, firstTurn: "player" },
dice: fixedDice([]),
});
await game.double();
const offered = game.events.find((e) => e.type === "CubeOffered");
return { game, eventId: offered?.id ?? "" };
}
/** Le joueur, au trait dans la position de double et prise, lance sans
* doubler. */
export async function playerRollsWithoutDoubling(
engine: Engine,
): Promise<{ game: Game; eventId: string }> {
const game = Game.start(engine, {
setup: { position: DOUBLE_TAKE_POSITION, firstTurn: "player" },
dice: fixedDice([6, 5]),
});
await game.roll();
const rolled = game.events.findLast((e) => e.type === "DiceRolled");
return { game, eventId: rolled?.id ?? "" };
}
/** L'ordinateur, au trait dans la position de double et prise, double ; le
* joueur prend (take) ou passe. */
export async function playerAnswers(
engine: Engine,
take: boolean,
): Promise<{ game: Game; eventId: string }> {
const game = Game.start(engine, {
setup: { position: DOUBLE_TAKE_POSITION, firstTurn: "computer" },
level: "2ply",
dice: fixedDice([]),
});
await game.playComputerTurn();
if (take) {
game.take();
} else {
await game.drop();
}
const answer = game.events.find((e) => e.type === (take ? "CubeTaken" : "CubeDropped"));
return { game, eventId: answer?.id ?? "" };
}
/** Le conseil du moteur sur une décision ; erreur s'il n'y en a pas. */
export async function adviceOn(
engine: Engine,
played: { game: Game; eventId: string },
): Promise<Advice> {
const advice = await analyseDecision(engine, played.game.journal, played.eventId);
if (advice === null) {
throw new Error(`aucun conseil pour l'événement ${played.eventId}`);
}
return advice;
}
+1 -1
View File
@@ -33,7 +33,7 @@ export async function step(
if (strategy.take ?? true) {
game.take();
} else {
game.drop();
await game.drop();
}
} else if (state.turn === "computer") {
await game.playComputerTurn();
+5 -5
View File
@@ -44,7 +44,7 @@ async function main(): Promise<void> {
[3, 0],
[11, 2],
] as const) {
const game = Game.start(engine, { dice: seededDice(seed), ply: 0 });
const game = Game.start(engine, { dice: seededDice(seed), level: "0ply" });
await playToTheEnd(game, engine, choice);
games.push([`complete-${seed}`, game]);
}
@@ -52,7 +52,7 @@ async function main(): Promise<void> {
// Des branches : rejouée d'un coup de l'ordinateur (nouveaux dés), d'un
// jet du joueur (même jet, autre coup), et une branche d'une branche.
{
const game = Game.start(engine, { dice: seededDice(5), ply: 0 });
const game = Game.start(engine, { dice: seededDice(5), level: "0ply" });
await playMoves(game, engine, 14);
const original = game.events.at(-1)?.id as string;
game.goBackTo(lastMoveId(game, 6));
@@ -70,7 +70,7 @@ async function main(): Promise<void> {
// Le joueur double d'entrée ; l'ordinateur prend ou passe selon le moteur.
{
const game = Game.start(engine, { dice: seededDice(9), ply: 0 });
const game = Game.start(engine, { dice: seededDice(9), level: "0ply" });
for (let i = 0; i < 400 && game.state.phase !== "over"; i++) {
await step(game, engine, { double: true });
}
@@ -83,7 +83,7 @@ async function main(): Promise<void> {
{
const game = Game.start(engine, {
dice: seededDice(13),
ply: 0,
level: "0ply",
setup: { position: "6O5uAAC4uzsAAA", firstTurn: "computer" },
});
await step(game, engine);
@@ -99,7 +99,7 @@ async function main(): Promise<void> {
{
const game = Game.start(engine, {
dice: seededDice(17),
ply: 0,
level: "0ply",
setup: {
position: "t+0OAADg/wcAQA",
firstTurn: "player",
+212
View File
@@ -0,0 +1,212 @@
// Tests du tutoriel des règles : le texte (docs/tutoriel/regles.md) se lit,
// chaque position est valide pour le vrai moteur, chaque coup illustré et
// chaque coup attendu est légal, chaque écran « à vous » accepte ses coups
// justes et refuse les autres, et la réponse au double est celle du moteur.
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
import assert from "node:assert/strict";
import { existsSync, readFileSync } from "node:fs";
import { test } from "node:test";
import type { Move } from "../src/domain/game/engine.js";
import {
canStillLeadTo,
illustratedMove,
MovePractice,
shouldTake,
tutorialBoard,
} from "../src/domain/learning/tutorial.js";
import { parseInline, parseTutorial, type TutorialScreen } from "../src/ui/tutorial-text.js";
import { loadNodeEngine } from "./support/node-engine.js";
const root = new URL("../../../", import.meta.url);
const tutorial = parseTutorial(readFileSync(new URL("docs/tutoriel/regles.md", root), "utf8"));
test("le texte du tutoriel a huit étapes, une fin, et ses écrans « à vous »", () => {
assert.equal(tutorial.steps.length, 8);
assert.deepEqual(
tutorial.steps.filter((s) => s.practice !== null).map((s) => s.number),
[3, 4, 6, 8],
);
assert.ok(tutorial.end.action !== "");
assert.ok(tutorial.end.paragraphs.length > 0);
for (const step of tutorial.steps) {
assert.ok(step.explain.paragraphs.length > 0, `étape ${step.number} sans texte`);
const practice = step.practice;
if (practice !== null) {
assert.ok(practice.paragraphs.length > 0, `étape ${step.number} : consigne absente`);
assert.ok(
practice.right.length > 0 && practice.wrong.length > 0,
`étape ${step.number} : phrases juste et refus absentes`,
);
assert.ok(
practice.expected.length > 0 || practice.answer !== null,
`étape ${step.number} : ni coup attendu, ni réponse au double`,
);
}
}
});
test("chaque mot du glossaire cité par le tutoriel a sa fiche", () => {
const screens = tutorial.steps.flatMap((s) => [s.explain, ...(s.practice ? [s.practice] : [])]);
const inlines = [
...screens.flatMap((s) => [...s.paragraphs.flat(), ...s.right, ...s.wrong]),
...tutorial.end.paragraphs.flat(),
];
const terms = inlines.flatMap((i) => (i.term === undefined ? [] : [i.term]));
assert.ok(terms.length >= 10, `seulement ${terms.length} liens vers le glossaire`);
for (const term of terms) {
assert.ok(
existsSync(new URL(`docs/glossaire/${term}.md`, root)),
`fiche ${term}.md absente`,
);
}
});
test("les liens et le gras du texte sont lus", () => {
assert.deepEqual(parseInline("Un [blot](blot.md) **seul**, voilà."), [
{ text: "Un " },
{ text: "blot", term: "blot" },
{ text: " " },
{ text: "seul", strong: true },
{ text: ", voilà." },
]);
});
test("un texte mal formé est refusé", () => {
assert.throws(() =>
parseTutorial("## 2. Sans la première\n\n- position: x\n\nTexte.\n\n## Fin\n"),
);
assert.throws(() => parseTutorial("## 1. Sans position\n\nTexte.\n\n## Fin\n"));
assert.throws(() => parseTutorial("## 1. Jet faux\n\n- position: x\n- jet: 7-1\n\n## Fin\n"));
assert.throws(() => parseTutorial("## 1. Sans fin\n\n- position: x\n\nTexte.\n"));
});
/** Tous les écrans, avec leur nom pour les messages. */
function screens(): [string, TutorialScreen][] {
return tutorial.steps.flatMap((s) => [
[`étape ${s.number}`, s.explain] as [string, TutorialScreen],
...(s.practice === null
? []
: [[`étape ${s.number}, à vous`, s.practice] as [string, TutorialScreen]]),
]);
}
test("chaque position du tutoriel est valide pour le moteur", async () => {
const engine = await loadNodeEngine();
for (const [name, screen] of screens()) {
const board = await tutorialBoard(engine, screen.position, screen.onRoll);
const count = (side: "player" | "computer") => {
const s = board.sides[side];
return s.points.reduce((a, b) => a + b, 0) + s.bar + s.off;
};
assert.equal(count("player"), 15, `${name} : pions ivoire`);
assert.equal(count("computer"), 15, `${name} : pions noyer`);
}
});
test("chaque coup illustré est légal, et la partie de l'étape 7 finit par un gammon", async () => {
const engine = await loadNodeEngine();
for (const [name, screen] of screens()) {
if (screen.move === null) {
continue;
}
assert.ok(screen.roll !== null, `${name} : coup sans jet`);
const { steps, after } = await illustratedMove(
engine,
screen.position,
screen.roll,
screen.move,
);
assert.equal(steps.boards.length, steps.halfMoves.length + 1, name);
if (name === "étape 7") {
assert.deepEqual(after.result, { winner: "player", kind: "gammon" });
} else {
assert.equal(after.result, null, name);
}
if (name === "étape 4") {
assert.ok(steps.hits.some(Boolean), "étape 4 : le coup illustré ne frappe pas");
}
}
});
test("chaque écran « à vous » accepte ses coups attendus et refuse les autres", async () => {
const engine = await loadNodeEngine();
for (const [name, screen] of screens()) {
if (screen.expected.length === 0) {
continue;
}
assert.ok(screen.roll !== null, `${name} : pas de jet`);
const legal = await engine.legalMoves(screen.position, screen.roll);
for (const move of legal) {
const practice = await MovePractice.start(
engine,
screen.position,
screen.roll,
screen.expected,
);
let verdict = "continue";
for (const halfMove of move.halfMoves) {
verdict = await practice.play(halfMove);
if (verdict !== "continue") {
break;
}
}
const right = screen.expected.includes(move.notation);
assert.equal(verdict, right ? "right" : "wrong", `${name} : ${move.notation}`);
}
// Un coup attendu au moins, et un coup refusé au moins.
assert.ok(
legal.some((m) => !screen.expected.includes(m.notation)),
`${name} : rien à refuser`,
);
}
});
test("le joueur peut revenir en arrière après un coup refusé", async () => {
const engine = await loadNodeEngine();
const practice = await MovePractice.start(engine, "4HPwATDgc/ABMA", [3, 1], ["8/5 6/5"]);
assert.equal(await practice.play({ from: 24, to: 21 }), "wrong");
practice.reset();
assert.deepEqual(practice.pendingHalfMoves, []);
assert.equal(await practice.play({ from: 8, to: 5 }), "continue");
assert.equal(await practice.play({ from: 6, to: 5 }), "right");
});
test("la réponse au double de l'étape 8 est celle du moteur", async () => {
const engine = await loadNodeEngine();
for (const [name, screen] of screens()) {
if (screen.answer === null) {
continue;
}
assert.equal(screen.onRoll, "computer", `${name} : l'ordinateur doit avoir la main`);
assert.equal(await shouldTake(engine, screen.position), screen.answer === "take", name);
}
});
test("des demi-coups peuvent encore mener à un coup attendu, ou plus", () => {
const move = (notation: string, halfMoves: [number, number][]): Move => ({
notation,
halfMoves: halfMoves.map(([from, to]) => ({ from, to })),
position: notation,
});
const expected = [
move("8/5 6/5", [
[8, 5],
[6, 5],
]),
];
assert.ok(canStillLeadTo([], expected));
assert.ok(canStillLeadTo([{ from: 6, to: 5 }], expected));
assert.ok(!canStillLeadTo([{ from: 8, to: 7 }], expected));
assert.ok(
!canStillLeadTo(
[
{ from: 8, to: 5 },
{ from: 8, to: 5 },
],
expected,
),
);
});
+75 -8
View File
@@ -19,7 +19,15 @@ import {
pointColor,
pointSlot,
} from "../src/ui/board-model.js";
import { parseFeature, parseGlossaryEntry, parseGlossaryIndex } from "../src/ui/help-text.js";
import { FEATURES } from "../src/ui/help.js";
import { HELP_QUESTIONS, helpHash } from "../src/ui/help-context.js";
import {
matchesSearch,
parseFeature,
parseGlossaryEntry,
parseGlossaryIndex,
} from "../src/ui/help-text.js";
import { slug } from "../src/ui/router.js";
import { TEXTS } from "../src/ui/texts.js";
// Compilé dans build/web/tests/ : la racine du dépôt est trois dossiers plus haut.
@@ -143,21 +151,77 @@ test("l'aide lit le scénario tel qu'il est écrit dans docs/scenarios/", () =>
const feature = parseFeature(read("docs/scenarios/jouer-une-partie.feature"));
assert.equal(feature.title, "Jouer une partie contre l'ordinateur");
assert.match(feature.description[0] ?? "", /^Vous jouez une partie complète/);
assert.equal(feature.scenarios[0]?.title, "Commencer une partie");
assert.equal(feature.scenarios[0]?.title, "Comment commence une partie ?");
assert.equal(feature.scenarios[0]?.position, "4HPwATDgc/ABMA");
assert.deepEqual(feature.scenarios[0]?.steps, [
"Quand vous commencez une nouvelle partie",
"Alors les pions sont en position de départ",
"Et le premier jet de dés désigne qui commence",
]);
const refused = feature.scenarios.find((s) => s.title === "Un coup interdit est refusé");
assert.match(refused?.description[0] ?? "", /^La case 12 est tenue/);
const refused = feature.scenarios.find(
(s) => s.title === "Pourquoi je ne peux pas poser mon pion sur cette flèche ?",
);
assert.match(
refused?.description[0] ?? "",
/^Une flèche où l'ordinateur a au moins deux pions/,
);
assert.equal(refused?.steps[0], "Étant donné que c'est à vous de jouer avec le jet 3-1");
assert.equal(feature.scenarios[2]?.position, null);
// Chaque scénario a des étapes : aucune n'a été prise pour du texte libre.
for (const scenario of feature.scenarios) {
assert.ok(scenario.steps.length >= 2, scenario.title);
}
});
test("aide : chaque scénario affiché est une question avec sa réponse", () => {
const listed = read("docs/scenarios/README.md");
const ids = new Set<string>();
for (const url of FEATURES) {
assert.ok(
listed.includes(`](${url.replace("docs/scenarios/", "")})`),
`${url} hors de l'index`,
);
const feature = parseFeature(read(url));
assert.ok(feature.description.length > 0, `${url} : pas d'introduction`);
for (const scenario of feature.scenarios) {
// La réponse se lit seule : pas de vocabulaire technique.
assert.ok(scenario.description.length > 0, `${scenario.title} : pas de réponse`);
for (const word of ["scénario", "événement", "moteur", "Étant donné"]) {
assert.ok(
!scenario.description.join(" ").includes(word),
`${scenario.title} : « ${word} » dans la réponse`,
);
}
const id = slug(scenario.title);
assert.ok(id !== "" && !ids.has(id), `${scenario.title} : identifiant ${id} en double`);
ids.add(id);
}
}
});
test("aide contextuelle : chaque question ouverte depuis la partie existe dans les scénarios", () => {
const titles = FEATURES.flatMap((url) => parseFeature(read(url)).scenarios.map((s) => s.title));
for (const title of Object.values(HELP_QUESTIONS)) {
assert.ok(titles.includes(title), `« ${title} » n'est plus le titre d'un scénario`);
}
assert.equal(helpHash("stuck"), "#/aide/je-ne-peux-rien-jouer-avec-mes-des");
assert.equal(helpHash(null), "#/aide");
});
test("glossaire : nom anglais, renvois, recherche sans accents", () => {
const entry = parseGlossaryEntry(read("docs/glossaire/camp.md"));
assert.ok(entry !== null);
assert.equal(entry.english, "Home board");
assert.deepEqual(
entry.seeAlso.map((s) => s.name),
["sortie", "barre", "point"],
);
assert.ok(matchesSearch(entry, "camp"));
assert.ok(matchesSearch(entry, "HOME"));
assert.ok(matchesSearch(entry, "fleches sortir"));
assert.ok(!matchesSearch(entry, "videau"));
});
test("l'aide lit chaque fiche de l'index du glossaire", () => {
const files = parseGlossaryIndex(read("docs/glossaire/README.md"));
assert.ok(files.includes("videau.md"));
@@ -167,10 +231,13 @@ test("l'aide lit chaque fiche de l'index du glossaire", () => {
assert.ok(entry !== null, file);
assert.ok(entry.brief.length > 10, file);
}
assert.deepEqual(parseGlossaryEntry(read("docs/glossaire/videau.md")), {
term: "Videau",
brief: "le dé à doubler, marqué 2, 4, 8, 16, 32 et 64 : il indique la valeur actuelle de la partie.",
});
const videau = parseGlossaryEntry(read("docs/glossaire/videau.md"));
assert.equal(videau?.term, "Videau");
assert.equal(videau?.english, "Cube");
assert.equal(
videau?.brief,
"le dé à doubler, marqué 2, 4, 8, 16, 32 et 64 : il indique la valeur actuelle de la partie.",
);
assert.equal(parseGlossaryEntry("pas une fiche"), null);
});
@@ -106,3 +106,47 @@ clés `localStorage` de l'application (`ustagammon.settings`,
d'événements parmi les formats durables : toute modification passe par une
montée de version avec migration testée, jamais par une réécriture qui
perdrait les parties des joueurs.
### 2026-11-09 — Version 2 des événements : niveau et match
Avec le choix du niveau et les matchs (étape 1c), le format des événements
passe à la version 2 (`EVENT_VERSION`, `app/src/domain/game/events.ts`) :
- `GameStarted` porte le niveau de l'ordinateur (`level`, un niveau de gnubg)
et, pour une partie de match, le match à son début (`match` : longueur,
score, partie Crawford) ;
- `GameEnded` porte, dans un match, le match après la partie (score, fin du
match, partie Crawford suivante), tel que le moteur l'a calculé.
Chaque partie d'un match est une partie à part entière, avec son journal et
son enregistrement ; la suivante reprend le match que la précédente a laissé.
La migration (`migrateJournal`, testée dans `app/tests/game.test.ts`) passe un
journal de la version 1 à la version 2 au moment de le reprendre : le niveau,
implicite, devient explicite (2 plis, le seul qui existait) ; rien d'autre ne
change, une partie de la version 1 étant une partie libre. Le schéma IndexedDB
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.
+45
View File
@@ -85,3 +85,48 @@ unique pour tous les écrans, avant sa première mise en œuvre.
- Une adresse inconnue mène à la partie en cours avec un message clair.
- L'adresse de l'écran courant est la source de vérité de la navigation :
l'application l'écoute (`hashchange`) et dessine l'écran correspondant.
### 2026-11-09 — Adresses du tutoriel et de l'installation
Ajoutées avec l'accueil et le tutoriel (étape 0e.5), dans le même schéma :
| Écran | Adresse |
| --- | --- |
| Écran « à vous » d'une étape du tutoriel | `#/tutoriel/<n>/a-vous` |
| Fin du tutoriel | `#/tutoriel/fin` |
| Marche à suivre pour installer l'application | `#/installer` |
Lancée sans adresse, l'application ouvre l'accueil (`#/accueil`) ; `#/` reste
la partie en cours, pour que les liens existants ne changent pas.
### 2026-11-09 — Adresse de l'écran « Nouvelle partie »
Ajoutée avec le choix de la partie et le match (étape 1c), dans le même
schéma :
| Écran | Adresse |
| --- | --- |
| Nouvelle partie : type de partie, niveau, conseil du maître | `#/nouvelle-partie` |
Le menu, l'accueil et « Nouvelle partie » en fin de partie y mènent. « Commencer »
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).
+22 -13
View File
@@ -19,8 +19,8 @@ Repris du cadrage (modules, écrans clés, IA générative en V1) et des ADR 001
| --- | --- | --- |
| 1a. Moteur pour l'analyse (fait) | Niveaux de jeu (plis et bruit de GNU Backgammon : débutant à expert), classement des coups d'un jet avec équités, analyse d'un coup joué et d'une décision de videau, chance d'un jet ; exposés par `engine.h`, `engine-cli` et le Worker, avec positions de référence comparées à gnubg de bureau | — |
| 1b. Parties enregistrées (fait) | Stockage IndexedDB derrière l'interface de stockage existante : plusieurs parties, reprise automatique, branches (« Rejouer d'ici » crée une branche, l'originale reste intacte et rechargeable), export SGF avec variantes | — |
| 1c. Match et réglages | Partie libre ou match en N points, règle Crawford, règle Jacoby, choix du niveau de l'ordinateur, conseil du maître activable ou non ; écran de nouvelle partie | 1a, 1b |
| 1d. Conseil du maître | Après chaque coup du joueur, jamais avant (ADR 0011) : appréciation du moteur (bon coup, imprécision, erreur, gaffe) avec mot, icône et couleur, phrase du maître fondée sur les chiffres du moteur, coup recommandé montré sur demande ; aussi pour les décisions de videau ; calcul en arrière-plan sans retarder le jeu | 1a |
| 1c. Match et réglages (fait) | Partie libre ou match en N points, règle Crawford, règle Jacoby, choix du niveau de l'ordinateur, conseil du maître activable ou non ; écran de nouvelle partie | 1a, 1b |
| 1d. Conseil du maître (fait) | Après chaque coup du joueur, jamais avant (ADR 0011) : appréciation du moteur (bon coup, imprécision, erreur, grosse erreur) avec mot, icône et couleur, phrase du maître fondée sur les chiffres du moteur, coup recommandé montré sur demande ; aussi pour les décisions de videau ; calcul en arrière-plan sans retarder le jeu | 1a |
| 1e. Fin de partie et analyse | Bilan (ADR 0011) : résultat, coups par appréciation, taux d'erreur comparé à la chance, « le coup à revoir » ; écran d'analyse : graphe d'équité (toucher un point ouvre la position), liste des erreurs filtrable, visualiseur pas à pas coup joué contre meilleur coup, « Rejouer d'ici » | 1a, 1b, 1d |
| 1f. Prompts à copier | Gabarits versionnés et testés (`docs/prompts/` ou équivalent, source unique), bouton « Demander à une IA » sur le plateau, le conseil du maître, l'analyse et la fin de partie ; le moteur décide, l'IA explique | 1d, 1e |
| 1g. Accueil, tutoriel et leçons | Accueil (reprendre, nouvelle partie, prochaine leçon, progression résumée), tutoriel des règles interactif et illustré (0e.5), cinq premières leçons numérotées (ADR 0011) avec positions et « à vous de jouer », progression locale ; contenus CC BY-SA 4.0 en brouillon, à relire par un joueur fort | 0e.5, 1a |
@@ -36,21 +36,30 @@ Repris du cadrage (modules, écrans clés, IA générative en V1) et des ADR 001
ton des phrases du maître est encourageant ; l'espace qu'il occupe est
réservé dans la mise en page (revue UX 0e.2), pas en fenêtre surgissante.
## Questions pour Pierre (ne bloquent pas le démarrage)
## Décisions de Pierre (2026-11-09)
- **Indice** : le cadrage le liste dans le module Jeu, l'ADR 0011 l'a laissé
« à concevoir séparément ». Il n'est pas dans l'étape 1 sans décision.
- **Questionnaire de premier lancement** (choix du parcours) : le parcours 1
seul existe en V1 ; proposé : un simple choix « Je découvre » / « Je connais
les règles » qui oriente vers le tutoriel ou la partie.
- **Indice avant le coup** : pas en V1 (« l'idée est bonne »), à reprendre en
V1.5 ou V2. Le conseil du maître après le coup reste le seul mécanisme de
l'étape 1.
- **Premier lancement** : un choix « Je découvre » / « Je connais les règles »
(fait en 0e.5).
- **Seuils des appréciations** : ceux de GNU Backgammon (douteux 0,03,
mauvais 0,06, très mauvais 0,12 d'équité, `arSkillLevel`), exposés par le
moteur depuis 1a ; à calibrer sur de vraies parties.
- **Relecture des leçons** par un joueur fort de l'Atelier du Huit.
mauvais 0,06, très mauvais 0,12, `arSkillLevel`), exposés par le moteur
depuis 1a ; à recalibrer après les retours de Pierre sur de vraies parties.
Le niveau le plus grave se dit « Grosse erreur », pas « gaffe ».
- **Leçons** : pas de relecture préalable par un joueur fort ; elles sont
livrées telles quelles et améliorées sur retours d'experts. Garde-fous
maintenus : chaque réponse attendue est le meilleur coup du moteur, vérifié
par un test, et le texte n'affirme rien que le moteur ne confirme.
- **Plein écran par défaut dans l'app installée** : non (pas de
`display_override` fullscreen) ; la barre d'état reste visible en portrait.
- **Validés** : le texte du tutoriel et toutes les maquettes du canevas
(accueil, installation, tutoriel, aide, écrans 1c à 1i), ainsi que le cadre
des phrases du maître (section « Les phrases du maître » de
`docs/identite/charte/ecrans-etape-1.md`).
## Ordre de réalisation
1a et 1b d'abord (fondations, sans conflit avec l'interface), en parallèle des
sous-étapes 0e. Puis 1c, 1d, 1e, 1f dans cet ordre ; 1g après la validation des
maquettes d'accueil et de tutoriel par Pierre ; 1h et 1i ; 1j en dernier. Chaque
sous-étapes 0e. Puis 1c, 1d, 1e, 1f dans cet ordre ; 1g (maquettes validées par Pierre) ; 1h et 1i ; 1j en dernier. Chaque
sous-étape a son plan dans `docs/plans/` et sa merge request.
+1 -2
View File
@@ -23,14 +23,13 @@ Demandes de Pierre du 2026-11-03, traitées avant d'ouvrir l'étape 1.
| Sous-étape | Contenu | État | Plan |
| --- | --- | --- | --- |
| 0e.5 | Page d'accueil, mise à jour de l'app en un toucher, installation en un toucher (« Ajouter à l'écran d'accueil », proposée par l'app), tutoriel des règles très didactique avec plateaux illustrés, aide réécrite sur un ton plus humain ; textes à valider par Pierre | ⏳ | `plans/2026-11-03-accueil-tutoriel-aide.md` |
## Étapes suivantes
| Étape | Contenu | État |
| --- | --- | --- |
| 1. V1 Jouer et comprendre | Détail et ordre dans `docs/etape-1.md` : moteur pour l'analyse, parties enregistrées et branches, match et réglages, conseil du maître, fin de partie et analyse, prompts, accueil, tutoriel et leçons, progression, plateau soigné, plan de test | 🔄 |
| 2. V1.5 Apprendre | Parcours 2, exercices, progression | ⏳ |
| 2. V1.5 Apprendre | Parcours 2, exercices, progression ; idée retenue par Pierre : un indice avant le coup (sans la réponse) | ⏳ |
| 3. V2 Perfectionnement et IA | Parcours 3 et 4, rollouts, révision espacée | ⏳ |
Chaque étape reçoit son fichier `docs/etape-N.md` avant de démarrer.
+2
View File
@@ -40,8 +40,10 @@ exportée. `make test` vérifie le lien dans les deux sens : chaque fiche avec
| [Barre](barre.md) | Bar |
| [Blot](blot.md) | Blot |
| [Branche](branche.md) | Branch |
| [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 |
+12
View File
@@ -0,0 +1,12 @@
---
term: Camp
english: Home board
---
# Camp
**En bref** : les six dernières flèches d'un joueur, avant sa sortie : il y rassemble ses pions pour les sortir, et un pion adverse frappé doit y rentrer.
Votre camp est en bas à droite (vos flèches 1 à 6) ; celui de l'ordinateur,
en haut à droite (vos flèches 19 à 24), où rentrent vos pions frappés.
Voir aussi : [Sortie](sortie.md), [Barre](barre.md), [Point](point.md)
+18
View File
@@ -0,0 +1,18 @@
---
term: Conseil du maître
english: Advice
code: Advice
---
# Conseil du maître
**En bref** : après chacun de vos coups et chacune de vos décisions de videau, une ligne qui donne l'appréciation du moteur et ce que le maître aurait joué.
Le maître ne parle jamais avant votre coup : il commente ce que vous venez de
jouer, avec un mot (bon coup, imprécision, erreur, grosse erreur) choisi
d'après l'[équité](equite.md) perdue par rapport au meilleur choix (voir
[Appréciation](appreciation.md)). Toucher la ligne ouvre l'explication : votre
coup et celui du maître, joués sur le plateau, et les chiffres du
[moteur](moteur.md) si vous les demandez. Le conseil se coupe dans le menu ou
sur l'écran « Nouvelle partie ».
Voir aussi : [Appréciation](appreciation.md), [Coup](coup.md), [Videau](videau.md)
+8 -1
View File
@@ -1,9 +1,16 @@
---
term: Match
english: Match
code: Match
---
# Match
**En bref** : suite de parties jouée jusqu'à ce qu'un joueur atteigne un nombre de points fixé à l'avance, par exemple 5 points.
Voir aussi : [Partie](partie.md), [Règle de Crawford](regle-de-crawford.md)
Chaque partie rapporte ses points à son gagnant : 1 pour une partie simple,
2 pour un gammon, 3 pour un backgammon, multipliés par la valeur du videau.
Le premier qui atteint la longueur du match le gagne. Dans Ustagammon, un
match se joue en 3, 5, 7 ou 11 points ; le score, la fin du match et la
partie Crawford sont calculés par GNU Backgammon.
Voir aussi : [Partie](partie.md), [Règle de Crawford](regle-de-crawford.md), [Identifiant de match](identifiant-de-match.md)
+15
View File
@@ -15,4 +15,19 @@ la position seule : dans la même position, avec le même jet, l'ordinateur
joue toujours le même coup. Les niveaux suivants, sans bruit, réfléchissent
de 0 à 4 plis.
L'écran « Nouvelle partie » en propose six :
| Nom montré | Niveau de GNU Backgammon |
| --- | --- |
| Débutant | beginner (0 pli, bruit 0,060) |
| Amateur | casual play (0 pli, bruit 0,050) |
| Intermédiaire | intermediate (0 pli, bruit 0,040) |
| Avancé | advanced (0 pli, bruit 0,015) |
| Expert | 0 pli sans bruit, l'ancien « expert » de gnubg |
| Champion | 2 plis, l'ancien « world class » de gnubg |
Champion est le plus fort qui joue sans attente sur le téléphone ; 3 et
4 plis ne sont pas proposés. L'ordinateur double, prend et passe comme celui
de GNU Backgammon, au même niveau.
Voir aussi : [Pli](pli.md), [Moteur](moteur.md)
+8
View File
@@ -6,4 +6,12 @@ english: Crawford rule
**En bref** : en match, dans la partie qui suit le moment où un joueur arrive à un point de la victoire, personne ne peut doubler.
Cette partie s'appelle la partie Crawford. Elle protège celui qui arrive
au bord de la victoire : sans elle, son adversaire doublerait aussitôt,
puisqu'il n'a plus rien à perdre. Après la partie Crawford, le videau
revient, et celui qui est mené peut de nouveau doubler. La règle ne joue
qu'une fois par match ; si les deux joueurs arrivent à un point de la
victoire, il n'y a pas de partie Crawford. Dans Ustagammon, elle s'applique
toujours aux matchs, comme dans GNU Backgammon.
Voir aussi : [Match](match.md), [Doubler](doubler.md)
+6 -1
View File
@@ -6,4 +6,9 @@ english: Jacoby rule
**En bref** : en partie libre, hors match, un gammon ou un backgammon ne compte que si le videau a été utilisé.
Voir aussi : [Gammon](gammon.md), [Videau](videau.md)
Tant que personne n'a doublé, un gammon ou un backgammon ne rapporte qu'un
point. La règle encourage à doubler une position forte plutôt qu'à jouer le
gammon sans risque. Elle s'applique toujours aux parties libres d'Ustagammon,
jamais aux matchs.
Voir aussi : [Gammon](gammon.md), [Videau](videau.md), [Match](match.md)
+1
View File
@@ -14,6 +14,7 @@ Décisions : ADR 0010. Fichiers du logo et des icônes : `../logo/`.
| Fichier | Contenu |
| --- | --- |
| `guide-de-marque.md` | Règles d'usage : ton, couleurs, typographie, formes, mouvement, icônes, composants |
| `ecrans-etape-1.md` | Écrans de l'étape 1, lot par lot : nouvelle partie et match (1c), conseil du maître (1d), bilan et analyse (1e), IA (1f), leçons (1g), progression (1h), gestes (1i) |
| `accueil-tutoriel-aide.md` | Accueil, installation sans intrusion, tutoriel des règles (forme et première version du texte), aide en questions |
| `recette-ux.md` | Critères de recette design de l'écran de jeu, à cocher sur les captures et sur le téléphone |
| `interaction.md` | Comportement : réponse immédiate, annuler plutôt que confirmer, mouvement, vibrations, clavier, mises en page de l'écran de jeu (portrait, paysage, ordinateur), pictogrammes |
@@ -67,7 +67,8 @@ interrompre.
deux ou trois phrases. Une étape sur deux finit par un écran **« à vous »**
où le joueur fait le geste sur le vrai plateau, contrôlé par le moteur.
- En haut : une barre de progression en huit segments (`accent` pour les
étapes faites) et « Passer » à droite. En bas : « Retour » et
étapes faites) et « Quitter » à droite (pas « Passer » : ce mot appartient au
videau, et le tutoriel enseigne les mots exacts du jeu). En bas : « Retour » et
« Continuer ».
- Les positions sont données par leurs identifiants gnubg (ADR 0013) ; le
plateau est le composant de l'app, non jouable sauf sur les écrans
@@ -113,9 +114,10 @@ interrompre.
flèche 6, un 1 un pion de la flèche 1. Touchez le pion, puis la sortie à
droite. » *À vous* : sortir deux pions avec 6-3.
7. **Gammon et backgammon.** « Une partie gagnée vaut 1 point. Si
l'adversaire n'a sorti aucun pion quand vous finissez, c'est un gammon :
2 points. S'il lui reste en plus un pion sur la barre ou dans votre camp,
c'est un backgammon : 3 points. »
l'adversaire n'a sorti aucun pion, c'est un gammon : 2 points ; s'il lui
reste en plus un pion sur la barre ou dans votre camp, c'est un
backgammon : 3 points. En partie libre, gammon et backgammon ne comptent
que si le videau a été doublé : c'est la règle de Jacoby. »
8. **Le videau.** « Le cube aux faces 2, 4, 8, 16, 32 et 64 sert à miser.
Avant de lancer vos dés, vous pouvez proposer de doubler la valeur de la
partie. L'ordinateur prend, et la partie continue en valant le double, ou il
+253
View File
@@ -0,0 +1,253 @@
# Écrans de l'étape 1
Règles de design des écrans de l'étape 1 (`docs/etape-1.md`), lot par lot.
Maquettes : canevas https://claude.ai/artifact/CQw6vbDpi6XQqVWQa4sANe, rangées
« Étape 1 ». Comportement général : `interaction.md` ; liens : ADR 0013.
## 1c. Nouvelle partie et match
### Écran « Nouvelle partie » (`#/nouvelle-partie`)
Ouvert depuis le menu, l'accueil et la fin de partie. Une seule page, sans
étapes, de haut en bas :
1. **Type de partie** : un sélecteur à deux segments, « Partie libre » et
« Match » (fond `surface-sunken`, segment choisi en `surface` avec
`shadow-raised`).
- **Match** : « Le premier à [3] [5] [7] [11] points », quatre boutons
carrés de 44 px, le choisi en brique. Dessous, une phrase sur la règle de
Crawford, avec le lien vers sa fiche du glossaire.
- **Partie libre** : une phrase sur la règle de Jacoby, avec le lien vers
sa fiche. Pas d'interrupteur pour Crawford ni Jacoby : ce sont les règles
usuelles, et le joueur débutant n'a pas à les choisir.
2. **Niveau de l'ordinateur** : six barres de hauteur croissante, à toucher
(Débutant, Amateur, Intermédiaire, Avancé, Expert, Champion) ; les barres
jusqu'au niveau choisi sont en brique, le niveau choisi porte l'anneau de
focus. Dessous, le nom en `heading` et une phrase qui le décrit en mots de
joueur, jamais en plis ni en bruit :
- Débutant : « Joue simplement et laisse passer des occasions. Idéal pour
vos premières parties. »
- Amateur : « Un joueur qui connaît les règles et commet encore des
erreurs. »
- Intermédiaire : « Un habitué de la table : solide, sans être
implacable. »
- Avancé : « Un bon joueur de club. Peu d'erreurs de pions, un videau
sérieux. »
- Expert : « Très peu d'erreurs. Chaque point se gagne. »
- Champion : « GNU Backgammon à son meilleur niveau sur ce téléphone. »
3. **Pendant la partie** : interrupteur « Conseil du maître », sous-titre
« Une ligne après chacun de vos coups ».
4. **Barre d'action collée en bas** : un seul bouton principal, qui dit ce
qu'il lance (« Commencer la partie » ou « Commencer le match en 5
points »), et la mention « Vos choix sont gardés pour la prochaine fois. ».
Par défaut : partie libre, Intermédiaire, conseil activé. Fermer (croix en
haut à gauche, ou retour du téléphone) ne change rien à la partie en cours.
Commencer une nouvelle partie alors qu'une partie est en cours suit la règle
« annuler plutôt que confirmer » (bandeau « Reprendre l'ancienne »).
### Score de match sur l'écran de jeu
- **Portrait** : une ligne `overline` centrée sous la barre haute, « Match en
5 points », complétée par « · partie Crawford » pendant la partie Crawford ;
dans chaque bandeau, une pastille carrée (contour `border-strong`, chiffre
gras) avec le score de ce joueur, juste avant son compte de points.
- **Paysage** : dans la colonne de commandes, sous le menu, une case qui
empile le score de l'ordinateur, « sur 5 », puis le vôtre (même ordre que
les bandeaux : l'ordinateur en haut, vous en bas).
- Le score se lit aussi au lecteur d'écran : « Match en 5 points :
l'ordinateur 4, vous 2 ».
- Pendant la partie Crawford, le videau n'est pas affiché : il n'existe pas
pour cette partie.
## 1d. Conseil du maître
### La ligne du maître
Maquette « Portrait révisé : conseil du maître après le coup ». Une ligne de
44 px, réservée sous le bandeau du joueur (elle ne pousse rien) :
- icône d'appréciation, puis le **mot en couleur** suivi d'un point
(« Bon coup. »), puis la **phrase du maître en italique**, sur une ligne,
tronquée par des points de suspension si elle est trop longue, et un
chevron ;
- elle apparaît **après** la validation du coup du joueur (ou sa décision de
videau), dès que l'analyse est prête, en fondu de `duration-fast` ; elle
s'efface à l'action suivante du joueur (lancer les dés, toucher un pion) ;
- **pendant que le joueur réfléchit, rien n'apparaît** ; si l'analyse arrive
trop tard (le joueur a déjà relancé), la ligne n'apparaît pas, et le coup
reste commenté dans le bilan ;
- conseil désactivé : la ligne reste réservée et vide (la mise en page ne
bouge pas).
### Les mots
| Seuil gnubg (écart d'équité) | Mot | Couleur | Icône |
| --- | --- | --- | --- |
| meilleur coup, ou moins de 0,03 | Bon coup | `verdict-good` | coche |
| 0,03 à 0,06 (doubtful) | Imprécision | `verdict-doubtful` | point d'interrogation |
| 0,06 à 0,12 (bad) | Erreur | `verdict-bad` | croix |
| plus de 0,12 (very bad) | Grosse erreur | `verdict-bad` | croix |
Pour le videau : « Bon choix » et « Bonne décision » remplacent « Bon coup ».
« Grosse erreur » est la recommandation de design ; `docs/etape-1.md` écrit
« gaffe », et le choix final revient à Pierre (ton).
### La feuille d'explication
Maquette « Feuille d'explication du maître ». Toucher la ligne ouvre une
feuille qui monte du bas (`duration-moderate`), au-dessus du plateau voilé :
1. le mot d'appréciation et le contexte (« Erreur · votre 3-1 d'ouverture ») ;
2. le titre : le coup du maître, à la première personne (« J'aurais joué
8/5 6/5 ») ; pour un bon coup : « C'est aussi le coup que j'aurais joué » ;
3. la phrase du maître, en italique ;
4. un sélecteur à deux segments, « Votre coup » et « Le coup du maître », au-
dessus d'un plateau qui montre la position d'avant le coup ; le coup choisi
se joue pion par pion (`duration-fast`), avec un tracé du départ à
l'arrivée : `text` pour votre coup, `verdict-good` pour celui du maître ;
5. « Voir les chiffres du moteur » (repliée par défaut) : le classement des
coups avec leur écart d'équité et le niveau d'évaluation ; votre coup est en
gras ;
6. « Demander à une IA » (1f) et « Fermer ».
Le lien vers la fiche d'un concept du glossaire n'apparaît que si le moteur
permet de le déterminer, sinon rien.
### Les phrases du maître
- **Le maître parle à la première personne et vouvoie** : « J'aurais joué… »,
jamais « Vous auriez dû… », jamais « Il fallait… ».
- **Il ne cite que des faits** : le coup recommandé, le rang de votre coup,
les pions frappés, les points faits ou laissés (lisibles dans le coup
lui-même), l'écart d'équité sur demande. Aucune explication stratégique
inventée (« la case la plus précieuse », « trop risqué ») : celles-là
viendront des leçons, relues par un joueur fort.
- **Trois parties au plus** : ce que vous avez fait, ce que le maître préfère,
un fait qui les distingue. Moins de 140 caractères pour la ligne ; la
feuille peut aller jusqu'à deux phrases.
- **Ton** : encourageant et sobre. Pas de point d'exclamation, pas d'émoji, pas
de superlatif. Les bons coups ont plusieurs formules qui tournent
(« C'est le coup du maître. », « Exactement ce que j'aurais joué. »,
« Bien vu. »), pour ne pas lasser.
- **Exemples** (gabarits à remplir avec les données du moteur) :
- Bon coup : « Bien vu. 8/5 6/5 fait votre point 5. »
- Imprécision : « J'aurais joué {meilleur}. Votre coup était le
{rang}e choix, tout près. »
- Erreur : « J'aurais joué {meilleur}. Votre {joué} était le {rang}e
choix. »
- Videau, prise juste : « Bonne décision : prendre était le bon choix. »
- Videau, double manqué : « Ici, j'aurais doublé. »
## 1e. Bilan et analyse
### Bilan (`#/parties/<id>/bilan`)
Maquette « Bilan de fin de partie ». Il remplace le panneau de fin de partie
quand l'analyse est prête ; avant, le panneau actuel s'affiche et le bilan le
remplace en place, sans saut.
1. `overline` « Fin de partie », titre `display` (« Vous gagnez », « L'ordinateur
gagne un gammon »), puis une phrase : le résultat en points et une
lecture simple du jeu et des dés (« Votre jeu a fait la différence, avec
des dés un peu contre vous. »), construite à partir du taux d'erreur et de
la chance donnés par le moteur, jamais inventée.
2. Trois cases côte à côte : le nombre de bons coups, d'imprécisions et
d'erreurs (grosses erreurs comprises), chiffre en 28 px, mot et icône en
couleur d'appréciation.
3. Deux jauges sur une ligne chacune :
- « Votre jeu » : le taux d'erreur traduit en niveau (correspondance avec
les classements de gnubg documentée, mêmes noms que les niveaux de
l'ordinateur), jauge en `verdict-good` ;
- « Les dés » : la chance, jauge centrée (à droite si elle vous a aidé, à
gauche sinon), en `border-strong`, avec trois mots possibles : « pour
vous », « équilibrés », « contre vous » (« un peu » ou « nettement »
selon l'écart).
Lien « Comment lire ces mesures » vers les fiches du glossaire.
4. « Le coup à revoir » : la plus grosse erreur, mini-plateau, mot
d'appréciation, coup joué et coup du maître ; « Revoir ce coup » (ouvre
l'analyse sur ce coup) et « Demander à une IA ». Sans erreur ni
imprécision : la carte dit « Aucun coup à revoir : vous avez joué comme le
maître. »
5. Barre d'actions : « Voir l'analyse » et « Nouvelle partie » (principale).
### Analyse (`#/parties/<id>/analyse/<coup>`)
Maquette interactive « Analyse ». Une page qui défile, de haut en bas :
1. **Courbe de vos chances de gagner**, coup après coup, en pourcentage (pas
en équité : un débutant lit « 41 % », pas « −0,18 »). Tracé `text` de 2 px,
aire `accent-soft`, ligne pointillée à 50 %. Les coups commentés sont des
points de couleur d'appréciation, les autres de petits points `text`.
**On parcourt la courbe du doigt** : chaque coup possède une bande verticale
sur toute la hauteur du graphique, qui sert de cible tactile ; le coup
choisi porte un trait vertical brique et un point cerclé.
2. **Visualiseur** : « ‹ Coup 7 sur 24 · 6-1 › », le plateau de la position
d'avant le coup, la phrase du maître, le sélecteur « Votre coup » / « Le
coup du maître » de la feuille 1d, puis « Rejouer d'ici » (crée une
branche, ADR 0011 et 1b) et « Demander à une IA ».
3. **Les coups à revoir** : filtres en pastilles de 44 px (« Tout »,
« Erreurs », « Imprécisions », avec leurs nombres), puis une ligne par
coup : numéro, dés, coup joué, mot d'appréciation. Toucher une ligne place
le visualiseur et la courbe sur ce coup.
Sur ordinateur, `←` et `→` passent d'un coup à l'autre.
## 1f. Demander à une IA
Le bouton est secondaire partout, jamais l'action principale, avec l'icône
de copie : dans la feuille du maître (1d), dans la carte « Le coup à
revoir » du bilan, dans le visualiseur de l'analyse, et dans une fiche du
glossaire. Après la copie, le bandeau « Prompt copié » (`verdict-good`, 2 s),
puis le texte du prompt reste lisible dans une zone `surface-sunken`
repliable, pour qui veut le relire avant de le coller (composant `AskAI`).
## 1g. Leçons (`#/lecons`, `#/lecons/<n>/<écran>`)
Maquette « Leçons ». En tête, un rappel discret « Les règles du jeu ·
revoir » (le tutoriel de 0e.5). Puis les cinq leçons numérotées, dans
l'ordre, chacune avec une pastille ronde à son numéro :
- terminée : pastille `verdict-good`, « Terminée » ;
- en cours : carte bordée de brique, pastille brique, « 2 exercices sur 4 »,
barre de progression, bouton « Continuer » (seule action principale de
l'écran) ;
- à commencer : pastille au contour `border-strong`.
Toutes les leçons restent ouvertes : l'ordre est conseillé, jamais imposé.
Les écrans d'une leçon suivent la forme du tutoriel (barre de progression,
« Passer », « Retour », « Continuer », écrans « à vous » contrôlés par le
moteur).
## 1h. Progression (`#/progression`)
Maquette « Progression ».
- Une phrase de synthèse, factuelle (« Sur vos 8 dernières parties, vous
faites moins d'erreurs : votre taux d'erreur est passé de 14 à 7. »). Si la
tendance monte, la phrase reste encourageante et sans reproche (« Votre
taux d'erreur remonte un peu ces derniers jours. Les leçons sont là
pour reprendre un point. »).
- La courbe du taux d'erreur par partie terminée, avec le rappel « plus bas,
c'est mieux » dans son titre, le dernier point en `verdict-good`, et un lien
vers la fiche « taux d'erreur ».
- La liste des parties (résultat, date, niveau de l'ordinateur, taux d'erreur
en chiffre gras aligné à droite), chacune ouvrant son bilan.
- Moins de trois parties analysées : pas de courbe, une phrase « Votre
courbe apparaîtra après trois parties. ».
## 1i. Gestes sur le plateau
- **Glisser-déposer** : le pion suit le doigt, agrandi à 115 % et décalé de
12 px vers le haut pour rester visible sous le doigt ; à son départ, un pion
fantôme en pointillés ; les cases d'arrivée permises gardent leur voile et
leur anneau (révision UX). Lâché sur une case permise : il s'y pose
(`duration-fast`, vibration « pion posé ») ; lâché ailleurs : il revient à
sa place en `duration-fast`, sans vibration ni message.
- **Double toucher** : joue le coup évident quand il existe (un seul coup légal
complet commence par ce pion, ou le moteur n'en propose qu'un) ; sinon, il
vaut un simple toucher, sans message d'erreur.
- **Toucher simple** : inchangé (pion puis case, ou avance directe s'il n'a
qu'une case d'arrivée).
- Les trois gestes coexistent ; aucun n'est expliqué par une fenêtre : le
tutoriel et l'aide en parlent.
+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
+35
View File
@@ -163,3 +163,38 @@ Une entrée datée par session : ce qui a été fait, décidé, et la suite.
Playwright (412×915, 915×412, 844×320, clair et sombre) avant l'essai de
Pierre sur le téléphone.
- Réglage en main des vibrations sur le Fairphone 4.
## 2026-11-03 → 2026-11-09 : retours 0e et début de l'étape 1
**Fait**
- Retours du premier test sur téléphone, traités et publiés :
- saisie du coup (MR !28) ;
- révision UX arbitrée par la session « identité » (MR !35) ;
- parties enregistrées avec branches et export SGF, sous-étape 1b (MR !37).
- Fusionnés :
- adresses par écran et liens de position, sous-étape 0e.4 (MR !43). L'ADR 0013 est amendé : une adresse par écran, de vrais liens ;
- moteur pour l'analyse et les niveaux, sous-étape 1a (MR !32) : 9 niveaux de gnubg, analyse de coup et de videau, chance d'un jet, concordance avec gnubg de bureau à 1,64e-3 près.
- Décisions techniques :
- le cache d'évaluation de gnubg est vidé à chaque calcul, pour que les résultats ne dépendent que de leurs arguments ;
- le schéma IndexedDB est un format durable (amendement de l'ADR 0007) ;
- l'image Docker de test est nommée par worktree, ce qui corrige l'instabilité des tests concurrents.
- Plans écrits pour toute l'étape 1, de 1c à 1i. La session « identité » a maquetté tous les écrans (MR !40 à !42).
- Décisions et validations de Pierre (2026-11-09), consignées dans `docs/etape-1.md` :
- « Grosse erreur » plutôt que « gaffe » ;
- pas d'indice en V1 ;
- pas de relecture préalable des leçons ;
- pas de plein écran par défaut ;
- seuils de gnubg ;
- validés : le texte du tutoriel, les maquettes et le cadre des phrases du maître.
- Nouvelles demandes de Pierre, intégrées aux plans :
- une adresse pour chaque écran ;
- la mise à jour forcée de l'app (0e.5) ;
- un coach bienveillant, mis en avant mais discret.
**Reste à faire**
- 0e.5 (MR !44) : appliquer les corrections de recette T1 à T5, puis fusionner et publier.
- Publication bloquée : `RESTART.sh`, un fichier local non suivi par git, fait échouer `make lint`, et donc `make deploy-prod`.
- Étape 1 :
- 1c en cours ;
- puis 1d, 1e, 1f, 1g, 1h et 1i ;
- enfin le plan de test 1j.
+233
View File
@@ -0,0 +1,233 @@
# Les phrases du maître
Les mots et les phrases du conseil du maître (étape 1d), en une seule source
(ADR 0008) : `make site` copie ce dossier dans le site publié
(`docs/maitre/`), l'application le lit à l'exécution
(`app/src/domain/analysis/phrases.ts`), et `make test` rend chaque gabarit
sur de vraies analyses du moteur (`app/tests/advice.test.ts`).
Texte sous licence **CC BY-SA 4.0**. Version 1, rédigée par Claude Code
d'après la charte (`docs/identite/charte/ecrans-etape-1.md`, « Les phrases du
maître »), **à relire** par la session « identité » puis par Pierre.
## Règles
- Le maître parle à la première personne et vouvoie : « J'aurais joué… »,
jamais « Vous auriez dû… ».
- Il ne cite que des faits donnés par le moteur : le coup recommandé, le rang
du coup joué, le nombre de coups possibles, les pions frappés et les points
faits (lus sur les plateaux décodés par le moteur), la décision de videau
qu'il préfère. Aucune explication stratégique.
- La ligne (le mot et la phrase) fait moins de 140 caractères ; pas de point
d'exclamation, pas d'émoji, pas de superlatif.
- Quand une section a plusieurs gabarits, ils tournent d'un coup à l'autre
(l'identifiant de l'événement choisit), pour ne pas lasser.
## Format
Une section `## clé` par emploi, suivie d'un gabarit par ligne (`- …`). Les
mots entre accolades sont remplis avec les données du moteur :
| Repère | Ce qu'il devient |
| --- | --- |
| `{best}` | le coup du maître, en notation de gnubg (« 8/5 6/5 ») |
| `{played}` | votre coup, même notation |
| `{move}` | le coup dont parle le fait (le vôtre ou celui du maître) |
| `{rank}` | le rang de votre coup dans le classement du moteur (2 ou 3 seulement) |
| `{points}` | « votre point 5 » ou « vos points 5 et 7 » |
| `{roll}` | le jet, « 3-1 » |
| `{hits}` | le nombre de pions frappés, en chiffres |
Un fait (sections `fait.…`) s'ajoute à la phrase seulement si la ligne reste
sous 140 caractères.
Tout ce qui précède la première section `## mot.…` n'est pas lu.
## mot.coup.bon
- Bon coup
## mot.videau.bon
- Bonne décision
## mot.douteux
- Imprécision
## mot.mauvais
- Erreur
## mot.tres-mauvais
- Grosse erreur
## coup.meilleur
- C'est le coup du maître.
- Exactement ce que j'aurais joué.
- Bien vu.
- C'est aussi mon choix.
## coup.seul
- C'était le seul coup possible.
## coup.proche
« Bon coup » sans être le meilleur, votre coup étant le 2e ou le 3e choix du
moteur. Le rang n'est cité qu'ici et dans coup.douteux : il mesure mal la
gravité d'une erreur (un 2e choix peut être loin du premier), et le
classement complet est dans « Voir les chiffres du moteur ».
- Tout près : j'aurais joué {best}, votre coup était le {rank}e choix.
## coup.douteux
Imprécision, votre coup étant le 2e ou le 3e choix.
- J'aurais joué {best}. Votre coup était le {rank}e choix, tout près.
## coup.loin
« Bon coup » non meilleur ou imprécision, votre coup étant au-delà du 3e
choix : le rang n'est pas cité.
- J'aurais joué {best}.
## coup.mauvais
Erreur : le rang n'est jamais cité.
- J'aurais joué {best}.
## coup.tres-mauvais
Grosse erreur : le rang n'est jamais cité.
- J'aurais joué {best}.
## fait.frappe
- {move} frappe un pion.
## fait.frappe-plusieurs
- {move} frappe {hits} pions.
## fait.point
- {move} fait {points}.
## fait.frappe-et-point
- {move} frappe un pion et fait {points}.
## fait.maitre-frappe
- Mon coup frappe un pion.
## fait.maitre-point
- Mon coup fait {points}.
## fait.joueur-frappe
- Votre coup frappe un pion, le mien non.
## videau.bon.double
- Doubler était le bon choix.
## videau.bon.pas-de-double
- Ne pas doubler était le bon choix.
## videau.bon.prendre
- Prendre était le bon choix.
## videau.bon.passer
- Passer était le bon choix.
## videau.proche.double
- Les deux choix étaient très proches. J'aurais doublé.
## videau.proche.pas-de-double
- Les deux choix étaient très proches. Je n'aurais pas doublé.
## videau.proche.prendre
- Les deux choix étaient très proches. J'aurais pris.
## videau.proche.passer
- Les deux choix étaient très proches. J'aurais passé.
## videau.autre.double
- Ici, j'aurais doublé.
## videau.autre.pas-de-double
- Ici, je n'aurais pas doublé.
## videau.autre.prendre
- Ici, j'aurais pris.
## videau.autre.passer
- Ici, j'aurais passé.
## titre.coup.meme
- C'est aussi le coup que j'aurais joué
## titre.coup.autre
- J'aurais joué {best}
## titre.videau.meme
- C'est aussi ce que j'aurais fait
## titre.videau.double
- J'aurais doublé
## titre.videau.pas-de-double
- Je n'aurais pas doublé
## titre.videau.prendre
- J'aurais pris
## titre.videau.passer
- J'aurais passé
## contexte.coup
- votre {roll}
## contexte.coup.ouverture
- votre {roll} d'ouverture
## contexte.videau.double
- votre double
## contexte.videau.pas-de-double
- avant votre jet
## contexte.videau.reponse
- le double de l'ordinateur
@@ -1,76 +0,0 @@
# Accueil, installation, tutoriel des règles, aide (0e.5)
- **Date** : 2026-11-03
- **Étape** : 0e.5 (feuille de route), qui couvre aussi le tutoriel de 1g
- **Statut** : prévu (après la fusion de 0e.4)
- **Exécutant** : sous-agent general-purpose, dans un worktree
## Objectif
Traiter les points 2 et 3 du message de Pierre du 2026-11-03 et sa demande d'installation en un toucher :
- une page d'accueil ;
- l'installation proposée par l'app ;
- un tutoriel des règles très didactique, illustré par le plateau ;
- une aide plus humaine.
## Références
- Règles : `docs/identite/charte/accueil-tutoriel-aide.md`. Le texte du tutoriel y figure en première version, à faire valider par Pierre.
- Maquettes : https://claude.ai/artifact/CQw6vbDpi6XQqVWQa4sANe, rangée « Étape 0e.5 » et artboard « Accueil ».
- ADR 0008 (source unique), ADR 0013 (positions par identifiants gnubg).
## Consignes
1. **Accueil**, selon la charte :
- au premier lancement, et une seule fois, le choix entre « Je découvre » et « Je connais les règles » ;
- ensuite : la partie en cours, la carte d'installation, la prochaine leçon et la progression résumée. La leçon et la progression restent en attente tant que 1g et 1h n'existent pas : n'afficher que ce qui existe.
2. **Installation** :
- sous Chrome, garder l'invite reçue avec `beforeinstallprompt` ;
- sous Firefox et sur iPhone, une feuille en 3 étapes ;
- la carte n'apparaît qu'à partir de la première partie terminée. « Plus tard » la masque, et elle ne revient qu'une fois, après 3 parties. Le menu garde une ligne tant que l'app n'est pas installée ;
- plus rien en `display-mode: standalone` ni après l'événement `appinstalled`.
3. **Tutoriel**, en 8 étapes :
- le texte vit dans un fichier source unique sous `docs/` (par exemple `docs/tutoriel/`), copié dans le site par `make site` comme le glossaire, sous licence CC BY-SA 4.0 ;
- positions données par leurs identifiants gnubg ;
- sur les écrans « à vous », le moteur vérifie le coup joué en le comparant aux coups attendus de l'étape ;
- le coup illustré se joue seul après 1 s, avec « Revoir » ;
- un test vérifie que chaque position du tutoriel est valide pour le moteur et que chaque coup attendu est légal ;
- ajouter la fiche du glossaire « camp ».
4. **Aide**, en trois onglets :
- « Jouer » : les scénarios deviennent des questions repliables. Le titre est la question et la description la réponse ; les étapes Gherkin sont cachées. Reformuler les titres des `.feature` sans casser les tests ;
- « Les mots du jeu » : les cartes du glossaire, avec une recherche ;
- « À propos » : licence, crédits, version ;
- le tutoriel est proposé en tête de l'aide.
5. **Mise à jour de l'application** (demande de Pierre du 2026-11-09). Quand
le service worker trouve une nouvelle version, l'app affiche un bandeau
discret « Nouvelle version disponible » avec « Mettre à jour », jamais
pendant un coup en cours. Toucher le bouton active la nouvelle version
(`skipWaiting`, puis rechargement au `controllerchange`), et la partie
reprend où elle en était. Le menu propose aussi « Rechercher une mise à
jour » (`registration.update()`), qui dit « Vous avez la dernière
version » sinon. L'onglet « À propos » affiche la version publiée (le
commit de `version.json`). Aujourd'hui, `web/sw.js` appelle
`skipWaiting()` dès l'installation et efface l'ancien cache : une page
ouverte garde son ancien code et peut ne plus trouver les anciens
fichiers. Le nouveau service worker doit attendre l'accord du joueur, sauf
à la toute première installation.
6. **Scénarios Gherkin** : premier lancement, installation, suivre le tutoriel.
7. **`make test-browser`** :
- premier lancement, tutoriel complet avec ses écrans « à vous », aide ;
- mise à jour : publier deux versions du site de test l'une après
l'autre, voir le bandeau, mettre à jour, retrouver la partie ;
- `beforeinstallprompt` simulé dans Chromium ;
- captures dans `build/browser-shots/accueil-tutoriel/` pour la recette design.
Chaque écran (accueil, étapes du tutoriel, onglets et questions de l'aide) a son adresse et s'ouvre par un vrai lien, selon l'amendement de l'ADR 0013 ; le routeur vient de 0e.4.
`make lint`, `make test` et `make test-browser` doivent passer. Aucune nouvelle dépendance.
## Critères de réussite
- Un débutant suit le tutoriel jusqu'au bout sur le téléphone, et chaque écran « à vous » accepte le bon coup et refuse les autres.
- Les trois commandes passent, et les captures sont livrées pour la recette.
## Résultat
À remplir à la clôture.
@@ -1,60 +0,0 @@
# Conseil du maître (1d)
- **Date** : 2026-11-03
- **Étape** : 1d (`docs/etape-1.md`)
- **Statut** : prévu (après 1c)
- **Exécutant** : sous-agent general-purpose, dans un worktree
## Objectif
Le coach bienveillant, « mis en avant mais discret » (Pierre, 2026-11-03) :
après chaque coup et chaque décision de videau du joueur, jamais avant
(ADR 0011), une appréciation et une phrase du maître fondées sur les chiffres
du moteur.
## Références
- ADR 0011 ; maquette « conseil du maître après le coup » du canevas
https://claude.ai/artifact/CQw6vbDpi6XQqVWQa4sANe ; règles de la charte
(`docs/identite/charte/`, dont interaction.md) ; la ligne réservée de 44 px
de la révision UX.
- Moteur : `analyseMove`, `analyseCube`, `luck`, seuils `SKILL_THRESHOLDS`
(gnubg : 0,03 / 0,06 / 0,12).
## Consignes
1. **Analyse en arrière-plan** : après la validation d'un coup (ou une
décision de videau), l'analyse à 2 plis part dans le Worker sans retarder le
tour de l'ordinateur ; son résultat est rattaché au coup dans le journal
(événement d'analyse rétrocompatible, ou cache séparé justifié) pour servir
au bilan (1e) sans recalcul.
2. **Ligne du maître** : icône d'appréciation, mot en couleur (« Bon coup. »,
« Imprécision. », « Erreur. », « Gaffe. » : correspondance avec les seuils
de gnubg documentée), phrase courte en italique, chevron. Toucher ouvre la
feuille d'explication : coup joué et meilleur coup montrés sur le plateau
(pions animés, ADR 0011 « comparaison visuelle »), écart d'équité seulement
sur demande, lien vers la fiche du glossaire du concept en jeu si le moteur
permet de le déterminer, sinon rien.
3. **Phrases du maître** : gabarits versionnés en français dans une source
unique sous `docs/` (CC BY-SA 4.0), ton encourageant, jamais culpabilisant ;
elles ne citent que des faits du moteur (coup recommandé, rang, pions
frappés, écart) : aucune explication inventée. Première version à faire
valider par Pierre (ton).
4. **Discret** : rien n'apparaît pendant que le joueur réfléchit ; la ligne
s'efface à l'action suivante ; désactivable (réglage de 1c et menu).
5. **Videau** : même traitement pour doubler, ne pas doubler, prendre, passer.
6. Scénarios Gherkin (bon coup, erreur avec meilleur coup montré, conseil
désactivé, décision de videau commentée) avec positions et dés imposés ;
tests unitaires des gabarits (chaque gabarit rendu sur des analyses réelles
du moteur). `make lint`, `make test`, `make test-browser`, captures pour la
recette design.
## Critères de réussite
Après une erreur volontaire dans une position connue, la ligne du maître dit
« Erreur. », nomme le coup recommandé par le moteur et le montre sur demande ;
le tour de l'ordinateur n'est pas retardé ; les trois commandes passent.
## Résultat
À remplir à la clôture.
+1 -1
View File
@@ -18,7 +18,7 @@ Mettre en place les cinq premières leçons numérotées de l'ADR 0011. Chaque l
4. La course et le compte de points.
5. Sortir ses pions sans se faire frapper.
Chaque leçon compte deux à quatre exercices « à vous ». Leurs positions sont données par identifiants gnubg. La réponse attendue est le meilleur coup du moteur à 2 plis, vérifié par un test. Le texte n'affirme rien que le moteur ne confirme. Les contenus sont livrés en brouillon, marqués « à relire par un joueur fort » (cadrage, risque « contenus »), et Pierre les valide.
Chaque leçon compte deux à quatre exercices « à vous ». Leurs positions sont données par identifiants gnubg. La réponse attendue est le meilleur coup du moteur à 2 plis, vérifié par un test. Le texte n'affirme rien que le moteur ne confirme. Décision de Pierre du 2026-11-09 : pas de relecture préalable par un joueur fort ; les leçons sont livrées telles quelles et améliorées sur retours d'experts. Les garde-fous restent : chaque réponse attendue est le meilleur coup du moteur, vérifié par un test, et le texte n'affirme rien que le moteur ne confirme.
2. **Progression.** Elle est enregistrée dans le stockage de 1b, dans le magasin `settings` ou dans un nouveau magasin avec montée de version (ADR 0007 amendé). Chaque leçon a son état : à commencer, « 2 exercices sur 4 », terminée.
3. **Adresses** : `#/lecons` et `#/lecons/<n>/<écran>`, à ajouter au tableau de l'ADR 0013.
4. **Accueil.** Il reçoit la carte « prochaine leçon » prévue par la charte.
@@ -1,47 +0,0 @@
# Match, niveaux et réglages (1c)
- **Date** : 2026-11-03
- **Étape** : 1c (`docs/etape-1.md`)
- **Statut** : prévu (après la révision UX et 1b)
- **Exécutant** : sous-agent general-purpose, dans un worktree
## Objectif
Choisir sa partie : partie libre ou match en N points, avec les règles de
Crawford et de Jacoby, contre l'ordinateur au niveau voulu.
## Consignes
ADR 0007, 0008. Le moteur expose depuis 1a les niveaux de gnubg
(`levelMove`, `levelCubeDecision`) et accepte les matchs dans son API C.
1. **Domaine** : longueur de match, score, règle de Crawford (partie Crawford
et suivantes, calculées par le moteur via le Match ID), règle de Jacoby en
partie libre ; enchaînement des manches d'un match jusqu'à sa fin ; le
journal d'événements reste rétrocompatible (les parties existantes se
rechargent).
2. **Adaptateur** : `EngineCube` étendu au match (score, longueur, Crawford)
ou remplacé par le Match ID, sans casser les appels existants.
3. **Niveau de l'ordinateur** : ses coups et ses décisions de videau au niveau
choisi. Reprendre de `ComputerTurn` (gnubg, `play.c`) les règles de
déroulement non reprises en 1a : pré-filtre « fenêtre de marché » et double
facultatif à 0 pli, avec citation des sources.
4. **Écran de nouvelle partie** (maquettes de la session « identité » si
disponibles, sinon sobre et conforme à la charte) : type de partie, niveau
(noms montrés au joueur à proposer simplement : Débutant, Amateur,
Intermédiaire, Avancé, Expert, Champion — correspondance avec les niveaux
de gnubg documentée), conseil du maître activé ou non. Par défaut : partie
libre, Intermédiaire, conseil activé. Le réglage est mémorisé.
5. Affichage du score de match et de la partie Crawford sur le plateau.
6. Scénarios Gherkin (match en 3 points jusqu'au bout avec dés imposés,
Crawford, Jacoby) et fiches du glossaire (match, Crawford, Jacoby si
absentes). `make lint`, `make test`, `make test-browser` (un match court).
## Critères de réussite
Un match en 3 points se joue jusqu'au bout, au niveau choisi, avec Crawford
appliqué ; les trois commandes passent.
## Résultat
À remplir à la clôture.
+15 -1
View File
@@ -33,6 +33,15 @@ Fonctionnalité: <la tâche>
Et <autre constat>
```
Dans l'aide, chaque scénario devient une question repliable : son titre est
la question, formulée comme le joueur se la pose (« Comment jouer un
coup ? ») ; le paragraphe sous le titre est la réponse, qui doit se lire
seule ; les étapes ne s'affichent pas. Une étiquette
`@position:<Position ID>` au-dessus du scénario ajoute à la réponse le lien
« Voir sur le plateau ». L'adresse d'une question (`#/aide/<identifiant>`)
est tirée de son titre : changer un titre change son adresse, et les titres
cités par `app/src/ui/help-context.ts` sont vérifiés par `make test`.
Règles d'écriture :
- la deuxième personne (« vous ») et les mots du glossaire (`docs/glossaire/`) ;
@@ -47,6 +56,11 @@ Règles d'écriture :
| Fichier | Tâche | Depuis |
| --- | --- | --- |
| [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) | Étape 0e |
| [regler-l-application.feature](regler-l-application.feature) | Régler l'application (menu), la mettre à jour | Étape 0e |
| [decouvrir-l-application.feature](decouvrir-l-application.feature) | Premier lancement et accueil | Étape 0e.5 |
| [apprendre-les-regles.feature](apprendre-les-regles.feature) | Apprendre les règles avec le tutoriel | Étape 0e.5 |
| [installer-l-application.feature](installer-l-application.feature) | Installer l'application | Étape 0e.5 |
@@ -0,0 +1,42 @@
# language: fr
Fonctionnalité: Apprendre les règles avec le tutoriel
Le tutoriel explique les règles en huit petites étapes, sur le vrai
plateau : les pions bougent pour montrer chaque règle, et une étape sur
deux vous fait jouer le geste vous-même. GNU Backgammon vérifie chaque
coup, comme en partie.
Scénario: Comment se passe le tutoriel ?
Chaque étape tient en un écran court : un titre, le plateau, deux ou
trois phrases. Le coup montré se joue tout seul après une seconde ;
« Revoir » le rejoue. La barre du haut montre où vous en êtes et vous
ramène aux étapes déjà vues. « Quitter » vous emmène jouer.
Étant donné que vous commencez le tutoriel
Alors il compte huit étapes, dont quatre où c'est à vous de jouer
Et chaque position du tutoriel se lit sur le plateau
Quand vous arrivez à l'étape 3
Alors la barre de progression vous ramène aux étapes 1 et 2, mais pas aux suivantes
@position:4HPwATDgc/ABMA
Scénario: Je joue un autre coup que celui demandé
Sur les écrans « à vous », vous jouez sur le vrai plateau. Si votre coup
n'est pas celui demandé, le pion revient et une phrase vous dit
pourquoi ; réessayez autant que vous voulez. Le bon coup est salué, et
« Continuer » vous mène à la suite.
Étant donné que vous êtes à l'écran « à vous » de l'étape 3
Quand vous déplacez un pion de la flèche 24 à la flèche 21
Alors le pion revient, et une phrase vous dit pourquoi
Quand vous réunissez deux pions sur la flèche 5
Alors le tutoriel salue votre coup
Scénario: Comment répondre au double dans le tutoriel ?
À la dernière étape, l'ordinateur vous propose de doubler. Prenez ou
passez : le maître vous dit ensuite ce qu'il aurait fait, et pourquoi.
Étant donné que vous êtes à l'écran « à vous » de l'étape 8
Quand vous répondez « Passer »
Alors le maître dit qu'il aurait pris
Quand vous répondez « Prendre »
Alors le tutoriel salue votre réponse
@@ -0,0 +1,52 @@
# language: fr
Fonctionnalité: Comprendre mes coups avec le conseil du maître
Après chacun de vos coups, et après chacune de vos décisions de videau,
le maître dit en une ligne ce qu'il en pense : « Bon coup. »,
« Imprécision. », « Erreur. » ou « Grosse erreur. », puis une phrase,
par exemple le coup qu'il aurait joué. Il ne parle jamais avant que vous
ayez joué, et la ligne s'efface dès que vous lancez les dés ou touchez un
pion. Touchez-la pour ouvrir l'explication. Le maître, c'est GNU
Backgammon : il ne dit que ce que le programme a calculé.
Scénario: Que dit le maître après un bon coup ?
« Bon coup. », avec une phrase qui change d'une fois à l'autre et, s'il
y en a, ce que votre coup fait sur le plateau : un pion frappé, un point
fait. Avec 3-1 à l'ouverture, 8/5 6/5 est le meilleur coup.
Étant donné que vous avez 3-1 à jouer à la position de départ
Quand vous validez le coup 8/5 6/5
Alors le maître dit « Bon coup. »
Et sa phrase dit que 8/5 6/5 fait votre point 5
Scénario: Comment voir le coup que le maître aurait joué ?
Après une imprécision ou une erreur, la ligne nomme le coup du maître.
Touchez-la : l'explication montre, sur le plateau, votre coup et le coup
du maître joués pion par pion depuis la même position. Les chiffres,
repliés en bas, donnent l'écart entre les coups si vous les voulez.
Étant donné que vous avez 3-1 à jouer à la position de départ
Quand vous validez le coup 24/23 13/10
Alors le maître dit « Grosse erreur. »
Et sa phrase commence par « J'aurais joué 8/5 6/5. »
Et l'explication peut montrer votre coup et le coup du maître sur le plateau
Scénario: Comment faire taire le maître ?
Coupez « Conseil du maître » dans le menu, ou sur l'écran « Nouvelle
partie ». La ligne reste alors vide, et rien d'autre ne bouge. Vos coups
restent analysés pour le bilan de fin de partie.
Étant donné que vous avez coupé le conseil du maître
Et que vous avez 3-1 à jouer à la position de départ
Quand vous validez le coup 24/23 13/10
Alors le maître ne dit rien
Scénario: Le maître commente-t-il le videau ?
Oui : après votre double, votre réponse à un double de l'ordinateur, ou
quand vous avez lancé les dés alors qu'il aurait doublé, le maître dit
si vous avez bien fait.
Étant donné que l'ordinateur vous propose de doubler dans une position où il faut prendre
Quand vous passez son double
Alors le maître dit « Erreur. »
Et sa phrase dit « Ici, j'aurais pris. »
@@ -0,0 +1,31 @@
# language: fr
Fonctionnalité: Découvrir l'application
À la toute première ouverture, Ustagammon vous demande d'où vous partez,
puis s'efface : ensuite, il s'ouvre sur l'accueil, avec votre partie en
cours à reprendre d'un toucher.
@position:4HPwATDgc/ABMA
Scénario: Que me propose l'application à la première ouverture ?
Deux choix, et c'est tout : « Je découvre » ouvre le tutoriel des règles,
« Je connais les règles » lance une partie tout de suite. Ce choix ne
revient plus ; le tutoriel reste dans le menu.
Étant donné que vous ouvrez Ustagammon pour la toute première fois
Alors l'accueil vous propose de découvrir les règles ou de jouer tout de suite
Quand vous choisissez de découvrir les règles
Alors le tutoriel s'ouvre à sa première étape
Et ce choix ne vous est plus proposé
Scénario: Où retrouver ma partie en rouvrant l'application ?
L'application s'ouvre sur l'accueil. Votre partie en cours y est, avec
son plateau et les comptes de points : « Reprendre la partie » vous y
ramène. Sans partie en cours, « Nouvelle partie » en commence une.
Étant donné que vous avez choisi de jouer tout de suite
Et qu'une partie est en cours
Quand vous rouvrez Ustagammon
Alors l'accueil vous propose de reprendre votre partie
Quand la partie se termine
Et que vous rouvrez Ustagammon
Alors l'accueil vous propose une nouvelle partie
@@ -0,0 +1,46 @@
# language: fr
Fonctionnalité: Installer l'application
Installé, Ustagammon a son icône sur l'écran d'accueil du téléphone,
s'ouvre en plein écran et marche sans réseau. L'application vous le
propose au bon moment, une fois, sans jamais interrompre une partie.
Scénario: Comment installer l'application ?
Touchez « Installer » sur l'accueil, ou « Installer l'application » dans
le menu. Avec Chrome, le téléphone vous demande de confirmer. Avec
Firefox ou sur iPhone, l'application vous montre la marche à suivre, en
trois étapes.
Étant donné que votre navigateur ne propose pas lui-même l'installation
Quand vous demandez à installer l'application avec Firefox
Alors la marche à suivre compte trois étapes, en commençant par le menu du navigateur
Quand vous demandez à installer l'application sur iPhone
Alors la marche à suivre compte trois étapes, en commençant par le bouton de partage
Scénario: Quand l'application me propose-t-elle de l'installer ?
Jamais à la première ouverture ni pendant une partie : l'invitation
apparaît sur l'accueil après votre première partie terminée. « Plus
tard » la masque ; elle revient une seule fois, trois parties plus tard.
Le menu garde « Installer l'application » tant qu'elle n'est pas
installée.
Étant donné que vous ouvrez Ustagammon pour la toute première fois
Alors l'accueil ne vous propose pas d'installer l'application
Quand vous terminez une partie
Alors l'accueil vous propose d'installer l'application
Quand vous choisissez « Plus tard »
Alors l'accueil ne vous propose pas d'installer l'application
Mais le menu vous le propose toujours
Quand vous terminez 3 parties de plus
Alors l'accueil vous propose d'installer l'application
Quand vous choisissez « Plus tard »
Et que vous terminez 3 parties de plus
Alors l'accueil ne vous propose pas d'installer l'application
Scénario: Et une fois l'application installée ?
Une fois Ustagammon installé, plus rien ne vous propose de l'installer.
Étant donné que vous avez terminé une partie
Quand vous installez l'application
Alors l'accueil ne vous propose pas d'installer l'application
Et le menu non plus
+76
View File
@@ -0,0 +1,76 @@
# 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, 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.
Scénario: Comment choisir le niveau de l'ordinateur ?
Six niveaux, du Débutant, qui laisse passer des occasions, au Champion,
GNU Backgammon à son meilleur niveau sur ce téléphone. La première fois,
c'est une partie libre contre un ordinateur Intermédiaire, avec le
conseil du maître. L'ordinateur garde le niveau choisi toute la partie,
même si vous fermez l'application.
Étant donné que vous ouvrez l'écran « Nouvelle partie » pour la première fois
Alors une partie libre contre un ordinateur Intermédiaire, avec le conseil du maître, est proposée
Quand vous choisissez le niveau Champion et commencez la partie
Alors l'ordinateur joue au niveau 2 plis de GNU Backgammon
Et la partie reprise garde ce niveau
Et vos choix sont proposés à la prochaine nouvelle partie
Scénario: Comment jouer un match ?
Un match se joue en plusieurs parties : chacune rapporte ses points au
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. 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
Alors le match se termine dès qu'un joueur atteint 3 points
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
règle de Crawford ; l'écran l'annonce par « partie Crawford ». Dans les
parties d'après, celui qui est mené peut de nouveau doubler.
Étant donné qu'un match en 3 points commence et que l'ordinateur va gagner un gammon
Quand l'ordinateur gagne la partie
Alors l'ordinateur mène le match 2 à 0
Et la partie suivante est la partie Crawford
Quand la partie Crawford commence
Alors personne ne peut doubler pendant cette partie
Quand vous gagnez la partie Crawford
Alors vous pouvez de nouveau doubler dans la partie suivante
Scénario: Pourquoi mon gammon ne m'a-t-il rapporté qu'un point ?
En partie libre, la règle de Jacoby s'applique : tant que personne n'a
doublé, un gammon ou un backgammon ne rapporte qu'un point. Elle
encourage à doubler plutôt qu'à jouer le gammon sans risque. En match,
elle ne s'applique pas : le gammon compte toujours double.
Étant donné que vous allez gagner un gammon sans que personne ait doublé
Quand vous gagnez une partie libre
Alors vous marquez 1 point
Quand vous gagnez la même partie dans un match
Alors vous marquez 2 points
+48 -23
View File
@@ -1,16 +1,22 @@
# language: fr
Fonctionnalité: Jouer une partie contre l'ordinateur
Vous jouez une partie complète contre l'ordinateur, qui s'appuie sur le
moteur GNU Backgammon. L'application tire les dés, n'accepte que les coups
Vous jouez une partie complète contre l'ordinateur, qui s'appuie sur
GNU Backgammon. L'application tire les dés, n'accepte que les coups
permis par les règles, et l'ordinateur joue et décide du videau tout seul.
Scénario: Commencer une partie
@position:4HPwATDgc/ABMA
Scénario: Comment commence une partie ?
Les pions sont en position de départ. Chaque camp lance un dé : celui
qui a le plus fort commence, et joue ces deux dés. En cas d'égalité, on
relance.
Quand vous commencez une nouvelle partie
Alors les pions sont en position de départ
Et le premier jet de dés désigne qui commence
Scénario: Jouer un coup permis
@position:4HPwATDgc/ABMA
Scénario: Comment jouer un coup ?
Touchez un pion, puis sa case d'arrivée ; s'il n'en a qu'une, il y va dès
que vous le touchez. Chaque dé joué s'estompe. Une fois tous vos dés
joués, votre coup attend que vous le validiez : touchez vos dés, d'un côté
@@ -24,16 +30,17 @@ Fonctionnalité: Jouer une partie contre l'ordinateur
Alors votre coup est accepté
Et c'est ensuite au tour de l'ordinateur
Scénario: Un pion qui n'a qu'une case d'arrivée
Après 8/5, il ne vous reste que le 1 : chaque pion jouable n'a plus
qu'une case d'arrivée, et il y va dès que vous le touchez.
Scénario: Pourquoi mon pion part-il dès que je le touche ?
Un pion qui n'a qu'une case d'arrivée possible y va dès que vous le
touchez, sans attendre un second toucher. Par exemple, avec 3-1, une fois
8/5 joué, il ne vous reste que le 1.
Étant donné que c'est à vous de jouer avec le jet 3-1
Quand vous jouez 8/5
Alors seul votre 3 est marqué comme joué
Et chacun de vos pions jouables n'a plus qu'une case d'arrivée
Scénario: Reprendre un pion avant de valider
Scénario: Je me suis trompé de pion
Tant que vous n'avez pas validé votre coup, « Annuler » reprend le
dernier pion déplacé ; touchez-le encore pour reprendre le précédent.
@@ -45,21 +52,27 @@ Fonctionnalité: Jouer une partie contre l'ordinateur
Alors la position n'a pas changé
Et aucun de vos dés n'est marqué comme joué
Scénario: Un coup interdit est refusé
La case 12 est tenue par l'ordinateur, qui y a cinq pions : vous ne
pouvez pas vous y poser.
@position:4HPwATDgc/ABMA
Scénario: Pourquoi je ne peux pas poser mon pion sur cette flèche ?
Une flèche où l'ordinateur a au moins deux pions est fermée : vous ne
pouvez pas vous y poser. Au départ, par exemple, la flèche 12 est tenue
par cinq de ses pions.
Étant donné que c'est à vous de jouer avec le jet 3-1
Quand vous essayez de jouer 13/12
Alors votre coup est refusé
Et la position n'a pas changé
Scénario: L'ordinateur joue
Scénario: Comment joue l'ordinateur ?
L'ordinateur lance ses dés et joue le coup que GNU Backgammon, l'un des
meilleurs programmes de backgammon, juge le meilleur. Il répond en
moins de deux secondes.
Étant donné que c'est au tour de l'ordinateur
Quand ses dés sont tirés
Alors il joue le coup recommandé par le moteur en moins de 2 secondes
Scénario: Aucun coup possible avec votre jet
Scénario: Je ne peux rien jouer avec mes dés
Il arrive que les dés ne permettent de bouger aucun pion. Votre jet reste
affiché, barré, avec un message. L'application ne joue jamais à votre
place : le tour ne passe à l'ordinateur que quand vous touchez vos dés
@@ -72,7 +85,7 @@ Fonctionnalité: Jouer une partie contre l'ordinateur
Quand vous laissez passer votre tour
Alors c'est ensuite au tour de l'ordinateur
Scénario: Voir le coup de l'ordinateur
Scénario: Qu'a joué l'ordinateur ?
Après chaque coup de l'ordinateur, ses pions glissent un par un sur le
plateau. Restent ensuite, jusqu'à votre prochaine action, une flèche par
pion déplacé, ses pions arrivés entourés et un pion fantôme là où ils
@@ -84,11 +97,13 @@ Fonctionnalité: Jouer une partie contre l'ordinateur
Et votre pion frappé part sur la barre
Et vous pouvez revoir son coup
Scénario: Voir le compte de points
Au-dessus et au-dessous du plateau, le bandeau de chaque camp donne son
compte de points, et « mène de 7 » s'affiche du côté de celui qui mène la
course. Touchez un compte pour lire ce qu'il veut dire. En paysage, les
comptes sont aux deux bouts de la barre.
Scénario: À quoi sert le compte de points ?
Le compte de points dit combien de cases vos pions doivent encore
parcourir avant de sortir : le plus petit mène la course. Au-dessus et
au-dessous du plateau, le bandeau de chaque camp donne son compte, et
« mène de 7 » s'affiche du côté de celui qui mène. Touchez un compte pour
lire ce qu'il veut dire. En paysage, les comptes sont aux deux bouts de
la barre.
Quand vous commencez une nouvelle partie
Alors chaque camp a un compte de points de 167
@@ -96,7 +111,7 @@ Fonctionnalité: Jouer une partie contre l'ordinateur
Quand vous jouez 13/8 13/11
Alors vous menez la course de 7
Scénario: Doubler, après confirmation
Scénario: Comment doubler ?
Avant de lancer les dés, vous pouvez doubler en touchant le videau ou
« Doubler ». L'application vous demande de confirmer : une fois proposé,
un double ne se reprend pas.
@@ -109,19 +124,29 @@ Fonctionnalité: Jouer une partie contre l'ordinateur
Et que vous confirmez
Alors l'ordinateur prend ou passe, comme le recommande le moteur
Scénario: Prendre le double de l'ordinateur
Scénario: L'ordinateur double : que se passe-t-il si je prends ?
Si vous prenez, la partie continue et vaut deux fois plus. Le videau
passe de votre côté : vous seul pourrez redoubler.
Étant donné que l'ordinateur vous propose de doubler
Quand vous prenez
Alors la partie continue à valeur doublée
Et le videau est de votre côté : vous seul pourrez redoubler
Scénario: Passer le double de l'ordinateur
Scénario: L'ordinateur double : que se passe-t-il si je passe ?
Passer, c'est abandonner la partie tout de suite : vous la perdez à sa
valeur d'avant le double.
Étant donné que l'ordinateur vous propose de doubler
Quand vous passez
Alors la partie s'arrête
Et vous perdez la partie à sa valeur d'avant le double
Scénario: Finir la partie
Scénario: Comment se termine une partie ?
La partie s'arrête dès qu'un joueur a sorti tous ses pions. Le panneau de
fin dit qui gagne, et combien de points : la valeur du videau, doublée
pour un gammon et triplée pour un backgammon, une fois le videau doublé.
Étant donné qu'un joueur a sorti tous ses pions
Alors la partie s'arrête
Et le résultat indique le gagnant et les points gagnés
+11 -3
View File
@@ -7,7 +7,11 @@ Fonctionnalité: Retrouver mes parties
une, vous passez d'une branche à l'autre, ou vous l'exportez au format SGF
pour l'ouvrir dans GNU Backgammon.
Scénario: Reprendre une partie parmi plusieurs
Scénario: Comment reprendre une autre partie ?
Toutes vos parties restent sur le téléphone. Dans « Mes parties », depuis
le menu, la dernière jouée vient en premier : « Reprendre » vous ramène à
une partie telle que vous l'aviez laissée.
Étant donné que vous avez joué deux parties, l'une après l'autre
Quand vous fermez l'application puis la rouvrez
Alors la dernière partie jouée est à l'écran
@@ -15,7 +19,7 @@ Fonctionnalité: Retrouver mes parties
Quand vous reprenez la première partie
Alors elle est à l'écran, telle que vous l'aviez laissée
Scénario: Rejouer d'ici sans perdre l'original
Scénario: Puis-je rejouer un moment de la partie autrement ?
Revenez à un moment passé de la partie et jouez autre chose : une
nouvelle branche commence là, et la partie d'origine reste entière.
@@ -28,7 +32,11 @@ Fonctionnalité: Retrouver mes parties
Alors vous êtes sur la nouvelle branche
Et vous pouvez revenir à la partie d'origine, au coup près
Scénario: Exporter une partie
Scénario: Comment garder une partie dans un fichier ?
Dans « Mes parties », « Exporter (SGF) » enregistre la partie dans un
fichier que GNU Backgammon sait ouvrir ; les suites rejouées y sont des
variantes.
Étant donné que vous avez joué plusieurs coups
Et que vous avez rejoué d'ici une autre suite
Quand vous exportez la partie
+3 -3
View File
@@ -6,7 +6,7 @@ Fonctionnalité: Partager une position
question. Le lien contient les deux identifiants de GNU Backgammon, celui
de la position et celui du match, et rien d'autre.
Scénario: Partager la position
Scénario: Comment partager une position ?
Pendant la partie, « Copier le lien de la position », dans le menu ou à
côté du dernier coup de l'ordinateur, copie l'adresse de la position à
l'écran. Ouvert ailleurs, le lien montre la même position.
@@ -17,7 +17,7 @@ Fonctionnalité: Partager une position
Quand vous ouvrez ce lien
Alors vous voyez exactement la même position, avec le même videau
Scénario: Ouvrir un lien vers une position
Scénario: On m'a envoyé le lien d'une position
Un lien vers une position montre le plateau, sans le rendre jouable :
vous y êtes en bas, avec la main. « Jouer à partir d'ici » commence une
nouvelle partie contre l'ordinateur depuis cette position, et votre
@@ -33,7 +33,7 @@ Fonctionnalité: Partager une position
Et le videau est à 2, chez l'ordinateur
Et votre partie précédente est toujours enregistrée
Scénario: Ouvrir un lien abîmé
Scénario: Le lien d'une position ne s'ouvre pas
Un lien coupé ou mal recopié ne casse rien : un message le dit, et
« Retour à ma partie » reste là.
+23 -4
View File
@@ -7,7 +7,7 @@ Fonctionnalité: Régler l'application
l'écran allumé pendant la partie. Rien de ce qu'il contient ne vous pose de
question : ce qui se fait se rattrape.
Scénario: Commencer une nouvelle partie, puis reprendre l'ancienne
Scénario: J'ai commencé une nouvelle partie par erreur
Une nouvelle partie commence tout de suite, sans question. Pendant 10
secondes, le bandeau « Reprendre l'ancienne » vous rend la partie que
vous venez de quitter, exactement là où vous l'aviez laissée.
@@ -19,7 +19,7 @@ Fonctionnalité: Régler l'application
Quand vous reprenez l'ancienne partie
Alors vous retrouvez exactement la partie quittée
Scénario: Couper les vibrations
Scénario: Comment couper les vibrations ?
Le téléphone vibre brièvement quand votre pion se pose, quand un pion est
frappé, quand l'ordinateur vous propose de doubler et à la fin de la
partie. Ces vibrations accompagnent toujours ce que montre l'écran ; rien
@@ -32,7 +32,7 @@ Fonctionnalité: Régler l'application
Et qu'un pion est frappé
Alors le téléphone ne vibre pas
Scénario: Garder l'écran allumé pendant la partie
Scénario: Pourquoi l'écran ne se met-il pas en veille ?
Pour que le téléphone ne se mette pas en veille pendant que vous
réfléchissez, l'écran reste allumé tant qu'une partie est en cours.
@@ -41,8 +41,27 @@ Fonctionnalité: Régler l'application
Quand vous coupez « Écran allumé pendant la partie » dans le menu
Alors l'écran peut se mettre en veille comme d'habitude
Scénario: Choisir le thème
Scénario: Comment passer en thème sombre ?
Le thème suit d'abord celui du téléphone. Dans le menu, « Thème » passe à
clair, puis à sombre, puis revient à automatique.
Étant donné que vous ouvrez l'application pour la première fois
Alors le thème suit celui du téléphone
Quand vous changez de thème dans le menu
Alors il passe à clair, puis à sombre, puis revient à automatique
Scénario: Comment mettre l'application à jour ?
Dès qu'une nouvelle version d'Ustagammon est publiée, l'application la
télécharge d'elle-même, puis affiche en bas de l'écran « Nouvelle
version prête. », seulement entre deux tours, jamais pendant que vous
jouez un coup. « Mettre à jour » l'installe et vous ramène où vous en
étiez. Dans le menu, « Rechercher une mise à
jour » vérifie tout de suite s'il y en a une.
Étant donné qu'une nouvelle version d'Ustagammon est disponible
Et que vous avez des dés à jouer
Alors l'application ne vous propose pas encore de mettre à jour
Quand vous déplacez un pion de votre coup
Alors l'application ne vous propose pas encore de mettre à jour
Quand vous avez joué votre coup
Alors l'application vous propose de mettre à jour
+65
View File
@@ -0,0 +1,65 @@
# Tutoriel des règles
Le texte du tutoriel de l'application, en une seule source (ADR 0008) :
`regles.md`. `make site` copie ce dossier dans le site publié
(`docs/tutoriel/`) ; l'application le lit à l'exécution
(`app/src/ui/tutorial-text.ts`), et `make test` vérifie chaque position et
chaque coup avec le vrai moteur (`app/tests/tutorial.test.ts`).
Texte sous licence **CC BY-SA 4.0**. Première version, rédigée par Claude
Code d'après la charte (`docs/identite/charte/accueil-tutoriel-aide.md`),
**à valider par Pierre**.
## Format
Tout ce qui précède la première étape (ce titre, cette notice) n'est pas
affiché.
```markdown
## 3. Les dés et les doubles
- position: 4HPwATDgc/ABMA
- jet: 5-2
- coup: 13/11 13/8
Un paragraphe, avec un [mot du glossaire](pion.md) et du **gras**.
### À vous
- position: 4HPwATDgc/ABMA
- jet: 3-1
- attendu: 8/5 6/5
- juste: La phrase affichée quand le coup est juste.
- refus: La phrase affichée quand le coup est refusé.
La consigne de l'écran.
## Fin
- action: Jouer ma première partie
Le texte de la fin du tutoriel.
```
- `## <n>. <titre>` commence l'étape *n* (huit au plus, dans l'ordre) ;
`### À vous` commence son écran « à vous », facultatif ; `## Fin`, l'écran
qui suit la dernière étape.
- Les lignes `- clé: valeur` en tête d'écran le décrivent :
- `position` : l'identifiant de position de GNU Backgammon (ADR 0013), vu
du camp au trait ;
- `trait` : `ordinateur` si c'est l'ordinateur qui a la main dans cette
position (par défaut, vous) ;
- `jet` et `coup` : sur un écran d'explication, le coup illustré, qui se
joue tout seul (notation de gnubg) ;
- `chemin: oui` : le sens du jeu, dessiné en pointillés sur le plateau ;
- `attendu` (une ligne par coup accepté) : sur un écran « à vous », les
coups justes, en notation de gnubg, tels que le moteur les écrit ;
- `réponse` : `prendre` ou `passer`, pour un écran « à vous » où
l'ordinateur double ; le moteur doit donner la même réponse ;
- `juste` et `refus` : les phrases qui suivent un geste juste, ou refusé ;
- `action` : le libellé du bouton de la fin.
- Les autres lignes forment les paragraphes. `[mot](fiche.md)` renvoie à une
fiche du glossaire, à la première apparition du mot.
Aucune règle n'est écrite dans le code : le moteur lit les positions,
illustre les coups, juge les coups joués et les décisions de videau.
+128
View File
@@ -0,0 +1,128 @@
# Les règles du backgammon
Tutoriel d'Ustagammon, en huit étapes. Texte sous licence CC BY-SA 4.0,
validé par Pierre le 9 novembre 2026. Format : voir README.md.
## 1. Le but du jeu
- position: 4HPwATDgc/ABMA
Vous avez quinze [pions](pion.md) ivoire, l'ordinateur quinze pions noyer. Chacun fait faire le tour du [plateau](plateau.md) à ses pions, puis les [sort](sortie.md).
Le premier qui a sorti tous les siens gagne la [partie](partie.md).
## 2. Dans quel sens avancer
- position: 4HPwATDgc/ABMA
- chemin: oui
Vos pions ivoire font le tour du plateau dans le sens des pointillés : ils partent d'en haut à droite, passent à gauche, puis reviennent en bas jusqu'aux **six dernières flèches**, en bas à droite. C'est là que vous les rassemblerez avant de les sortir.
Les pions noyer de l'ordinateur font le même chemin, en sens inverse.
## 3. Les dés et les doubles
- position: 4HPwATDgc/ABMA
- jet: 5-2
- coup: 13/11 13/8
Chaque dé déplace un pion d'autant de flèches. Avec 5 et 2, vous jouez un pion de 5 et un pion de 2, ou le même pion de 5 puis de 2.
Un double se joue quatre fois : avec 4-4, vous avancez quatre fois de 4.
### À vous
- position: 4HPwATDgc/ABMA
- jet: 3-1
- attendu: 8/5 6/5
- juste: Deux pions sur la flèche 5 : elle est à vous, l'ordinateur ne peut plus s'y poser.
- refus: Ce coup est permis, mais il ne réunit pas deux pions sur la flèche 5. Jouez un pion de la flèche 8 avec le 3, et un de la flèche 6 avec le 1.
Vous avez 3 et 1. Réunissez deux pions sur la flèche 5 : touchez un pion, puis sa case d'arrivée.
## 4. Frapper un pion seul
- position: 4HPwESDgc/ABMA
- jet: 4-2
- coup: 13/11 13/9*
Un pion seul sur une flèche s'appelle un [blot](blot.md). Si vous vous posez dessus, vous le frappez : il part sur la [barre](barre.md) et devra refaire tout le chemin.
Une flèche où l'adversaire a deux pions ou plus est fermée : vous ne pouvez pas vous y poser.
### À vous
- position: 4HPwIDDgc/ABMA
- jet: 6-1
- attendu: 13/7* 8/7
- attendu: 13/7*/6
- attendu: 13/7* 6/5
- attendu: 24/23 13/7*
- attendu: 24/18 8/7*
- attendu: 8/7* 8/2
- juste: Touché : le pion noyer part sur la barre, il devra refaire tout le chemin.
- refus: Ce coup ne frappe pas le pion seul de la flèche 7. Un pion qui s'y pose le frappe : celui de la flèche 13 avec le 6, ou celui de la flèche 8 avec le 1.
Vous avez 6 et 1. Le pion noyer de la flèche 7 est seul : frappez-le.
## 5. Rentrer depuis la barre
- position: 4HPwATDgc/ABUA
- jet: 4-3
- coup: bar/22 13/9
Votre pion frappé attend sur la barre, au milieu. Avant de jouer quoi que ce soit d'autre, il doit rentrer dans le [camp](camp.md) adverse, en haut à droite.
Chaque dé indique une flèche d'entrée : avec 3 et 4, la 22 ou la 21. Si les deux flèches sont fermées, votre tour passe.
## 6. Sortir ses pions
- position: bHcAANh2dwAAAA
- jet: 5-2
- coup: 5/off 2/off
Quand vos quinze pions sont dans les six dernières flèches, vous pouvez commencer à les sortir : un 6 sort un pion de la flèche 6, un 1 un pion de la flèche 1.
Touchez le pion, puis la sortie à droite.
### À vous
- position: bHcAANh2dwAAAA
- jet: 6-3
- attendu: 6/off 3/off
- juste: Deux pions sortis. Plus que treize !
- refus: Ce coup est permis, mais il ne sort qu'un pion. Le 6 sort un pion de la flèche 6, le 3 un pion de la flèche 3.
Vous avez 6 et 3 : sortez deux pions.
## 7. Gammon et backgammon
- position: 8J4HBwAFAAAAAA
- jet: 2-1
- coup: 2/off 1/off
Une partie gagnée vaut 1 point. Si l'adversaire n'a sorti aucun pion, c'est un [gammon](gammon.md) : 2 points ; s'il lui reste en plus un pion sur la barre ou dans votre camp, c'est un [backgammon](backgammon.md) : 3 points. En partie libre, gammon et backgammon ne comptent que si le videau a été doublé : c'est la [règle de Jacoby](regle-de-jacoby.md).
## 8. Le videau
- position: 4HPwATDgc/ABMA
Le cube aux faces 2, 4, 8, 16, 32 et 64 s'appelle le [videau](videau.md) : il sert à miser. Avant de lancer vos dés, vous pouvez proposer de [doubler](doubler.md) la valeur de la partie.
L'ordinateur [prend](prendre.md), et la partie continue en valant le double, ou il [passe](passer.md), et vous gagnez tout de suite. Celui qui a pris garde le cube : lui seul pourra redoubler.
### À vous
- position: 7O7YAADs7jYAAA
- trait: ordinateur
- réponse: prendre
- juste: Le maître prend aussi. L'ordinateur mène la course, mais vous gagnez encore assez souvent pour que prendre vous coûte moins cher, en moyenne, que passer.
- refus: Le maître prendrait. Passer vous coûte un point tout de suite ; en prenant, vous gardez vos chances de gagner, et le videau est chez vous.
C'est à l'ordinateur de jouer, et il mène la course. Il vous propose de doubler : prenez-vous, ou passez-vous ?
## Fin
- action: Jouer ma première partie
Vous connaissez les règles. Le reste s'apprend en jouant, et le maître vous accompagne.
+116 -18
View File
@@ -19,9 +19,19 @@
* vérifie des demi-coups saisis un par
* un (ex. « 8/5 6/5 », « bar/22 »,
* « 6/off »)
* matchid VIDEAU PROPRIÉTAIRE Match ID d'une partie libre avec la
* règle Jacoby, joueur 0 au trait ;
* matchid VIDEAU PROPRIÉTAIRE [LONGUEUR SCORE0 SCORE1 CRAWFORD]
* Match ID joueur 0 au trait : partie
* libre avec la règle Jacoby, ou match
* en LONGUEUR points (CRAWFORD : 1
* pour la partie Crawford, sinon 0) ;
* PROPRIÉTAIRE : -1 (centre), 0 ou 1
* double POSITION_ID MATCH_ID (avec --level) l'ordinateur au trait
* double-t-il ? (ComputerTurn de gnubg)
* take POSITION_ID MATCH_ID (avec --level) l'ordinateur prend-il
* le double du joueur au trait ?
* aftergame MATCH_ID GAGNANT POINTS le match après une partie : score,
* fin du match, partie Crawford
* suivante
* reference POSITION_ID MATCH_ID [DÉS]
* un niveau au format des positions de
* référence (tests/reference/README.md) :
@@ -42,8 +52,9 @@
*
* --ply : niveau de gnubg, de 0 (défaut) à 4 (meilleur coup comme « hint »,
* analyses comme l'analyse de partie de gnubg).
* --level : niveau de jeu de l'ordinateur (voir levels), pour bestmove, moves
* et cube : coups classés comme l'ordinateur de gnubg les joue, bruit compris.
* --level : niveau de jeu de l'ordinateur (voir levels), pour bestmove, moves,
* cube, double et take : coups classés et videau comme l'ordinateur de gnubg
* les joue, bruit compris.
* --data : dossier des données ; par défaut « data » à côté du programme.
*
* En WebAssembly (engine-wasm/), le même programme sert d'interface : le
@@ -71,13 +82,13 @@
/* Liste des commandes, pour les messages d'erreur. */
#define COMMANDS "bestmove, moves, legal, cube, eval, position, halfmoves, matchid, reference, " \
"levels, analysemove, analysecube ou luck"
"levels, analysemove, analysecube, luck, double, take ou aftergame"
/* Coups gardés dans le classement de la commande reference (comme hint). */
#define REFERENCE_MAX_MOVES 10
/* Arguments de la ligne de commande, une fois lus. */
#define MAX_ARGS 4
#define MAX_ARGS 6
typedef struct {
int ply;
@@ -125,6 +136,8 @@ status_code(engine_status status)
return "invalid_move";
case ENGINE_ERROR_CUBE_ACTION:
return "invalid_cube_action";
case ENGINE_ERROR_RESULT:
return "invalid_result";
}
return "unknown_error";
}
@@ -475,24 +488,45 @@ run_half_moves_command(const options *opt)
return EXIT_SUCCESS;
}
/* matchid : Match ID d'une partie libre avec la règle Jacoby, joueur 0 au
* trait. */
/* Lit un entier ; renvoie 0 si le texte n'en est pas un. */
static int
parse_int(const char *text, int *value)
{
char *end;
*value = (int) strtol(text, &end, 10);
return !*end && end != text;
}
/* matchid : Match ID joueur 0 au trait, sans dés. Partie libre avec la
* règle Jacoby, ou match (LONGUEUR SCORE0 SCORE1 CRAWFORD, ce dernier à 0
* ou 1), sans Jacoby. */
static int
run_match_id_command(const options *opt)
{
engine_match match = { .cube_value = 1, .cube_owner = -1, .jacoby = 1 };
char match_id[13];
char *end;
engine_status status;
if (opt->arg_count != 2)
return usage_error("arguments attendus : VIDEAU PROPRIÉTAIRE");
match.cube_value = (int) strtol(opt->args[0], &end, 10);
if (*end || end == opt->args[0])
if (opt->arg_count != 2 && opt->arg_count != 6)
return usage_error("arguments attendus : VIDEAU PROPRIÉTAIRE "
"[LONGUEUR SCORE0 SCORE1 CRAWFORD]");
if (!parse_int(opt->args[0], &match.cube_value))
return usage_error("VIDEAU attend un nombre");
match.cube_owner = (int) strtol(opt->args[1], &end, 10);
if (*end || end == opt->args[1])
if (!parse_int(opt->args[1], &match.cube_owner))
return usage_error("PROPRIÉTAIRE attend -1, 0 ou 1");
if (opt->arg_count == 6) {
if (!parse_int(opt->args[2], &match.match_length) || match.match_length < 1
|| !parse_int(opt->args[3], &match.score[0]) || match.score[0] < 0
|| !parse_int(opt->args[4], &match.score[1]) || match.score[1] < 0
|| match.score[0] >= match.match_length || match.score[1] >= match.match_length)
return usage_error("LONGUEUR attend un nombre positif, SCORE0 et SCORE1 des "
"scores inférieurs à la longueur");
if (!parse_int(opt->args[5], &match.crawford) || match.crawford < 0
|| match.crawford > 1)
return usage_error("CRAWFORD attend 0 ou 1");
match.jacoby = 0;
}
status = engine_encode_match(&match, match_id);
if (status != ENGINE_OK)
@@ -503,8 +537,67 @@ run_match_id_command(const options *opt)
return EXIT_SUCCESS;
}
/* double et take (avec --level) : le videau de l'ordinateur, comme
* ComputerTurn de gnubg (voir engine.h). */
static int
run_level_cube_command(const options *opt)
{
const char *position_id = opt->args[0], *match_id = opt->args[1];
engine_status status;
if (opt->arg_count != 2)
return usage_error("arguments attendus : POSITION_ID MATCH_ID");
if (opt->level < 0)
return usage_error("double et take demandent --level");
if (strcmp(opt->command, "double") == 0) {
engine_level_double d;
status = engine_level_double_decision(position_id, match_id, opt->level, &d);
if (status != ENGINE_OK)
return print_engine_error(status);
printf("{\"position_id\": \"%s\", \"match_id\": \"%s\"", position_id, match_id);
print_level_or_ply(opt);
printf(", \"available\": %s, \"market_window\": %s, \"double\": %s, \"decision\": ",
d.available ? "true" : "false", d.market_window ? "true" : "false",
d.doubles ? "true" : "false");
if (d.decision)
printf("\"%s\"}\n", d.decision);
else
printf("null}\n");
} else {
int takes;
const char *decision;
status = engine_level_take_decision(position_id, match_id, opt->level, &takes, &decision);
if (status != ENGINE_OK)
return print_engine_error(status);
printf("{\"position_id\": \"%s\", \"match_id\": \"%s\"", position_id, match_id);
print_level_or_ply(opt);
printf(", \"take\": %s, \"decision\": \"%s\"}\n", takes ? "true" : "false", decision);
}
return EXIT_SUCCESS;
}
/* aftergame : le match après une partie (voir engine_match_after_game). */
static int
run_after_game_command(const options *opt)
{
engine_match_after after;
engine_status status;
int winner, points;
if (opt->arg_count != 3)
return usage_error("arguments attendus : MATCH_ID GAGNANT POINTS");
if (!parse_int(opt->args[1], &winner) || !parse_int(opt->args[2], &points))
return usage_error("GAGNANT et POINTS attendent des nombres");
status = engine_match_after_game(opt->args[0], winner, points, &after);
if (status != ENGINE_OK)
return print_engine_error(status);
printf("{\"score\": [%d, %d], \"match_over\": %s, \"crawford\": %s}\n", after.score[0],
after.score[1], after.match_over ? "true" : "false", after.crawford ? "true" : "false");
return EXIT_SUCCESS;
}
/*
* reference : un niveau d'un fichier de résultat de tests/reference/, de
* reference :un niveau d'un fichier de résultat de tests/reference/, de
* même structure que plies["<n>"] (voir tests/reference/README.md).
*/
static int
@@ -731,8 +824,9 @@ parse_options(int argc, char **argv, options *opt)
if (opt->level >= 0 && has_ply)
return "--ply et --level ne vont pas ensemble";
if (opt->level >= 0 && strcmp(opt->command, "bestmove") != 0
&& strcmp(opt->command, "moves") != 0 && strcmp(opt->command, "cube") != 0)
return "--level ne s'applique qu'à bestmove, moves et cube";
&& strcmp(opt->command, "moves") != 0 && strcmp(opt->command, "cube") != 0
&& strcmp(opt->command, "double") != 0 && strcmp(opt->command, "take") != 0)
return "--level ne s'applique qu'à bestmove, moves, cube, double et take";
return NULL;
}
@@ -772,6 +866,10 @@ main(int argc, char **argv)
result = run_half_moves_command(&opt);
else if (strcmp(opt.command, "matchid") == 0)
result = run_match_id_command(&opt);
else if (strcmp(opt.command, "double") == 0 || strcmp(opt.command, "take") == 0)
result = run_level_cube_command(&opt);
else if (strcmp(opt.command, "aftergame") == 0)
result = run_after_game_command(&opt);
else if (strcmp(opt.command, "levels") == 0)
result = run_levels_command(&opt);
else if (strcmp(opt.command, "analysemove") == 0)
+154
View File
@@ -156,6 +156,8 @@ engine_status_message(engine_status status)
return "coup joué absent des coups légaux";
case ENGINE_ERROR_CUBE_ACTION:
return "décision de videau inconnue";
case ENGINE_ERROR_RESULT:
return "résultat de partie invalide";
}
return "erreur inconnue";
}
@@ -667,6 +669,158 @@ engine_level_cube_decision(const char *position_id, const char *match_id, int le
return cube_decision(&pos, &context, cube);
}
/* -------------------------------------------- Videau de l'ordinateur */
/* Valeur maximale du videau (MAX_CUBE dans backgammon.h de gnubg, non
* repris ici). */
#define MAX_CUBE_VALUE (1 << 12)
/* La décision de videau de gnubg pour une position, avec ces réglages. */
static engine_status
find_cube_decision(position *pos, const evalcontext *context, cubedecision *decision)
{
float output[2][NUM_ROLLOUT_OUTPUTS];
float equities[NUM_CUBEFUL_OUTPUTS];
if (GeneralCubeDecisionE(output, (ConstTanBoard) pos->board, &pos->cube, context, NULL) < 0)
return ENGINE_ERROR_INTERNAL;
*decision = FindCubeDecision(equities, output, &pos->cube);
if ((size_t) *decision >= sizeof cube_decision_names / sizeof cube_decision_names[0])
return ENGINE_ERROR_INTERNAL;
return ENGINE_OK;
}
/* Voir engine.h : ComputerTurn, play.c l. 1312-1398. */
engine_status
engine_level_double_decision(const char *position_id, const char *match_id, int level,
engine_level_double *result)
{
position pos;
evalcontext context, market_context;
float output[NUM_ROLLOUT_OUTPUTS];
cubedecision decision;
engine_status status;
int available = 0;
memset(result, 0, sizeof *result);
status = decode_position(position_id, match_id, &pos);
if (status == ENGINE_OK)
status = eval_context_for_level(level, &context);
if (status != ENGINE_OK)
return status;
/* « if (ms.fCubeUse && !ms.anDice[0] && ms.nCube < MAX_CUBE && GetDPEq(...)) » */
GetDPEq(&available, NULL, &pos.cube);
if (!available || pos.cube.nCube >= MAX_CUBE_VALUE)
return ENGINE_OK;
result->available = 1;
/* Fenêtre de marché : évaluation sans videau, un pli de moins. */
market_context = context;
market_context.fCubeful = FALSE;
if (market_context.nPlies)
market_context.nPlies--;
if (EvaluatePosition(NULL, (ConstTanBoard) pos.board, output, &pos.cube, &market_context))
return ENGINE_ERROR_INTERNAL;
if (output[0] < GetDoublePointDeadCube(output, &pos.cube))
return ENGINE_OK;
result->market_window = 1;
status = find_cube_decision(&pos, &context, &decision);
if (status != ENGINE_OK)
return status;
result->decision = cube_decision_names[decision];
switch (decision) {
case DOUBLE_TAKE:
case REDOUBLE_TAKE:
case DOUBLE_PASS:
case REDOUBLE_PASS:
case DOUBLE_BEAVER:
result->doubles = 1;
break;
case OPTIONAL_DOUBLE_BEAVER:
case OPTIONAL_DOUBLE_TAKE:
case OPTIONAL_REDOUBLE_TAKE:
case OPTIONAL_DOUBLE_PASS:
case OPTIONAL_REDOUBLE_PASS:
/* « double if 0-ply except when about to lose game » */
result->doubles = context.nPlies == 0 && output[0] > 0.001f;
break;
default:
/* « better leave cube where it is » */
break;
}
return ENGINE_OK;
}
/* Voir engine.h : ComputerTurn, play.c l. 1139-1235. */
engine_status
engine_level_take_decision(const char *position_id, const char *match_id, int level,
int *takes, const char **decision_name)
{
position pos;
evalcontext context;
cubedecision decision;
engine_status status;
status = decode_position(position_id, match_id, &pos);
if (status == ENGINE_OK)
status = eval_context_for_level(level, &context);
if (status == ENGINE_OK)
status = find_cube_decision(&pos, &context, &decision);
if (status != ENGINE_OK)
return status;
switch (decision) {
case DOUBLE_PASS:
case TOOGOOD_PASS:
case REDOUBLE_PASS:
case TOOGOODRE_PASS:
case OPTIONAL_DOUBLE_PASS:
case OPTIONAL_REDOUBLE_PASS:
*takes = 0;
break;
default:
*takes = 1;
break;
}
*decision_name = cube_decision_names[decision];
return ENGINE_OK;
}
/* ------------------------------------------------------------------ Match */
/* Voir engine.h : play.c l. 303, 1817-1820 et 4277. */
engine_status
engine_match_after_game(const char *match_id, int winner, int points,
engine_match_after *after)
{
engine_match m;
engine_status status = engine_decode_match(match_id, &m);
int length, post_crawford;
if (status != ENGINE_OK)
return status;
if (winner < 0 || winner > 1 || points < 1)
return ENGINE_ERROR_RESULT;
memset(after, 0, sizeof *after);
length = m.match_length;
after->score[0] = m.score[0];
after->score[1] = m.score[1];
after->score[winner] += points;
if (length == 0)
return ENGINE_OK;
after->match_over = after->score[winner] >= length;
if (after->match_over)
return ENGINE_OK;
/* Partie Crawford déjà jouée, lue comme gnubg (play.c l. 303). */
post_crawford = !m.crawford && (m.score[0] == length - 1 || m.score[1] == length - 1);
/* play.c l. 1817-1820. */
post_crawford |= m.crawford && after->score[winner] < length;
after->crawford = !post_crawford && !m.crawford && after->score[winner] == length - 1
&& after->score[!winner] != length - 1;
return ENGINE_OK;
}
/* -------------------------------------------- Analyse d'une décision de videau */
/* Le plus petit de deux nombres. */
+67 -1
View File
@@ -51,7 +51,8 @@ typedef enum {
ENGINE_ERROR_INTERNAL, /* échec signalé par gnubg */
ENGINE_ERROR_LEVEL, /* niveau de jeu inconnu */
ENGINE_ERROR_MOVE, /* coup joué absent des coups légaux */
ENGINE_ERROR_CUBE_ACTION /* décision de videau inconnue */
ENGINE_ERROR_CUBE_ACTION, /* décision de videau inconnue */
ENGINE_ERROR_RESULT /* résultat de partie invalide */
} engine_status;
#define ENGINE_MAX_PLY 4
@@ -374,6 +375,71 @@ engine_status engine_level_best_moves(const char *position_id, const char *match
engine_status engine_level_cube_decision(const char *position_id, const char *match_id,
int level, engine_cube *cube);
/*
* Le videau de l'ordinateur à ce niveau, comme le joue gnubg de bureau
* (ComputerTurn dans play.c, l. 1312-1398, « Consider doubling ») :
* - il ne pense à doubler que s'il a accès au videau (GetDPEq : pas en
* partie Crawford, pas avec un videau mort…) et que le videau vaut moins
* de 4096 (MAX_CUBE) ;
* - pré-filtre « fenêtre de marché » : ses chances de gain, évaluées sans
* videau à un pli de moins que le niveau (bruit compris), doivent atteindre
* le point de double à videau mort (GetDoublePointDeadCube) ; sinon il ne
* double pas, sans évaluer la décision complète ;
* - dans la fenêtre, il double sur DOUBLE_TAKE, REDOUBLE_TAKE, DOUBLE_PASS,
* REDOUBLE_PASS et DOUBLE_BEAVER ; sur un double « facultatif »
* (OPTIONAL_*), seulement s'il évalue à 0 pli et que ses chances de gain
* dépassent 0,001 (« double if 0-ply except when about to lose game ») ;
* sinon il garde le videau.
*/
typedef struct {
int available; /* le joueur au trait a accès au videau */
int market_window; /* le pré-filtre est passé : décision évaluée */
int doubles; /* l'ordinateur double */
const char *decision; /* décision complète (noms d'engine_cube.decision)
* si elle a été évaluée, sinon NULL */
} engine_level_double;
engine_status engine_level_double_decision(const char *position_id, const char *match_id,
int level, engine_level_double *result);
/*
* L'ordinateur, adversaire du joueur au trait qui vient de doubler, prend-il
* ? Comme ComputerTurn (play.c, l. 1139-1235, « normal double by
* opponent ») : la décision de videau est évaluée du point de vue de celui
* qui double, avec les réglages du niveau de l'ordinateur ; il passe sur
* DOUBLE_PASS, TOOGOOD_PASS, REDOUBLE_PASS, TOOGOODRE_PASS et les
* OPTIONAL_*_PASS, et prend sinon (Ustagammon ne joue pas les castors :
* gnubg prend aussi quand il ne peut pas en faire). position_id et match_id
* sont ceux du joueur qui double ; *decision reçoit le nom de la décision.
*/
engine_status engine_level_take_decision(const char *position_id, const char *match_id,
int level, int *takes, const char **decision);
/* ------------------------------------------------------------------ Match */
/* Le match après une partie. */
typedef struct {
int score[2]; /* score après la partie */
int match_over; /* le gagnant a atteint la longueur du match */
int crawford; /* la partie suivante est la partie Crawford */
} engine_match_after;
/*
* Le match après une partie gagnée par winner (0 ou 1, numérotation du
* Match ID) pour points points. match_id est celui de la partie jouée : sa
* longueur, son score avant la partie et son drapeau Crawford comptent, le
* reste est ignoré. Comme gnubg de bureau à la fin d'une partie (play.c,
* l. 1817-1820, règle de Crawford automatique) : la partie suivante est la
* partie Crawford si le gagnant arrive à un point du but, que son adversaire
* n'y est pas et qu'il n'y a pas encore eu de partie Crawford. Qu'une partie
* Crawford ait déjà eu lieu se lit dans le Match ID comme gnubg le lit
* (play.c, l. 303 et l. 4277) : pas de drapeau Crawford alors qu'un joueur
* est à un point du but. En partie libre (longueur 0), le score s'ajoute
* seulement. ENGINE_ERROR_RESULT si winner ou points sont invalides.
*/
engine_status engine_match_after_game(const char *match_id, int winner, int points,
engine_match_after *after);
/* ------------------------------------------------------------- Analyse */
/* Appréciation de gnubg pour une perte d'équité (0 ou négative). */
+23 -4
View File
@@ -11,6 +11,7 @@ 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
@@ -33,9 +34,14 @@ 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.
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"
# 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
echo "Vérification avant publication : make lint et make test"
make lint
@@ -43,4 +49,17 @@ 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
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})"
+58 -5
View File
@@ -2,7 +2,17 @@
# Joue une partie complète par l'interface dans Chromium et Firefox sans
# écran (tests/browser/play.mjs), sur le site servi par l'image de production
# construite en local, comme tests/test_site.sh. Les captures d'écran vont
# dans build/browser-shots/.
# dans build/browser-shots/. Puis l'accueil, le tutoriel, l'aide,
# l'installation et la mise à jour (tests/browser/accueil.mjs), avec leurs
# captures dans build/browser-shots/accueil-tutoriel/. Enfin l'écran « Nouvelle
# 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
# test.
#
# Outils : l'image de Playwright (navigateurs) et le paquet playwright,
# installé une fois par npm dans build/browser-tools/ (réseau nécessaire la
@@ -14,6 +24,8 @@ 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}"
unset MAKEFLAGS MFLAGS MAKELEVEL
make -s package >/dev/null
@@ -33,15 +45,56 @@ if [ ! -f "$tools/node_modules/playwright/package.json" ]; then
"$image" npm install --no-audit --no-fund --save-exact "playwright@$version" >/dev/null
fi
# Pour la mise à jour de l'application (tests/browser/accueil.mjs) : une
# copie du site, servie par la même image mais depuis un dossier que le test
# modifie, pour publier une deuxième version sur la même adresse.
updates=build/browser-site
rm -rf "$updates"
cp -r build/site "$updates"
update_container="$(docker run -d --rm -p 127.0.0.1::80 -v "$PWD/$updates:/srv:ro" "$site_image")"
# shellcheck disable=SC2317 # appelée par trap, à la sortie
update_cleanup() {
docker rm -f "$update_container" >/dev/null 2>&1 || true
site_cleanup
}
trap update_cleanup EXIT
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 "$tools/"
cp tests/browser/play.mjs tests/browser/accueil.mjs tests/browser/match.mjs tests/browser/recette.mjs \
tests/browser/maitre.mjs "$tools/"
status=0
for browser in $browsers; do
# Réseau de l'hôte : le site est sur 127.0.0.1. Le service worker
# demande un contexte sûr, ce qu'est localhost.
docker run --rm --init --ipc=host --network host --user "$(id -u):$(id -g)" -e HOME=/tmp \
-e TRACE -v "$PWD/$tools:/tools" -v "$PWD/build/browser-shots:/shots" -w /tools "$image" \
node play.mjs "http://localhost:$port/" "$browser" /shots || status=1
if [[ " $scripts " == *" play "* ]]; then
docker run --rm --init --ipc=host --network host --user "$(id -u):$(id -g)" -e HOME=/tmp \
-e TRACE -v "$PWD/$tools:/tools" -v "$PWD/build/browser-shots:/shots" -w /tools "$image" \
node play.mjs "http://localhost:$port/" "$browser" /shots || status=1
fi
if [[ " $scripts " == *" accueil "* ]]; then
# Accueil, tutoriel, aide, installation et mise à jour ; captures de
# la recette dans build/browser-shots/accueil-tutoriel/.
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" -v "$PWD/$updates:/site" \
-w /tools "$image" \
node accueil.mjs "http://localhost:$port/" "$browser" /shots \
"http://localhost:$update_port/" /site || status=1
fi
if [[ " $scripts " == *" match "* ]]; then
# Écran « Nouvelle partie » et match en 3 points ; captures de la
# recette dans build/browser-shots/match/.
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 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"
+551
View File
@@ -0,0 +1,551 @@
// accueil.mjs -- l'accueil, le tutoriel des règles, l'aide, l'installation
// et la mise à jour de l'application, dans un vrai navigateur sans écran
// (Playwright), sur le site servi par l'image de production
// (docs/plans/2026-11-03-accueil-tutoriel-aide.md, point 7).
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
//
// Usage : node accueil.mjs <adresse du site> <chromium|firefox> <dossier des
// captures> <adresse du site modifiable> <dossier de ce site>
// Lancé par tests/browser.sh. Le second site sert la même chose depuis un
// dossier que ce script modifie : il y publie une deuxième version pour
// essayer la mise à jour. Captures de la recette design (Chromium) dans
// <dossier des captures>/accueil-tutoriel/, en 412×915 et 915×412, et aussi
// en 844×320 pour le tutoriel.
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];
if (!base || !browserType || !shots || !updateBase || !updateDir) {
console.error("usage : node accueil.mjs <adresse> <chromium|firefox> <captures> <adresse 2> <dossier 2>");
process.exit(2);
}
const full = browserName === "chromium";
const dir = `${shots}/accueil-tutoriel`;
mkdirSync(dir, { recursive: true });
const SIZES = [
[412, 915],
[915, 412],
];
// Le tutoriel est aussi vérifié dans le plus petit paysage de la recette.
const TUTORIAL_SIZES = [...SIZES, [844, 320]];
const browser = await browserType.launch();
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);
}
}
/** Un contexte neuf (stockage vide), avec des réglages déjà enregistrés si
* settings est donné. Erreurs de la page et violations de la CSP relevées. */
async function newPage(settings = null, url = base) {
const context = await browser.newContext({
viewport: { width: 412, height: 915 },
deviceScaleFactor: 1,
locale: "fr-FR",
});
await context.addInitScript((saved) => {
window.__csp = [];
document.addEventListener("securitypolicyviolation", (e) =>
window.__csp.push(`${e.effectiveDirective} ${e.blockedURI}`),
);
// Un élément est-il à l'écran ? (Utilisé par les attentes.)
window.__shown = (id) => {
const el = document.getElementById(id);
return el !== null && !el.closest("[hidden]") && el.getClientRects().length > 0;
};
window.__vibrations = [];
Object.defineProperty(Navigator.prototype, "vibrate", {
configurable: true,
value: (pattern) => {
window.__vibrations.push(String(pattern));
return true;
},
});
if (saved !== null && localStorage.getItem("ustagammon.settings") === null) {
localStorage.setItem("ustagammon.settings", saved);
}
}, settings === null ? null : JSON.stringify(settings));
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()}`);
}
});
page.on("dialog", (dialog) => {
problems.push(`boîte native : ${dialog.type()}`);
void dialog.dismiss();
});
await page.goto(url);
return { context, page };
}
/** Attend que test (évalué dans la page) soit vrai, au plus ms. (Pas de
* waitForFunction : Playwright y passe par eval, que la CSP interdit.) */
async function until(page, what, test, arg, ms = 30_000) {
let last = null;
for (const end = Date.now() + ms; Date.now() < end; ) {
last = await page.evaluate(test, arg).catch((error) => `page illisible : ${error.message}`);
if (last === true) {
return;
}
await sleep(100);
}
await page.screenshot({ path: `${shots}/${browserName}-accueil-echec.png` }).catch(() => {});
throw new Error(`${what} : attente dépassée (${JSON.stringify(last)})`);
}
/** Range une capture de l'écran courant dans chaque taille (Chromium). */
async function capture(page, name, settle = async () => {}, sizes = SIZES) {
if (!full) {
return;
}
for (const [width, height] of 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++;
}
await page.setViewportSize({ width: 412, height: 915 });
await sleep(150);
}
/** Vérifie la CSP et ferme le contexte. */
async function close(context, page) {
const csp = await page.evaluate(() => window.__csp).catch(() => []);
check(csp.length === 0, `violations de la CSP : ${csp.join(", ")}`);
await context.close();
}
// ------------------------------------------------- Premier lancement
/** L'écran du tutoriel prêt : plateau dessiné pour cette adresse, coup
* illustré joué (« Revoir » utilisable) s'il y en a un. */
async function tutorialReady(page, hash) {
await until(page, `tutoriel ${hash}`, (hash) => {
const section = document.getElementById("tutorial");
const replay = document.getElementById("tutorial-replay");
return location.hash === hash && section.dataset.ready !== undefined &&
document.querySelectorAll("#tutorial-board polygon[data-point]").length === 24 &&
(replay.hidden || !replay.disabled);
}, hash);
}
/** Recette du tutoriel dans la taille courante : numéros des flèches de
* 12 px au moins ; en paysage, rien ne défile et les boutons sont en vue. */
async function checkTutorialView(page, name) {
const view = await page.evaluate(() => {
const svg = document.getElementById("tutorial-board");
const box = svg.getBoundingClientRect();
const vb = svg.viewBox.baseVal;
const scale = Math.min(box.width / vb.width, box.height / vb.height);
const numbers = [...svg.querySelectorAll(".point-number")]
.map((t) => Number(t.getAttribute("font-size")) * scale);
const buttons = ["tutorial-back", "tutorial-next", "tutorial-drop", "tutorial-take"]
.map((id) => document.getElementById(id))
.filter((el) => el !== null && el.closest("[hidden]") === null)
.map((el) => {
const r = el.getBoundingClientRect();
return { id: el.id, top: r.top, bottom: r.bottom };
});
return {
landscape: document.documentElement.classList.contains("landscape"),
minNumber: numbers.length === 0 ? 0 : Math.min(...numbers),
scroll: document.scrollingElement.scrollHeight - innerHeight,
body: (() => {
const b = document.querySelector("#tutorial .tutorial-body");
return b.scrollHeight - b.clientHeight;
})(),
height: innerHeight,
buttons,
};
});
const size = `${name} en ${await page.evaluate(() => `${innerWidth}x${innerHeight}`)}`;
check(view.minNumber >= 12, `${size} : numéros des flèches de ${view.minNumber.toFixed(1)} px`);
if (view.landscape) {
check(view.scroll <= 1, `${size} : la page défile de ${view.scroll} px`);
check(view.body <= 1, `${size} : le texte défile de ${view.body} px`);
if (view.body > tutorialBodyScroll.px) {
tutorialBodyScroll = { px: view.body, where: size };
}
for (const b of view.buttons) {
check(b.top >= 0 && b.bottom <= view.height + 0.5,
`${size} : « ${b.id} » hors de l'écran (${b.top}-${b.bottom} pour ${view.height})`);
}
}
tutorialViews++;
tutorialMinNumber = Math.min(tutorialMinNumber, view.minNumber);
}
let tutorialViews = 0;
let tutorialBodyScroll = { px: 0, where: "" };
let tutorialMinNumber = Infinity;
/** Capture d'un écran du tutoriel, recette comprise, dans les trois tailles. */
async function tutorialCapture(page, name) {
await capture(page, name, () => checkTutorialView(page, name), TUTORIAL_SIZES);
}
/** Touche une case du plateau du tutoriel. */
async function touch(page, place) {
await page.click(`#tutorial-board [data-place="${place}"]`);
await sleep(200);
}
async function verdict(page) {
await until(page, "verdict", () => {
const v = document.getElementById("tutorial-feedback").dataset.verdict;
return v === "right" || v === "wrong";
});
return page.evaluate(() => document.getElementById("tutorial-feedback").dataset.verdict);
}
/** Les gestes des écrans « à vous » : un coup refusé, puis le bon. */
const PRACTICE = {
// 24/21 : ne peut plus mener à 8/5 6/5, refusé tout de suite.
3: { wrong: [["p24", "p21"]], right: [["p8", "p5"], ["p6"]] },
// 24/23 puis 24/18 : un coup complet qui ne frappe pas.
4: { wrong: [["p24", "p23"], ["p24"]], right: [["p13"], ["p8"]] },
// 6/3 : ne sort pas deux pions.
6: { wrong: [["p6", "p3"]], right: [["p6", "off-player"], ["p3"]] },
};
async function playTouches(page, touches) {
for (const group of touches) {
for (const place of group) {
await touch(page, place);
}
await until(page, "demi-coup joué", () => !document.querySelector("#tutorial[data-busy]"));
await sleep(150);
}
}
{
const { context, page } = await newPage();
await until(page, "premier lancement", () =>
location.hash === "#/accueil" && !document.getElementById("home-welcome").hidden);
const first = await page.evaluate(() => ({
topbar: getComputedStyle(document.querySelector(".topbar")).display,
links: [...document.querySelectorAll("#home-welcome a.choice")].map((a) => a.getAttribute("href")),
main: document.getElementById("home-main").hidden,
}));
check(first.topbar === "none" && first.main, `premier lancement : ${JSON.stringify(first)}`);
check(JSON.stringify(first.links) === '["#/tutoriel/1","#/"]', `choix : ${first.links}`);
await capture(page, "01-premier-lancement");
log("premier lancement : « Je découvre » et « Je connais les règles », de vrais liens");
// Le tutoriel complet, par ses liens « Continuer ».
await page.click("#welcome-discover");
let screens = 0;
for (let step = 1; step <= 8; step++) {
const hash = `#/tutoriel/${step}`;
await tutorialReady(page, hash);
// La barre ne mène qu'aux étapes déjà vues.
const links = await page.evaluate(() =>
[...document.querySelectorAll("#tutorial-progress a")].map((a) => a.getAttribute("href")));
const expected = Array.from({ length: step - 1 }, (_, i) => `#/tutoriel/${i + 1}`);
check(JSON.stringify(links) === JSON.stringify(expected),
`étape ${step} : barre de progression ${JSON.stringify(links)}`);
const illustrated = await page.evaluate(() => !document.getElementById("tutorial-replay").hidden);
if (illustrated) {
check(await page.locator("#tutorial-board .trace-arrow").count() > 0,
`étape ${step} : le coup illustré n'a pas laissé de traces`);
// « Revoir » rejoue le coup.
await page.click("#tutorial-replay");
await tutorialReady(page, hash);
}
if (step === 2) {
check(await page.locator("#tutorial-board .tutorial-path").count() === 1,
"étape 2 : pas de chemin en pointillés");
// Sous les pions et sous les numéros des flèches.
const order = await page.evaluate(() => {
const children = [...document.getElementById("tutorial-board").children];
const at = (selector) => children.findIndex((c) => c.matches(selector));
const numbers = children.filter((c) => c.matches(".point-number")).map((c) => children.indexOf(c));
return { path: at(".tutorial-path"), pieces: at("g.pieces"), firstNumber: Math.min(...numbers) };
});
check(order.path >= 0 && order.path < order.pieces && order.path < order.firstNumber,
`étape 2 : le chemin passe au-dessus des pions ou des numéros ${JSON.stringify(order)}`);
}
await tutorialCapture(page, `tutoriel-${step}`);
screens++;
await page.click("#tutorial-next");
const practice = await page.evaluate(() => location.hash.endsWith("/a-vous"));
if (!practice) {
continue;
}
const avous = `${hash}/a-vous`;
await tutorialReady(page, avous);
check(await page.evaluate(() => document.getElementById("tutorial-next").classList.contains("secondary")),
`étape ${step}, à vous : « Continuer » principal avant le geste`);
await tutorialCapture(page, `tutoriel-${step}-a-vous`);
if (step === 8) {
await page.click("#tutorial-drop");
check(await verdict(page) === "wrong", "étape 8 : « Passer » accepté");
await tutorialCapture(page, "tutoriel-8-a-vous-refuse");
await page.click("#tutorial-take");
await until(page, "étape 8 juste", () =>
document.getElementById("tutorial-feedback").dataset.verdict === "right");
} else {
const moves = PRACTICE[step];
await playTouches(page, moves.wrong);
check(await verdict(page) === "wrong", `étape ${step} : coup faux accepté`);
// Le pion revient : de nouveau des pions jouables.
await until(page, "pion revenu", () => document.querySelectorAll("#tutorial-board .hit.can-move").length > 0);
const words = await page.textContent("#tutorial-feedback");
check(!/erreur/i.test(words), `étape ${step} : « erreur » dans le refus`);
await tutorialCapture(page, `tutoriel-${step}-a-vous-refuse`);
await playTouches(page, moves.right);
check(await verdict(page) === "right", `étape ${step} : bon coup refusé`);
check(await page.locator("#tutorial-board .hit.can-move").count() === 0,
`étape ${step} : plateau encore jouable après le bon coup`);
}
check(!(await page.evaluate(() => document.getElementById("tutorial-next").classList.contains("secondary"))),
`étape ${step} : « Continuer » pas principal après le bon geste`);
const vibrated = await page.evaluate(() => window.__vibrations.at(-1));
check(vibrated === "12", `étape ${step} : vibration ${vibrated}`);
await tutorialCapture(page, `tutoriel-${step}-a-vous-reussi`);
screens++;
await page.click("#tutorial-next");
}
await until(page, "fin du tutoriel", () => location.hash === "#/tutoriel/fin" &&
document.getElementById("tutorial").dataset.ready !== undefined);
await tutorialCapture(page, "tutoriel-fin");
// Le retour du téléphone : l'écran d'avant.
await page.goBack();
await tutorialReady(page, "#/tutoriel/8/a-vous");
await page.goForward();
await until(page, "fin", () => location.hash === "#/tutoriel/fin");
await page.click("#tutorial-next");
await until(page, "première partie", () => location.hash === "#/" && window.__shown("game"));
log(`tutoriel : ${screens} écrans, gestes refusés puis justes sur les 4 écrans « à vous », fin` +
(full ? ` ; recette sur ${tutorialViews} vues, numéros de ${tutorialMinNumber.toFixed(1)} px au moins, ` +
`texte défilant au plus de ${tutorialBodyScroll.px} px${tutorialBodyScroll.px > 0 ? ` (${tutorialBodyScroll.where})` : ""}` : ""));
// Le choix ne revient plus : l'accueil, ensuite.
await page.goto(base);
await until(page, "accueil", () => location.hash === "#/accueil" &&
document.getElementById("home-welcome").hidden && !document.getElementById("home-main").hidden);
await close(context, page);
}
// --------------------------------------------------- Accueil, installation
{
// Une partie terminée : l'invitation à installer apparaît.
const settings = { welcomed: true, gamesStarted: 2, gamesFinished: 1, tutorialSeen: 99 };
const { context, page } = await newPage(settings);
await until(page, "accueil prêt", () => location.hash === "#/accueil" &&
!document.getElementById("home-new").disabled && !document.getElementById("install-card").hidden);
const home = await page.evaluate(() => ({
resume: document.getElementById("home-resume").getAttribute("href"),
primaries: [...document.querySelectorAll("#home a.button:not(.secondary), #home button:not(.secondary):not(.icon-button)")]
.filter((el) => !el.closest("[hidden]")).length,
rules: document.getElementById("home-rules").getAttribute("href"),
}));
check(home.resume === "#/" && home.primaries === 1 && home.rules === "#/tutoriel/1",
`accueil : ${JSON.stringify(home)}`);
await capture(page, "02-accueil");
await capture(page, "03-carte-installation", async () => {
await page.locator("#install-card").scrollIntoViewIfNeeded();
});
// Sans invite du navigateur : la marche à suivre, une feuille avec son
// adresse, que le retour referme.
await page.click("#install-card-button");
await until(page, "feuille d'installation", () => location.hash === "#/installer" && window.__shown("install-sheet"));
check((await page.locator("#install-steps li").count()) === 3, "marche à suivre sans trois étapes");
await capture(page, "04-feuille-installation");
await page.goBack();
await until(page, "feuille refermée", () => location.hash === "#/accueil" && !window.__shown("install-sheet"));
if (browserName === "chromium") {
// L'invite de Chrome, simulée : « Installer » l'ouvre.
await page.evaluate(() => {
window.__prompted = 0;
const event = new Event("beforeinstallprompt", { cancelable: true });
event.prompt = () => {
window.__prompted++;
return Promise.resolve();
};
event.userChoice = Promise.resolve({ outcome: "dismissed" });
window.dispatchEvent(event);
});
await page.click("#install-card-button");
await until(page, "invite du navigateur", () => window.__prompted === 1);
check(await page.evaluate(() => location.hash) === "#/accueil", "l'invite a ouvert la feuille");
// Installée : plus aucune proposition.
await page.evaluate(() => window.dispatchEvent(new Event("appinstalled")));
await until(page, "installée", () => document.getElementById("install-card").hidden &&
document.getElementById("menu-install").hidden);
log("installation : invite de Chrome simulée, puis plus rien une fois installée");
} else {
await page.click("#install-later");
await until(page, "plus tard", () => document.getElementById("install-card").hidden);
log("installation : marche à suivre, « Plus tard »");
}
await close(context, page);
}
// ------------------------------------------------------------------- Aide
{
const { context, page } = await newPage({ welcomed: true, gamesStarted: 2 }, `${base}#/aide`);
await until(page, "aide", () => window.__shown("help-play") && document.getElementById("help-content").dataset.loaded === "true");
const help = await page.evaluate(() => ({
steps: document.querySelectorAll("#help-play .steps, #help-play li").length,
questions: [...document.querySelectorAll("#help-play .question h3")].map((h) => h.textContent),
tutorial: document.getElementById("help-tutorial")?.getAttribute("href"),
}));
check(help.steps === 0, "aide : les étapes Gherkin sont affichées");
check(help.questions.length >= 25 && help.tutorial === "#/tutoriel/1", `aide : ${JSON.stringify(help)}`);
check(help.questions.filter((q) => q.endsWith("?")).length >= 20, "aide : trop peu de questions");
await capture(page, "05-aide-jouer");
const question = await page.evaluate(() =>
[...document.querySelectorAll("#help-play .question")].find((q) => q.querySelector(".see-board"))?.id);
await page.click(`#${question} h3 a`);
await until(page, "question ouverte", (id) => document.getElementById(id).classList.contains("open"), question);
await capture(page, "06-aide-question");
// Le retour du téléphone referme la question.
await page.goBack();
await until(page, "question refermée", () => location.hash === "#/aide" && !document.querySelector(".question.open"));
await page.click('#help-tabs a[data-screen="glossary"]');
await until(page, "glossaire", () => window.__shown("help-glossary"));
const before = await page.locator("#help-glossary .term-card:not([hidden])").count();
await page.fill("#glossary-search", "sortir");
await sleep(200);
const after = await page.locator("#help-glossary .term-card:not([hidden])").count();
check(after > 0 && after < before, `recherche : ${after} fiches sur ${before}`);
await capture(page, "07-aide-glossaire-recherche");
await page.click('#help-tabs a[data-screen="about"]');
await until(page, "à propos", () => window.__shown("help-about"));
const version = await page.textContent("#about-version");
check(/^Version publiée : [0-9a-f]{12}\.$/.test(version), `version : « ${version} »`);
await capture(page, "08-aide-a-propos");
log(`aide : ${help.questions.length} questions, recherche dans le glossaire (${after}/${before}), ${version}`);
await close(context, page);
}
// ----------------------------------------------------------- Mise à jour
if (browserName === "chromium") {
const { context, page } = await newPage({ welcomed: true, gamesStarted: 2 }, `${updateBase}#/`);
await until(page, "partie prête", () => !document.body.classList.contains("busy"), undefined, 60_000);
// Le service worker installé et aux commandes (première installation :
// sans attendre l'accord du joueur).
await until(page, "service worker", async () => {
const reg = await navigator.serviceWorker.getRegistration();
return reg?.active?.state === "activated" && navigator.serviceWorker.controller !== null;
}, undefined, 120_000);
const journal = () => page.evaluate(() => localStorage.length >= 0 &&
new Promise((resolve) => {
const open = indexedDB.open("ustagammon");
open.onsuccess = () => {
const db = open.result;
const tx = db.transaction(["settings"]);
const current = tx.objectStore("settings").get("current");
current.onsuccess = () => {
db.close();
resolve(current.result?.value ?? null);
};
};
open.onerror = () => resolve(null);
}));
check(await page.isHidden("#update-banner"), "bandeau de mise à jour sans nouvelle version");
// Si vous avez la main (jet d'ouverture), le bandeau attend la fin du
// tour : on publie la nouvelle version d'abord, puis on joue le coup.
const toPlay = () => page.evaluate(() => {
if (document.body.classList.contains("busy")) {
return "busy";
}
const usable = (id) => {
const b = document.getElementById(id);
return b !== null && !b.hidden && !b.disabled;
};
for (const id of ["roll", "again", "confirm", "skip", "take"]) {
if (usable(id)) {
return id;
}
}
if (document.querySelector("#board .hit.can-land")) {
return "land";
}
return document.querySelector("#board .hit.can-move") ? "move" : "busy";
});
// Une deuxième version publiée : autre version.json, autre sw.js.
writeFileSync(`${updateDir}/version.json`, '{"commit": "0123456789abcdef0123", "dirty": false}\n');
const sw = readFileSync(`${updateDir}/sw.js`, "utf8");
writeFileSync(`${updateDir}/sw.js`, sw.replace(/^const VERSION = "[0-9a-f]+";$/m, 'const VERSION = "0000000000000002";'));
await page.click("#menu-button");
await page.waitForSelector("#menu", { state: "visible" });
await page.click("#menu-update");
await until(page, "nouvelle version trouvée", () =>
document.getElementById("update-text")?.textContent !== "Recherche d'une mise à jour…", undefined, 120_000);
// Pendant votre coup, jamais de bandeau : seulement entre deux tours.
let waited = false;
for (let i = 0; i < 40; i++) {
const todo = await toPlay();
if (todo === "roll" || todo === "again") {
break;
}
if (todo === "busy") {
await sleep(100);
continue;
}
if (todo === "move" || todo === "land" || todo === "confirm") {
await sleep(400);
check(await page.isHidden("#update-banner"), `bandeau de mise à jour pendant votre coup (${todo})`);
waited = true;
}
await page.click(todo === "move" ? "#board .hit.can-move" : todo === "land" ? "#board .hit.can-land" : `#${todo}`);
await sleep(200);
}
await until(page, "bandeau de mise à jour", () => window.__shown("update-banner") && window.__shown("update-apply"), undefined, 120_000);
check((await page.textContent("#update-text")) === "Nouvelle version prête.",
`bandeau : « ${await page.textContent("#update-text")} »`);
// En bas, au-dessus de la barre d'actions, comme « Reprendre l'ancienne ».
const place = await page.evaluate(() => {
const banner = document.getElementById("update-banner").getBoundingClientRect();
const actions = document.getElementById("actions").getBoundingClientRect();
const top = document.querySelector(".topbar").getBoundingClientRect();
return { bottom: banner.bottom, top: banner.top, actions: actions.top, bar: top.bottom };
});
check(place.bottom <= place.actions && place.top > place.bar,
`bandeau de mise à jour mal placé : ${JSON.stringify(place)}`);
const gameBefore = await journal();
await capture(page, "09-mise-a-jour");
await Promise.all([page.waitForEvent("load", { timeout: 60_000 }), page.click("#update-apply")]);
await until(page, "partie reprise", () => location.hash === "#/" && !document.body.classList.contains("busy"), undefined, 60_000);
check(await journal() === gameBefore, "après la mise à jour, ce n'est plus la même partie");
await page.goto(`${updateBase}#/a-propos`);
await until(page, "nouvelle version", () =>
document.getElementById("about-version")?.textContent === "Version publiée : 0123456789ab.");
log(`mise à jour : deuxième version publiée, bandeau ${waited ? "retenu pendant votre coup, puis " : ""}entre deux tours, « Mettre à jour », partie retrouvée, nouvelle version`);
// Rien de neuf : « Vous avez la dernière version ».
await page.goto(`${updateBase}#/`);
await until(page, "partie", () => !document.body.classList.contains("busy"), undefined, 60_000);
await page.click("#menu-button");
await page.waitForSelector("#menu", { state: "visible" });
await page.click("#menu-update");
await until(page, "dernière version", () =>
document.getElementById("update-text").textContent === "Vous avez la dernière version.", undefined, 60_000);
await close(context, page);
}
await browser.close();
if (problems.length > 0) {
console.error(problems.join("\n"));
process.exit(1);
}
log(`aucune erreur dans la page${full ? ` ; ${captures} captures dans accueil-tutoriel/` : ""}`);

Some files were not shown because too many files have changed in this diff Show More