Compare commits

..
Author SHA1 Message Date
Pierre MARTINandClaude Opus 5.5 9f72b6212f Bilan : ne se redessine que si ce qu'il montre change ; le test attend son mini-plateau
Les analyses de l'ordinateur et les chances de gagner, rangées en
arrière-plan pour l'écran d'analyse, redessinaient tout le bilan, et son
mini-plateau disparaissait le temps d'être redessiné.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 16:03:01 +01:00
Pierre MARTINandClaude Opus 5.5 f2417097f9 Bilan : l'état prêt s'efface quand il se ferme ; le test du match attend le bilan visible
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 15:07:54 +01:00
Pierre MARTINandClaude Opus 5.5 663ef35b51 Recette 1e : titre du bilan sur l'issue, notation du moteur partout, boutons de 48 px en paysage, filtre en accent-soft, longue partie ; tests navigateur adaptés au bilan
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 14:37:44 +01:00
Pierre MARTINandClaude Opus 5.5 ced004edb0 1e faite : scénario ajusté (l'ordinateur double, vous passez), plan clos
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 10:41:35 +01:00
Pierre MARTINandClaude Opus 5.5 4d6f8cfc2e ADR 0007 et 0013 amendés : analyse de partie dans le journal, adresses du bilan et de l'analyse
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 10:30:17 +01:00
Pierre MARTINandClaude Opus 5.5 2c5ae08f46 Bilan et analyse : scénario, fiches du glossaire, test dans le navigateur
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 10:30:17 +01:00
Pierre MARTINandClaude Opus 5.5 006fd62704 Bilan de fin de partie et écran d'analyse : courbe des chances, visualiseur, liste filtrable, rejouer d'ici
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 10:30:17 +01:00
Pierre MARTINandClaude Opus 5.5 a657d7d7a9 Analyse de partie : tours, chance, taux d'erreur à la manière de gnubg, format durable
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 09:56:48 +01:00
Pierre MARTINandClaude Opus 5.5 3e7b161933 Fusion de la MR !51 : conseil du maître, une ligne après chaque coup, l'explication sur demande (1d)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 09:32:34 +01:00
pierre ff3871d86f Fusion de la MR !50 : charte 1d, rang du coup 2026-11-10 04:39:36 +00:00
Pierre MARTINandClaude Opus 5.5 02467d32e0 Charte 1d : rang du coup seulement pour un coup proche ou une imprécision ; pastille en paysage
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 05:39:34 +01:00
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 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 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 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
123 changed files with 16088 additions and 681 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
+190
View File
@@ -0,0 +1,190 @@
// 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 { otherSide, type Side } from "../game/side.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 decisionsOf(events, "player");
}
/** Les décisions de side parmi des événements, comme playerDecisions. */
export function decisionsOf(events: readonly GameEvent[], side: Side): GameEvent[] {
return events.filter((event) => {
switch (event.type) {
case "MovePlayed":
return event.side === side && event.halfMoves.length > 0;
case "DiceRolled":
case "CubeOffered":
case "CubeTaken":
case "CubeDropped":
return event.side === side;
default:
return false;
}
});
}
/** Ce qu'on demande à l'analyse d'une décision. */
export interface DecisionOptions {
/** Niveau de l'analyse ; ANALYSIS_PLY par défaut. */
readonly ply?: Ply;
/** Le camp qui décide ; le joueur par défaut. Le conseil est alors vu
* de ce camp (sa position, ses coups). */
readonly side?: Side;
/** Garder le classement des coups (une seconde analyse) ; oui par
* défaut. L'analyse des coups de l'ordinateur s'en passe. */
readonly ranking?: boolean;
}
/**
* Le conseil du moteur sur la décision eventId du joueur (ou du camp
* options.side) ; null s'il n'y a rien à analyser (pas une décision de ce
* camp, aucun coup possible, videau inaccessible ou position de départ pour
* gnubg).
*/
export async function analyseDecision(
engine: Engine,
journal: GameJournal,
eventId: string,
options: DecisionOptions = {},
): Promise<Advice | null> {
const ply = options.ply ?? ANALYSIS_PLY;
const side = options.side ?? "player";
const line = currentLine({ ...journal, head: eventId });
const event = line.at(-1);
if (event === undefined || decisionsOf([event], side).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, side);
// Le coup joué est enregistré vu de l'adversaire : le moteur donne
// la même position vue de celui qui l'a joué.
const after = await engine.board(event.position, engineCube(before, otherSide(side)));
const analysis = await engine.analyseMove(
before.position,
cube,
before.roll,
after.swappedPosition,
ply,
);
if (analysis === null) {
return null;
}
const ranking =
options.ranking === false
? []
: 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: Side = side;
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 = otherSide(side);
}
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 };
}
+579
View File
@@ -0,0 +1,579 @@
// L'analyse d'une partie (étape 1e) : ce qui manque à analyser, le calcul
// de chaque analyse par le moteur, et les chiffres du bilan à la manière de
// GNU Backgammon (taux d'erreur, chance, résultat corrigé de la chance).
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
//
// Tout ce qui juge le jeu vient du moteur : l'analyse de chaque décision
// (équité perdue, appréciation), la chance de chaque jet, l'évaluation des
// positions. Ce module ne fait que les ranger et les additionner, comme le
// fait gnubg dans ses statistiques de partie. Sources, dans gnubg 1.08.003
// (commit 14b67984, ADR 0005), fichiers analysis.c et formatgs.c :
// - updateStatcontext (analysis.c, l. 304-596) : les erreurs de chaque
// décision et leur nombre ;
// - getMWCFromError (analysis.c, l. 1432-1472) : le taux d'erreur ;
// - GetRating et arThrsRating (analysis.c, l. 83-101) : le classement ;
// - formatgs.c, l. 348-374 et getLuckRating (analysis.c, l. 1757) : la
// chance par coup et son appréciation ;
// - arLuckAdj (analysis.c, l. 304-330 et 405-414) : le résultat corrigé de
// la chance.
// Les tâches d'analyse sont indépendantes : l'application les lance une à
// une sur le second moteur, en arrière-plan, et peut s'arrêter entre deux.
import {
type Advice,
type CubeAdvice,
type MoveAdvice,
type Verdict,
verdictOf,
} from "../game/advice.js";
import type { Engine, LuckRating } from "../game/engine.js";
import { LUCK_THRESHOLDS } from "../game/engine.js";
import {
currentLine,
type GameEnded,
type GameEvent,
type GameJournal,
type MovePlayed,
withAdvice,
withReview,
} from "../game/events.js";
import { engineCube } from "../game/game.js";
import { COMPUTER_LEVELS, type ComputerLevel } from "../game/level.js";
import { emptyReview, type GameReview } from "../game/review.js";
import type { Roll } from "../game/roll.js";
import type { Side } from "../game/side.js";
import { applyEvent, replay } from "../game/state.js";
import { ANALYSIS_PLY, analyseDecision, decisionsOf } from "./analyse.js";
// ------------------------------------------------------------ Les tours
/**
* Un tour de la partie : ce qu'un camp a fait quand il avait la main
* (doubler ou non, lancer, jouer), avec la réponse de l'adversaire à son
* double. Les tours sont numérotés à partir de 1 ; c'est le « coup » de
* l'écran d'analyse (#/parties/<id>/analyse/<n>).
*/
export interface Turn {
readonly number: number;
/** Le camp qui avait la main. */
readonly side: Side;
/** Le jet joué (null : le tour s'est fini sur un double passé). */
readonly roll: Roll | null;
/** Le coup joué (null : double passé). */
readonly move: MovePlayed | null;
/** Les événements du tour, dans l'ordre (sans la fin de partie). */
readonly events: readonly GameEvent[];
/** La fin de partie, si elle suit ce tour. */
readonly end: GameEnded | null;
}
/** Les tours d'une ligne de jeu (événements du début à la fin). */
export function turnsOf(line: readonly GameEvent[]): Turn[] {
const turns: Turn[] = [];
let bucket: GameEvent[] = [];
let state = replay([]);
for (const event of line) {
const before = state;
state = applyEvent(state, event);
if (event.type === "GameStarted") {
continue;
}
if (event.type === "GameEnded") {
const last = turns.at(-1);
if (bucket.length === 0 && last !== undefined) {
turns[turns.length - 1] = { ...last, end: event };
} else if (bucket.length > 0) {
const offer = bucket.find((e) => e.type === "CubeOffered");
const side = offer?.type === "CubeOffered" ? offer.side : (before.turn ?? "player");
turns.push(makeTurn(turns.length + 1, side, bucket, null, event));
}
bucket = [];
continue;
}
bucket.push(event);
if (event.type === "MovePlayed") {
turns.push(makeTurn(turns.length + 1, event.side, bucket, event, null));
bucket = [];
}
}
return turns;
}
function makeTurn(
number: number,
side: Side,
events: readonly GameEvent[],
move: MovePlayed | null,
end: GameEnded | null,
): Turn {
const rolled = move === null ? undefined : events.findLast((e) => e.type === "DiceRolled");
return {
number,
side,
roll: rolled?.type === "DiceRolled" ? rolled.roll : null,
move,
events,
end,
};
}
/** Les tours de la ligne courante d'un journal. */
export function gameTurns(journal: GameJournal): Turn[] {
return turnsOf(currentLine(journal));
}
// --------------------------------------------- Ce qui reste à analyser
/** Une analyse à demander au moteur. */
export type AnalysisTask =
/** Une décision (coup ou videau) d'un camp. */
| { readonly kind: "decision"; readonly side: Side; readonly eventId: string }
/** La chance d'un jet. */
| { readonly kind: "luck"; readonly eventId: string }
/** Les chances de gagner du joueur après un coup. */
| { readonly kind: "chance"; readonly eventId: string };
/** L'analyse de partie du journal, au niveau d'analyse ; vide s'il n'y en
* a pas encore (ou à un autre niveau). */
export function reviewOf(journal: GameJournal): GameReview {
const review = journal.review;
return review !== undefined && review.ply === ANALYSIS_PLY ? review : emptyReview(ANALYSIS_PLY);
}
/**
* Ce qui reste à analyser sur la ligne courante, dans l'ordre où le bilan
* en a besoin : les décisions du joueur (celles d'avant l'étape 1d, ou
* dont l'analyse est arrivée trop tard), la chance de chaque jet, puis,
* pour l'écran d'analyse, les décisions de l'ordinateur et les chances de
* gagner après chaque coup.
*/
export function missingAnalyses(journal: GameJournal): AnalysisTask[] {
const line = currentLine(journal);
const review = reviewOf(journal);
const skipped = new Set(review.unanalysed);
const advice = journal.advice ?? {};
const tasks: AnalysisTask[] = [];
for (const event of decisionsOf(line, "player")) {
if (advice[event.id] === undefined && !skipped.has(event.id)) {
tasks.push({ kind: "decision", side: "player", eventId: event.id });
}
}
for (const event of luckyRolls(line)) {
if (review.luck[event.id] === undefined) {
tasks.push({ kind: "luck", eventId: event.id });
}
}
for (const event of decisionsOf(line, "computer")) {
if (review.computer[event.id] === undefined && !skipped.has(event.id)) {
tasks.push({ kind: "decision", side: "computer", eventId: event.id });
}
}
for (const event of line) {
if (event.type === "MovePlayed" && review.winChances[event.id] === undefined) {
tasks.push({ kind: "chance", eventId: event.id });
}
}
return tasks;
}
/** Ce qu'il faut au bilan : toutes les décisions du joueur et la chance
* de tous les jets. */
export function readyForSummary(journal: GameJournal): boolean {
return missingAnalyses(journal).every(
(task) => task.kind === "chance" || (task.kind === "decision" && task.side === "computer"),
);
}
/** Les jets qui ont une chance : tous ceux d'un camp, et le jet
* d'ouverture qui désigne le premier joueur (pas une égalité, qui se
* relance). */
function luckyRolls(line: readonly GameEvent[]): GameEvent[] {
const rolls: GameEvent[] = [];
let state = replay([]);
for (const event of line) {
const before = state;
state = applyEvent(state, event);
if (event.type !== "DiceRolled") {
continue;
}
if (event.side !== null || (before.phase === "opening" && state.phase === "moving")) {
rolls.push(event);
}
}
return rolls;
}
/**
* Fait une analyse avec le moteur et la range dans le journal, qui est
* renvoyé (le journal reçu ne change pas). Une décision que le moteur
* n'analyse pas est notée comme telle, pour ne plus la demander.
*/
export async function runAnalysis(
engine: Engine,
journal: GameJournal,
task: AnalysisTask,
): Promise<GameJournal> {
const review = reviewOf(journal);
switch (task.kind) {
case "decision": {
const advice = await analyseDecision(engine, journal, task.eventId, {
side: task.side,
ranking: task.side === "player",
});
if (advice === null) {
return withReview(journal, {
...review,
unanalysed: [...review.unanalysed, task.eventId],
});
}
return task.side === "player"
? withAdvice(journal, advice)
: withReview(journal, {
...review,
computer: { ...review.computer, [task.eventId]: advice },
});
}
case "luck": {
const line = currentLine({ ...journal, head: task.eventId });
const event = line.at(-1);
if (event?.type !== "DiceRolled") {
throw new Error(`pas un jet : ${task.eventId}`);
}
const before = replay(line.slice(0, -1));
// Le camp qui joue le jet ; au jet d'ouverture, celui qu'il
// désigne. La position de départ est la même pour les deux.
const roller = event.side ?? replay(line).turn ?? "player";
const luck = await engine.luck(before.position, engineCube(before, roller), event.roll);
return withReview(journal, { ...review, luck: { ...review.luck, [event.id]: luck } });
}
case "chance": {
const chance = await chanceAfter(engine, journal, task.eventId);
return withReview(journal, {
...review,
winChances: { ...review.winChances, [task.eventId]: chance },
});
}
}
}
/** Les chances de gagner du joueur après le coup moveId : le résultat si
* la partie finit là, sinon l'évaluation du moteur, vue de celui qui a
* alors la main. */
async function chanceAfter(engine: Engine, journal: GameJournal, moveId: string): Promise<number> {
const line = currentLine(journal);
const index = line.findIndex((e) => e.id === moveId);
const upTo = index >= 0 ? line.slice(0, index + 1) : currentLine({ ...journal, head: moveId });
const next = index >= 0 ? line[index + 1] : undefined;
if (next?.type === "GameEnded") {
return next.winner === "player" ? 1 : 0;
}
const after = replay(upTo);
const onRoll = after.turn ?? "player";
const evaluation = await engine.evaluate(
after.position,
engineCube(after, onRoll),
ANALYSIS_PLY,
);
return onRoll === "player" ? evaluation.win : 1 - evaluation.win;
}
// ------------------------------------------------ Les chiffres du bilan
/**
* Classement de gnubg selon le taux d'erreur par décision (GetRating et
* arThrsRating, analysis.c l. 83-101) : en dessous de 0,005, « Super
* Grandmaster » ; puis 0,008 « Grandmaster », 0,013 « Master », 0,020
* « Advanced », 0,032 « Intermediate », au-delà « Beginner ». Les six
* classements portent les noms des six niveaux de l'ordinateur, dans le
* même ordre (correspondance dans docs/glossaire/taux-d-erreur.md).
*/
export const RATING_THRESHOLDS: readonly number[] = [0.032, 0.02, 0.013, 0.008, 0.005];
/**
* Le taux d'erreur d'un camp, à la manière de gnubg (getMWCFromError,
* analysis.c) : l'équité perdue sur toutes ses décisions, divisée par le
* nombre de coups non forcés et de décisions de videau « serrées ».
*
* Glossaire : docs/glossaire/taux-d-erreur.md
*/
export interface ErrorRate {
/** Équité perdue au total, pour un videau de 1 (positive ou nulle). */
readonly total: number;
/** Nombre de décisions comptées. */
readonly decisions: number;
/** Équité perdue par décision (0 sans décision). */
readonly perDecision: number;
/** Le niveau correspondant, du nom des niveaux de l'ordinateur. */
readonly level: ComputerLevel;
}
/** La chance d'un camp sur la partie, à la manière de gnubg (formatgs.c,
* « Luck total », « Luck rate », « Luck rating »). */
export interface LuckSummary {
/** Somme de la chance de ses jets, pour un videau de 1. */
readonly total: number;
/** Nombre de ses tours avec un jet (anTotalMoves de gnubg). */
readonly rolls: number;
/** Chance par tour (0 sans tour). */
readonly perRoll: number;
/** getLuckRating : la chance par tour, multipliée par 10, comparée aux
* seuils d'un jet (LUCK_THRESHOLDS). */
readonly rating: LuckRating;
/** La chance comptée en points : chaque jet multiplié par la valeur
* du videau à ce moment (arLuck[…][1] de gnubg en partie libre). */
readonly points: number;
}
/** Ce que le bilan dit d'un camp. */
export interface SideSummary {
readonly errorRate: ErrorRate;
readonly luck: LuckSummary;
}
/** Une décision du joueur et son conseil, dans son tour. */
export interface ReviewedDecision {
readonly turn: number;
readonly advice: Advice;
}
/** Le bilan d'une partie (ligne courante). */
export interface GameSummary {
readonly player: SideSummary;
readonly computer: SideSummary;
/** Les décisions du joueur comptées, par appréciation : ses coups, ses
* doubles et ses réponses aux doubles, et ses jets sans doubler
* seulement quand doubler était mieux. */
readonly counts: Readonly<Record<Verdict, number>>;
/** La plus grosse erreur du joueur (null : aucune imprécision ni
* erreur). */
readonly toReview: ReviewedDecision | null;
/** Le résultat de la partie en points pour le joueur (0 si elle n'est
* pas finie), et ce résultat corrigé de la chance des deux camps
* (arLuckAdj de gnubg) : ce qu'il serait sans les dés. */
readonly result: number;
readonly luckAdjustedResult: number;
}
/** Le niveau de gnubg pour un taux d'erreur par décision. */
export function levelOfErrorRate(perDecision: number): ComputerLevel {
let index = 0;
while (index < RATING_THRESHOLDS.length && perDecision < (RATING_THRESHOLDS[index] ?? 0)) {
index++;
}
return COMPUTER_LEVELS[index] ?? "beginner";
}
/** getLuckRating de gnubg : Luck(chance par tour × 10). */
export function luckRatingOf(perRoll: number): LuckRating {
const r = perRoll * 10;
if (r > LUCK_THRESHOLDS.veryLucky) {
return "veryLucky";
}
if (r > LUCK_THRESHOLDS.lucky) {
return "lucky";
}
if (r < -LUCK_THRESHOLDS.veryLucky) {
return "veryUnlucky";
}
if (r < -LUCK_THRESHOLDS.lucky) {
return "unlucky";
}
return "none";
}
/**
* Une décision de videau « serrée » pour gnubg (isCloseCubedecision,
* eval.c l. 5107) : doubler coûte moins de 0,16 par rapport à l'optimum.
* Un jet sans doubler n'est compté dans le taux d'erreur que dans ce cas ;
* un double, une prise ou un refus le sont toujours.
*/
function closeCube(advice: CubeAdvice): boolean {
const e = advice.equities;
return e.optimal - Math.min(e.doubleTake, 1) < 0.16;
}
/** L'erreur d'une décision (équité perdue, positive) et si elle compte
* dans le nombre de décisions (updateStatcontext). */
function errorOf(advice: Advice): { error: number; counted: boolean } {
const error = -Math.min(0, advice.equityLoss);
if (advice.kind === "move") {
// Un coup forcé ne compte pas (anUnforcedMoves).
return { error: advice.moveCount > 1 ? error : 0, counted: advice.moveCount > 1 };
}
return { error, counted: advice.action !== "noDouble" || closeCube(advice) };
}
function errorRateOf(advices: readonly Advice[]): ErrorRate {
let total = 0;
let decisions = 0;
for (const advice of advices) {
const { error, counted } = errorOf(advice);
total += error;
decisions += counted ? 1 : 0;
}
const perDecision = decisions > 0 ? total / decisions : 0;
return { total, decisions, perDecision, level: levelOfErrorRate(perDecision) };
}
/** Le bilan de la ligne courante, avec les analyses déjà faites (les
* manquantes ne comptent pas : voir readyForSummary). */
export function summarizeAnalysis(journal: GameJournal): GameSummary {
const line = currentLine(journal);
const review = reviewOf(journal);
const advice = journal.advice ?? {};
const turns = turnsOf(line);
const turnOf = new Map<string, number>();
for (const turn of turns) {
for (const event of turn.events) {
turnOf.set(event.id, turn.number);
}
}
const decided = (side: Side): Advice[] =>
decisionsOf(line, side)
.map((e) => (side === "player" ? advice[e.id] : review.computer[e.id]))
.filter((a): a is Advice => a !== undefined);
const mine = decided("player");
// La chance de chaque camp, et la valeur du videau à chaque jet.
const luck: Record<Side, { total: number; points: number; rolls: number }> = {
player: { total: 0, points: 0, rolls: 0 },
computer: { total: 0, points: 0, rolls: 0 },
};
let state = replay([]);
for (const event of line) {
const before = state;
state = applyEvent(state, event);
if (event.type === "MovePlayed") {
luck[event.side].rolls++;
}
const value = event.type === "DiceRolled" ? review.luck[event.id] : undefined;
if (value !== undefined && event.type === "DiceRolled") {
const roller = event.side ?? state.turn ?? "player";
luck[roller].total += value.value;
luck[roller].points += value.value * before.cube.value;
}
}
const luckSummary = (side: Side): LuckSummary => {
const { total, points, rolls } = luck[side];
const perRoll = rolls > 0 ? total / rolls : 0;
return { total, rolls, perRoll, rating: luckRatingOf(perRoll), points };
};
const counts: Record<Verdict, number> = { good: 0, doubtful: 0, bad: 0, veryBad: 0 };
let toReview: ReviewedDecision | null = null;
for (const a of mine) {
const verdict = verdictOf(a.skill);
// Un jet sans doubler n'est une décision visible que s'il était
// une faute.
if (!(a.kind === "cube" && a.action === "noDouble" && verdict === "good")) {
counts[verdict]++;
}
if (
verdict !== "good" &&
(toReview === null || a.equityLoss < toReview.advice.equityLoss)
) {
toReview = { turn: turnOf.get(a.eventId) ?? 0, advice: a };
}
}
const ended = line.findLast((e) => e.type === "GameEnded");
const result =
ended?.type === "GameEnded" ? (ended.winner === "player" ? 1 : -1) * ended.points : 0;
const player = luckSummary("player");
const computer = luckSummary("computer");
return {
player: { errorRate: errorRateOf(mine), luck: player },
computer: { errorRate: errorRateOf(decided("computer")), luck: computer },
counts,
toReview,
result,
luckAdjustedResult: result - player.points + computer.points,
};
}
// ------------------------------------------------ Coup par coup
/** Un point de la courbe des chances de gagner : un tour. */
export interface ChancePoint {
readonly turn: number;
readonly side: Side;
/** Chances de gagner du joueur après le tour, de 0 à 1 (null : pas
* encore analysé). */
readonly chance: number | null;
/** L'appréciation de la décision du joueur la plus coûteuse du tour
* (null : aucune décision du joueur analysée). */
readonly verdict: Verdict | null;
}
/**
* La courbe des chances de gagner du joueur, tour après tour : la position
* après chaque coup évaluée par le moteur, et le résultat après le dernier.
*
* Glossaire : docs/glossaire/courbe-des-chances.md
*/
export type WinChanceCurve = readonly ChancePoint[];
/** La décision du joueur à revoir dans un tour : la plus coûteuse, le coup
* à égalité (null : aucune décision du joueur analysée dans ce tour). */
export function mainAdvice(journal: GameJournal, turn: Turn): Advice | null {
const advice = journal.advice ?? {};
let main: Advice | null = null;
for (const event of decisionsOf(turn.events, "player")) {
const a = advice[event.id];
if (a === undefined) {
continue;
}
if (
main === null ||
a.equityLoss < main.equityLoss ||
(a.equityLoss === main.equityLoss && a.kind === "move")
) {
main = a;
}
}
return main;
}
/** Le conseil sur le coup de l'ordinateur d'un tour (null sinon). */
export function computerMoveAdvice(journal: GameJournal, turn: Turn): MoveAdvice | null {
if (turn.side !== "computer" || turn.move === null) {
return null;
}
const advice = reviewOf(journal).computer[turn.move.id];
return advice?.kind === "move" ? advice : null;
}
export function chanceCurve(journal: GameJournal): WinChanceCurve {
const review = reviewOf(journal);
return gameTurns(journal).map((turn) => {
const main = mainAdvice(journal, turn);
let chance: number | null = null;
if (turn.move !== null) {
chance = review.winChances[turn.move.id] ?? null;
}
if (turn.end !== null && (turn.move === null || chance === null)) {
chance = turn.end.winner === "player" ? 1 : 0;
}
return {
turn: turn.number,
side: turn.side,
chance,
verdict: main === null ? null : verdictOf(main.skill),
};
});
}
/**
* « Rejouer d'ici » sur un tour : l'événement où revenir pour rejouer la
* décision à revoir du joueur (juste avant elle : même jet pour un coup,
* même moment pour un videau). null pour un tour sans décision du joueur.
*/
export function replayPoint(journal: GameJournal, turn: Turn): string | null {
const main = mainAdvice(journal, turn);
const decisions = decisionsOf(turn.events, "player");
const event = main === null ? decisions.at(-1) : decisions.find((e) => e.id === main.eventId);
return event?.parent ?? null;
}
+312
View File
@@ -0,0 +1,312 @@
// 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, LuckRating } from "../game/engine.js";
import type { GameSummary } from "./game-analysis.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",
"bilan.jeu.gagne",
"bilan.jeu.perdu",
"bilan.des",
"bilan.des.tres-pour",
"bilan.des.pour",
"bilan.des.neutres",
"bilan.des.contre",
"bilan.des.tres-contre",
"bilan.aucun-coup",
];
const DICE_KEYS: Readonly<Record<LuckRating, string>> = {
veryLucky: "bilan.des.tres-pour",
lucky: "bilan.des.pour",
none: "bilan.des.neutres",
unlucky: "bilan.des.contre",
veryUnlucky: "bilan.des.tres-contre",
};
/** La chance de vos dés sur la partie, en mots : « un peu contre vous »… */
export function diceWords(phrases: MasterPhrases, rating: LuckRating): string {
return pick(phrases, DICE_KEYS[rating], "0");
}
/**
* La lecture du bilan d'une partie finie : ce qui a fait la différence,
* selon le résultat corrigé de la chance (gnubg) ; et vos dés, en mots.
* Partie gagnée : votre jeu, si le résultat corrigé de la chance reste
* positif, sinon les dés ; partie perdue : le jeu de l'ordinateur, si le
* résultat corrigé reste négatif, sinon les dés.
*/
export function summaryReading(phrases: MasterPhrases, summary: GameSummary): string {
const dice = diceWords(phrases, summary.player.luck.rating);
const won = summary.result > 0;
const playDecided = won ? summary.luckAdjustedResult > 0 : summary.luckAdjustedResult < 0;
if (!playDecided) {
return pick(phrases, "bilan.des", "0");
}
return fillIn(pick(phrases, won ? "bilan.jeu.gagne" : "bilan.jeu.perdu", "0"), { dice });
}
/** « Aucun coup à revoir… » : la carte du coup à revoir sans erreur. */
export function nothingToReview(phrases: MasterPhrases): string {
return pick(phrases, "bilan.aucun-coup", "0");
}
/** 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;
}
+66 -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,13 @@ 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. */
/** L'évaluation d'une position, joueur au trait avant de lancer les
* dés, au niveau ply : ses chances de gain (sans videau) et son équité
* avec videau. */
evaluate(position: PositionId, cube: EngineCube, ply: Ply): Promise<Evaluation>;
/** 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. */
@@ -160,6 +194,16 @@ export interface Luck {
readonly rating: LuckRating;
}
/** Une position évaluée par le moteur, du point de vue du joueur au
* trait. */
export interface Evaluation {
/** Chances de gagner la partie, de 0 à 1, sans tenir compte du videau
* (première probabilité de gnubg). */
readonly win: number;
/** Équité avec videau, normalisée pour un videau de 1. */
readonly equity: number;
}
/** Un coup avec son équité (avec videau), du point de vue de celui qui le joue. */
export interface EvaluatedMove extends Move {
readonly equity: number;
@@ -212,10 +256,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;
}
/**
+168 -7
View File
@@ -12,14 +12,131 @@
// 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 GameReview, REVIEW_VERSION } from "./review.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 = keepKnownReview(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) };
}
/**
* L'analyse de partie d'un journal enregistré (étape 1e) : oubliée si elle
* est d'une autre version (REVIEW_VERSION) ou mal formée ; sinon, seules
* restent ses entrées rattachées à un événement du journal. Ce n'est
* qu'une analyse du moteur, qui peut être refaite. Un journal sans analyse
* (enregistré avant l'étape 1e) reste tel quel.
*/
function keepKnownReview(journal: GameJournal): GameJournal {
const review = journal.review;
if (review === undefined) {
return journal;
}
const tables = [review.luck, review.winChances, review.computer];
const wellFormed =
typeof review === "object" &&
review !== null &&
review.version === REVIEW_VERSION &&
typeof review.ply === "number" &&
tables.every((table) => typeof table === "object" && table !== null) &&
Array.isArray(review.unanalysed);
if (!wellFormed) {
const { review: _, ...rest } = journal;
return rest;
}
const ids = new Set(journal.events.map((event) => event.id));
const known = <T>(table: Readonly<Record<string, T>>) =>
Object.fromEntries(Object.entries(table).filter(([id]) => ids.has(id)));
const computer = Object.fromEntries(
Object.entries(review.computer).filter(
([id, a]) =>
ids.has(id) &&
typeof a === "object" &&
a !== null &&
a.version === ADVICE_VERSION &&
a.eventId === id,
),
);
const kept: GameReview = {
...review,
luck: known(review.luck),
winChances: known(review.winChances),
computer,
unanalysed: review.unanalysed.filter((id) => ids.has(id)),
};
// Le nombre d'entrées de chaque table : rien d'oublié, rien ne change.
const sizes = (r: GameReview) =>
[r.luck, r.winChances, r.computer, r.unanalysed].map((t) => Object.keys(t).length).join();
return sizes(kept) === sizes(review) ? journal : { ...journal, review: kept };
}
interface EventBase {
/** Identifiant, unique dans la partie. */
@@ -31,12 +148,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 +203,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 +230,17 @@ 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.
* review (étape 1e, facultatif) : le reste de l'analyse de la partie (chance
* des jets, chances de gagner, décisions de l'ordinateur), pour le bilan. */
export interface GameJournal {
readonly events: readonly GameEvent[];
readonly head: string | null;
readonly advice?: Readonly<Record<string, Advice>>;
readonly review?: GameReview;
}
export const EMPTY_JOURNAL: GameJournal = { events: [], head: null };
@@ -120,7 +253,35 @@ 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 } };
}
/** Remplace l'analyse de partie du journal (elle ne change pas la
* partie). */
export function withReview(journal: GameJournal, review: GameReview): GameJournal {
return { ...journal, review };
}
/** Le journal avec les analyses d'une copie de lui-même, faite en
* arrière-plan : ses conseils s'ajoutent, son analyse de partie remplace
* celle du journal. Les événements ne changent pas. */
export function withAnalysesOf(journal: GameJournal, analysed: GameJournal): GameJournal {
const ids = new Set(journal.events.map((event) => event.id));
const advice = Object.entries(analysed.advice ?? {}).filter(([id]) => ids.has(id));
return {
...journal,
advice: { ...Object.fromEntries(advice), ...journal.advice },
...(analysed.review === undefined ? {} : { review: analysed.review }),
};
}
/** Les événements du début jusqu'à l'événement courant, dans l'ordre. */
@@ -144,5 +305,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 };
}
+183 -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,20 @@ import {
type GameEvent,
type GameJournal,
goBackTo,
migrateJournal,
type NewGameEvent,
withAdvice,
withAnalysesOf,
} 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 +68,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 +91,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 +100,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 +113,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 +126,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 +157,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 +221,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 +230,65 @@ 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);
}
/** Range dans le journal les analyses faites en arrière-plan sur une
* copie de celui-ci (étape 1e : conseils, analyse de partie). */
recordAnalyses(analysed: GameJournal): void {
this.#journal = withAnalysesOf(this.#journal, analysed);
}
/**
@@ -260,10 +328,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 +418,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 +513,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 +542,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 +550,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 +667,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
+55
View File
@@ -0,0 +1,55 @@
// L'analyse de la partie par le moteur, rangée dans le journal à côté du
// conseil du maître (étape 1e) : la chance de chaque jet, les chances de
// gagner après chaque coup, et les décisions de l'ordinateur.
//
// 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. Le calcul est dans domain/analysis/game-analysis.ts. Comme le
// conseil (advice.ts), ce n'est pas un événement : l'analyse arrive après
// coup et ne change pas la partie. Le format est durable (ADR 0007) : il
// porte sa version (REVIEW_VERSION), et une analyse d'une autre version
// est oubliée à la reprise (elle peut être refaite).
import type { Advice } from "./advice.js";
import type { Luck, Ply } from "./engine.js";
/** Version du format de l'analyse de partie enregistrée. */
export const REVIEW_VERSION = 1;
/**
* L'analyse de la partie, en plus des conseils du maître : de quoi
* calculer le bilan (taux d'erreur, chance) et tracer la courbe des chances
* de gagner. Chaque table est indexée par l'identifiant d'un événement du
* journal ; une table incomplète est complétée en arrière-plan.
*/
export interface GameReview {
readonly version: number;
/** Niveau des analyses et des évaluations, en plis. */
readonly ply: Ply;
/** Pour chaque jet (DiceRolled, ouverture comprise), sa chance pour le
* camp qui le joue, selon le moteur. */
readonly luck: Readonly<Record<string, Luck>>;
/** Pour chaque coup (MovePlayed), les chances de gagner du joueur dans
* la position d'après, de 0 à 1, selon le moteur. */
readonly winChances: Readonly<Record<string, number>>;
/** Les décisions de l'ordinateur, analysées comme celles du joueur (le
* conseil d'un coup de l'ordinateur est vu de lui). */
readonly computer: Readonly<Record<string, Advice>>;
/** Les décisions (des deux camps) que le moteur n'analyse pas : videau
* inaccessible, position de départ. Elles ne sont plus demandées. */
readonly unanalysed: readonly string[];
}
/** Une analyse vide, au niveau ply. */
export function emptyReview(ply: Ply): GameReview {
return {
version: REVIEW_VERSION,
ply,
luck: {},
winChances: {},
computer: {},
unanalysed: [],
};
}
+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;
}
+59 -4
View File
@@ -17,7 +17,9 @@ import type {
CubeDecision,
Engine,
EngineCube,
EngineMatch,
EvaluatedMove,
Evaluation,
HalfMovesCheck,
Level,
Luck,
@@ -75,13 +77,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 +141,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 +196,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,
@@ -215,6 +261,15 @@ export function createWasmEngine(run: RunCommand): Engine {
return { value: answer.luck, rating };
},
async evaluate(position: PositionId, cube: EngineCube, ply: Ply): Promise<Evaluation> {
const args = ["--ply", String(ply), "eval", position, await matchId(cube)];
const answer = (await run(args)) as {
probabilities: { win: number };
cubeful_equity: number;
};
return { win: answer.probabilities.win, equity: answer.cubeful_equity };
},
encodeMatch: matchId,
async decodeMatch(id: MatchId): Promise<MatchDescription> {
+81 -23
View File
@@ -5,21 +5,55 @@
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
import { analyseDecision } from "./domain/analysis/analyse.js";
import { runAnalysis } from "./domain/analysis/game-analysis.js";
import { Game } from "./domain/game/game.js";
import { linkOf, readPosition } from "./domain/game/position-link.js";
import { levelOf } from "./domain/game/saved-game.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 +67,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 +92,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),
@@ -74,6 +105,22 @@ startApp({
journal: async (id) => (await storage().library.load(id))?.journal ?? null,
shownId: () => storage().session.shownId,
open: (id, branchHead) => storage().session.open(id, branchHead),
view: async (id) => {
const saved = await storage().library.load(id);
if (saved === null) {
return null;
}
try {
return Game.resume(engine, saved.journal);
} catch {
return null;
}
},
saveAnalyses: (id, journal) => {
storage()
.library.saveAnalyses(id, journal, { level: levelOf(journal) })
.catch(() => {});
},
remove: (id) => storage().library.remove(id),
exportSgf: async (id) => {
const game = await storage().library.load(id);
@@ -96,20 +143,31 @@ 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),
// Le bilan et l'analyse de partie (étape 1e) : les analyses qui
// manquent, une à une, sur le même moteur à part.
analyseGame: (journal, task) => runAnalysis(analysisEngine, journal, task),
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 {
+13
View File
@@ -33,6 +33,10 @@ export interface GameLibrary {
/** Enregistre la partie id (nouvelle ou non), qui devient la partie en
* cours. Renvoie son résumé. */
save(id: string, journal: GameJournal, details: SavedGameDetails): Promise<SavedGameSummary>;
/** Enregistre le journal d'une partie analysée en arrière-plan (étape
* 1e) : ni la partie en cours ni la date de la dernière action ne
* changent. Rien si la partie n'existe plus. */
saveAnalyses(id: string, journal: GameJournal, details: SavedGameDetails): Promise<void>;
/** Supprime une partie (rien si elle n'existe pas). */
remove(id: string): Promise<void>;
/** La partie en cours : la dernière enregistrée ou choisie (setCurrent),
@@ -118,6 +122,15 @@ export function createGameLibrary(
await backend.setCurrentId(id);
return summary;
}),
saveAnalyses: (id, journal, details) =>
enqueue(async () => {
const known = await backend.summary(id);
if (known === undefined) {
return;
}
const times = { startedAt: known.startedAt, updatedAt: known.updatedAt };
await backend.write(summarizeGame(id, journal, details, times), journal);
}),
remove: (id) => enqueue(() => backend.remove(id)),
current: () =>
enqueue(async () => {
+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;
}
+343
View File
@@ -0,0 +1,343 @@
// 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 } from "../domain/game/game.js";
import { ADVICE_TEXTS, CUBE_GLOSSARY, formatEquity } from "./advice-texts.js";
import { type IconName, icon } from "./icons.js";
import { boardSvg, createMovePlayer, drawSteps, type MovePlayer } from "./move-board.js";
import { routeHash } from "./router.js";
/** Préfixe des identifiants du plateau de la feuille. */
const PREFIX = "explain-";
/** 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;
// Le plateau de la feuille joue un coup à la fois.
let player: MovePlayer | null = null;
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, et ce qui y joue les
* coups. */
function sheetBoard(box: HTMLElement): SVGSVGElement {
const svg = boardSvg(box, "explain-board", "explain-board");
player = createMovePlayer(svg, PREFIX, options.reducedMotion);
return svg;
}
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 = sheetBoard(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);
void player?.play(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;
void player?.play(shown === "mine" ? mine : best);
};
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, "explain-board", "explain-board");
svg.setAttribute("aria-label", ADVICE_TEXTS.legendCube);
drawSteps(svg, steps, 0, false, PREFIX);
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++;
player?.stop();
if (!veil.hidden) {
veil.hidden = true;
sheet.replaceChildren();
}
},
};
}
+682
View File
@@ -0,0 +1,682 @@
// L'écran d'analyse d'une partie (#/parties/<id>/analyse/<coup>, étape
// 1e ; charte, ecrans-etape-1.md, « Analyse ») : une page qui défile, avec
// la courbe de vos chances de gagner coup après coup, parcourue du doigt ;
// le visualiseur du coup choisi (votre coup contre le coup du maître,
// « Rejouer d'ici ») ; la liste des coups à revoir, filtrable ; et les
// chiffres du moteur, repliés.
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
//
// La courbe est dessinée à la main en SVG (aucune bibliothèque). Chaque
// coup y a une bande verticale sur toute la hauteur, cible du doigt : on
// touche ou on glisse, et le coup sous le doigt est choisi. Le coup choisi
// est dans l'adresse ; en changer remplace l'adresse (le retour du
// téléphone quitte l'écran, il ne repasse pas par chaque coup).
import {
chanceCurve,
computerMoveAdvice,
type GameSummary,
gameTurns,
mainAdvice,
replayPoint,
summarizeAnalysis,
type Turn,
type WinChanceCurve,
} from "../domain/analysis/game-analysis.js";
import { type MasterPhrases, masterWords } from "../domain/analysis/phrases.js";
import { type Advice, type Verdict, verdictOf } from "../domain/game/advice.js";
import type { GameJournal } from "../domain/game/events.js";
import type { Game, MoveSteps } from "../domain/game/game.js";
import { formatHalfMoves } from "../domain/game/position.js";
import { VERDICT_ICONS } from "./advice-view.js";
import { icon } from "./icons.js";
import { MATCH_TEXTS } from "./match-texts.js";
import { boardSvg, createMovePlayer, drawSteps, type MovePlayer } from "./move-board.js";
import { percent, REVIEW_TEXTS, signed } from "./review-texts.js";
import { routeHash } from "./router.js";
/** Ce que montre l'écran. */
export interface AnalysisData {
readonly id: string;
readonly journal: GameJournal;
/** La partie, pour rejouer les coups sur le plateau. */
readonly game: Game;
/** Le coup choisi (null : le coup à revoir, ou le premier). */
readonly turn: number | null;
readonly phrases: MasterPhrases | null;
}
export interface AnalysisViewOptions {
/** La page (index.html) ; son contenu est construit ici. */
readonly section: HTMLElement;
readonly reducedMotion: MediaQueryList;
/** Choisir un autre coup : remplacer l'adresse. */
readonly select: (turn: number) => void;
/** « Rejouer d'ici » : revenir à cet événement de la partie. */
readonly replay: (id: string, eventId: string) => void;
/** Le lien « Retour » (quitter l'écran sans empiler d'adresse). */
readonly back: (link: HTMLAnchorElement) => void;
}
export interface AnalysisView {
show(data: AnalysisData | null): void;
hide(): void;
/** ← et → : le coup d'avant ou d'après. */
step(delta: number): void;
}
type Filter = "all" | "errors" | "doubtful";
type Shown = "mine" | "best";
const SVG_NS = "http://www.w3.org/2000/svg";
/** Hauteur de la courbe, et marges du tracé (repère en pixels). */
const CURVE_HEIGHT = 168;
const PAD = { left: 40, right: 10, top: 10, bottom: 10 };
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 svgElement<K extends keyof SVGElementTagNameMap>(
parent: Element,
tag: K,
attributes: Record<string, string | number>,
): SVGElementTagNameMap[K] {
const child = document.createElementNS(SVG_NS, tag);
for (const [name, value] of Object.entries(attributes)) {
child.setAttribute(name, String(value));
}
parent.append(child);
return child;
}
/** Les verdicts que montre chaque filtre. */
const FILTERS: Readonly<Record<Filter, readonly Verdict[]>> = {
all: ["good", "doubtful", "bad", "veryBad"],
errors: ["bad", "veryBad"],
doubtful: ["doubtful"],
};
export function createAnalysisView(options: AnalysisViewOptions): AnalysisView {
const { section } = options;
let data: AnalysisData | null = null;
let turns: Turn[] = [];
let curve: WinChanceCurve = [];
let selected = 1;
let filter: Filter = "all";
// Ce qui est dessiné dans le visualiseur, pour ne pas rejouer le coup à
// chaque analyse rangée.
let drawnTurn = "";
let player: MovePlayer | null = null;
// Chaque changement de coup a son numéro : un plateau qui arrive trop
// tard n'est pas dessiné.
let request = 0;
// La page : en-tête, courbe, visualiseur, liste, chiffres.
const head = element(section, "div", { className: "analysis-head" });
const back = element(head, "a", {
id: "analysis-back",
className: "button secondary",
text: REVIEW_TEXTS.back,
});
back.href = "#/";
options.back(back);
element(head, "h2", {
id: "analysis-title",
className: "analysis-title",
text: REVIEW_TEXTS.analysisTitle,
});
const message = element(section, "p", {
id: "analysis-message",
className: "analysis-message",
});
message.setAttribute("role", "status");
const curveCard = element(section, "section", { className: "analysis-card curve-card" });
element(curveCard, "h3", { className: "overline", text: REVIEW_TEXTS.curveTitle });
const curveSvg = document.createElementNS(SVG_NS, "svg");
curveSvg.id = "analysis-curve";
curveSvg.setAttribute("class", "analysis-curve");
curveSvg.setAttribute("role", "slider");
curveSvg.setAttribute("tabindex", "0");
curveCard.append(curveSvg);
const viewer = element(section, "section", {
id: "analysis-viewer",
className: "analysis-card viewer",
});
const nav = element(viewer, "div", { className: "viewer-nav" });
const prev = element(nav, "a", { id: "analysis-prev", className: "icon-button viewer-step" });
prev.append(icon("chevron"));
prev.setAttribute("aria-label", REVIEW_TEXTS.previous);
const turnTitle = element(nav, "p", { id: "analysis-turn", className: "viewer-turn" });
turnTitle.setAttribute("aria-live", "polite");
const next = element(nav, "a", { id: "analysis-next", className: "icon-button viewer-step" });
next.append(icon("chevron"));
next.setAttribute("aria-label", REVIEW_TEXTS.next);
for (const [link, delta] of [
[prev, -1],
[next, 1],
] as const) {
link.addEventListener("click", (event) => {
event.preventDefault();
step(delta);
});
}
const viewerBody = element(viewer, "div", { className: "viewer-body" });
const boardBox = element(viewerBody, "div", { className: "viewer-board-box" });
const board = boardSvg(boardBox, "analysis-board", "analysis-board");
player = createMovePlayer(board, "analysis-", options.reducedMotion);
const viewerText = element(viewerBody, "div", { className: "viewer-text" });
const phrase = element(viewerText, "p", { id: "analysis-phrase", className: "viewer-phrase" });
const choice = element(viewerText, "div", { id: "analysis-choice", className: "segmented" });
choice.setAttribute("role", "radiogroup");
choice.setAttribute("aria-label", REVIEW_TEXTS.choiceLabel);
const segments: Record<Shown, HTMLButtonElement> = {
mine: element(choice, "button", { id: "analysis-mine", className: "segment" }),
best: element(choice, "button", {
id: "analysis-best",
className: "segment",
text: REVIEW_TEXTS.masterMove,
}),
};
for (const key of ["mine", "best"] as const) {
segments[key].type = "button";
segments[key].setAttribute("role", "radio");
}
const legend = element(viewerText, "p", { id: "analysis-legend", className: "viewer-legend" });
const chanceLine = element(viewerText, "p", {
id: "analysis-chance",
className: "viewer-chance",
});
const viewerActions = element(viewerText, "div", { className: "viewer-actions" });
const replayButton = element(viewerActions, "button", {
id: "analysis-replay",
text: REVIEW_TEXTS.replayFromHere,
});
replayButton.type = "button";
// L'emplacement de « Demander à une IA » (étape 1f).
const slot = element(viewerActions, "div", { className: "ask-ai-slot" });
slot.dataset.slot = "ask-ai";
const replayHint = element(viewerText, "p", {
className: "viewer-legend",
text: REVIEW_TEXTS.replayHint,
});
const listCard = element(section, "section", { className: "analysis-card list-card" });
element(listCard, "h3", { className: "overline", text: REVIEW_TEXTS.listTitle });
const filters = element(listCard, "div", { id: "analysis-filters", className: "filters" });
filters.setAttribute("role", "group");
filters.setAttribute("aria-label", REVIEW_TEXTS.filterLabel);
const filterButtons: Record<Filter, HTMLButtonElement> = {
all: element(filters, "button", { className: "filter" }),
errors: element(filters, "button", { className: "filter" }),
doubtful: element(filters, "button", { className: "filter" }),
};
for (const key of ["all", "errors", "doubtful"] as const) {
const button = filterButtons[key];
button.type = "button";
button.dataset.filter = key;
button.onclick = () => {
filter = key;
renderList();
};
}
const list = element(listCard, "ul", { id: "analysis-list", className: "analysis-list" });
const numbers = element(section, "details", {
id: "analysis-numbers",
className: "analysis-card explain-numbers",
});
element(numbers, "summary", { text: REVIEW_TEXTS.numbers });
const table = element(numbers, "div", { className: "explain-table analysis-table" });
element(numbers, "p", { className: "explain-note", text: REVIEW_TEXTS.numbersNote });
// ------------------------------------------------------------ Courbe
/** Le coup sous l'abscisse x (pixels du tracé). */
function turnAt(x: number): number {
const width = curveSvg.getBoundingClientRect().width;
const inner = Math.max(1, width - PAD.left - PAD.right);
const index = Math.floor(((x - PAD.left) / inner) * turns.length);
return Math.min(turns.length, Math.max(1, index + 1));
}
let dragging = false;
curveSvg.addEventListener("pointerdown", (event) => {
dragging = true;
curveSvg.setPointerCapture?.(event.pointerId);
choose(turnAt(event.clientX - curveSvg.getBoundingClientRect().left));
});
curveSvg.addEventListener("pointermove", (event) => {
if (dragging) {
choose(turnAt(event.clientX - curveSvg.getBoundingClientRect().left));
}
});
for (const end of ["pointerup", "pointercancel"] as const) {
curveSvg.addEventListener(end, () => {
dragging = false;
});
}
curveSvg.addEventListener("keydown", (event) => {
if (event.key === "ArrowLeft" || event.key === "ArrowRight") {
event.preventDefault();
event.stopPropagation();
step(event.key === "ArrowLeft" ? -1 : 1);
}
});
function renderCurve(): void {
const width = Math.round(curveSvg.getBoundingClientRect().width || 360);
const height = CURVE_HEIGHT;
curveSvg.setAttribute("viewBox", `0 0 ${width} ${height}`);
curveSvg.replaceChildren();
const inner = width - PAD.left - PAD.right;
const band = turns.length > 0 ? inner / turns.length : inner;
const x = (turn: number) => PAD.left + (turn - 0.5) * band;
const y = (chance: number) => PAD.top + (1 - chance) * (height - PAD.top - PAD.bottom);
// Axe : 100 %, 50 % (pointillé), 0 %.
for (const level of [1, 0.5, 0]) {
svgElement(curveSvg, "line", {
x1: PAD.left,
x2: width - PAD.right,
y1: y(level),
y2: y(level),
class: level === 0.5 ? "curve-half" : "curve-axis",
});
const label = svgElement(curveSvg, "text", {
x: PAD.left - 6,
y: y(level) + 4,
class: "curve-label",
"text-anchor": "end",
});
label.textContent = percent(level);
}
const known = curve.filter((p) => p.chance !== null) as {
turn: number;
chance: number;
verdict: Verdict | null;
side: string;
}[];
if (known.length > 0) {
const line = known.map((p) => `${x(p.turn)},${y(p.chance)}`).join(" ");
const first = known[0] as { turn: number };
const last = known.at(-1) as { turn: number };
svgElement(curveSvg, "polygon", {
points: `${x(first.turn)},${y(0)} ${line} ${x(last.turn)},${y(0)}`,
class: "curve-area",
});
svgElement(curveSvg, "polyline", { points: line, class: "curve-line" });
}
// Le coup choisi : un trait vertical brique.
svgElement(curveSvg, "line", {
x1: x(selected),
x2: x(selected),
y1: PAD.top,
y2: height - PAD.bottom,
class: "curve-cursor",
});
for (const point of known) {
const commented = point.verdict !== null;
svgElement(curveSvg, "circle", {
cx: x(point.turn),
cy: y(point.chance),
r: commented ? 4 : 2,
class: commented ? `curve-dot verdict-${point.verdict}` : "curve-dot other",
});
}
const chosen = curve[selected - 1];
if (chosen?.chance != null) {
svgElement(curveSvg, "circle", {
cx: x(selected),
cy: y(chosen.chance),
r: 7,
class: "curve-ring",
});
}
// Les bandes, sur toute la hauteur : cibles du doigt.
for (const turn of turns) {
const rect = svgElement(curveSvg, "rect", {
x: PAD.left + (turn.number - 1) * band,
y: 0,
width: band,
height,
class: turn.number === selected ? "curve-band selected" : "curve-band",
});
rect.dataset.turn = String(turn.number);
}
const chance = chosen?.chance;
curveSvg.setAttribute("aria-valuemin", "1");
curveSvg.setAttribute("aria-valuemax", String(turns.length));
curveSvg.setAttribute("aria-valuenow", String(selected));
const spoken =
chance === null || chance === undefined ? REVIEW_TEXTS.curveUnknown : percent(chance);
curveSvg.setAttribute("aria-valuetext", spoken);
curveSvg.setAttribute(
"aria-label",
REVIEW_TEXTS.curveLabel(selected, turns.length, spoken),
);
}
// -------------------------------------------------------- Visualiseur
function renderViewer(): void {
const current = data;
const turn = turns[selected - 1];
if (current === null || turn === undefined) {
return;
}
prev.href = routeHash({
screen: "analysis",
game: current.id,
turn: Math.max(1, selected - 1),
});
next.href = routeHash({
screen: "analysis",
game: current.id,
turn: Math.min(turns.length, selected + 1),
});
prev.setAttribute("aria-disabled", String(selected <= 1));
next.setAttribute("aria-disabled", String(selected >= turns.length));
const roll = turn.roll === null ? "" : ` · ${REVIEW_TEXTS.roll(turn.roll)}`;
turnTitle.textContent = `${REVIEW_TEXTS.turnOf(selected, turns.length)}${roll}`;
const chance = curve[selected - 1]?.chance;
chanceLine.textContent =
chance === null || chance === undefined
? ""
: REVIEW_TEXTS.chanceAfter(percent(chance));
const main = mainAdvice(current.journal, turn);
const theirs = computerMoveAdvice(current.journal, turn);
const key = [
current.id,
selected,
main?.eventId ?? "",
theirs?.eventId ?? "",
current.phrases !== null,
].join("|");
replayButton.hidden = replayPoint(current.journal, turn) === null;
replayHint.hidden = replayButton.hidden;
replayButton.onclick = () => {
const point = replayPoint(current.journal, turn);
if (point !== null) {
options.replay(current.id, point);
}
};
if (key === drawnTurn) {
return;
}
drawnTurn = key;
player?.stop();
phrase.replaceChildren();
delete phrase.dataset.verdict;
legend.textContent = "";
const id = ++request;
// Ce qu'en dit le maître : la décision du joueur à revoir, ou ce
// qu'a fait l'ordinateur.
if (main !== null && current.phrases !== null) {
const words = masterWords(current.phrases, main);
phrase.dataset.verdict = words.verdict;
const word = document.createElement("strong");
word.append(icon(VERDICT_ICONS[words.verdict]), `${words.word}.`);
const said = document.createElement("em");
said.textContent = words.phrase;
phrase.append(word, " ", said);
} else if (turn.side === "computer") {
phrase.textContent =
turn.move === null
? REVIEW_TEXTS.computerCube
: turn.move.halfMoves.length === 0
? REVIEW_TEXTS.computerCannotMove
: REVIEW_TEXTS.computerPlayed(
theirs?.played.notation ?? formatHalfMoves(turn.move.halfMoves),
);
} else if (turn.move !== null && turn.move.halfMoves.length === 0) {
phrase.textContent = REVIEW_TEXTS.playerCannotMove;
}
// Le plateau : le coup joué et, s'il diffère, le meilleur coup.
void (async () => {
const side = turn.side;
let mine: MoveSteps | null = null;
let best: MoveSteps | null = null;
// Les coups s'écrivent dans la notation du moteur, comme dans le
// bilan et la feuille du maître (« 6/off », pas « 6/5 5/off ») ;
// à défaut d'analyse, à partir des demi-coups.
let names = { mine: "", best: "" };
const moveAdvice: Advice | null = side === "player" ? main : theirs;
try {
if (moveAdvice?.kind === "move") {
mine = await current.game.stepsBefore(
moveAdvice.eventId,
side,
moveAdvice.played.halfMoves,
);
names = {
mine: moveAdvice.played.notation,
best: moveAdvice.best.notation,
};
if (moveAdvice.rank > 1) {
best = await current.game.stepsBefore(
moveAdvice.eventId,
side,
moveAdvice.best.halfMoves,
);
}
} else if (moveAdvice?.kind === "cube") {
mine = await current.game.stepsBefore(moveAdvice.eventId, "player", []);
} else if (turn.move !== null) {
mine = await current.game.stepsBefore(turn.move.id, side, turn.move.halfMoves);
names = { mine: formatHalfMoves(turn.move.halfMoves), best: "" };
} else {
const first = turn.events[0];
mine =
first === undefined
? null
: await current.game.stepsBefore(first.id, side, []);
}
} catch {
mine = null;
}
if (id !== request || mine === null) {
return;
}
showMoves(mine, best, side, names);
})();
}
function showMoves(
mine: MoveSteps,
best: MoveSteps | null,
side: "player" | "computer",
names: { readonly mine: string; readonly best: string },
): void {
const who = side === "player" ? REVIEW_TEXTS.yourMoveSegment : REVIEW_TEXTS.theirMove;
segments.mine.textContent = who;
choice.hidden = best === null;
const select = (shown: Shown) => {
const steps = shown === "best" && best !== null ? best : mine;
for (const key of ["mine", "best"] as const) {
segments[key].setAttribute("aria-checked", String(key === shown));
}
board.dataset.move = shown;
if (steps.halfMoves.length === 0) {
legend.textContent = REVIEW_TEXTS.legendPosition;
board.setAttribute("aria-label", REVIEW_TEXTS.legendPosition);
drawSteps(board, steps, 0, false, "analysis-");
return;
}
const text = REVIEW_TEXTS.legendMove(
shown === "best" ? REVIEW_TEXTS.masterMove : who,
shown === "best" ? names.best : names.mine,
);
legend.textContent = text;
board.setAttribute("aria-label", text);
void player?.play(steps);
};
segments.mine.onclick = () => select("mine");
segments.best.onclick = () => select("best");
choice.onkeydown = (event) => {
if (event.key === "ArrowLeft" || event.key === "ArrowRight") {
event.preventDefault();
event.stopPropagation();
const nextShown =
segments.mine.getAttribute("aria-checked") === "true" ? "best" : "mine";
select(nextShown);
segments[nextShown].focus();
}
};
select("mine");
}
// -------------------------------------------------------------- Liste
function renderList(): void {
const current = data;
if (current === null) {
return;
}
const rows = turns
.map((turn) => ({ turn, advice: mainAdvice(current.journal, turn) }))
.filter((row): row is { turn: Turn; advice: Advice } => row.advice !== null);
const count = (f: Filter) =>
rows.filter((r) => FILTERS[f].includes(verdictOf(r.advice.skill))).length;
for (const key of ["all", "errors", "doubtful"] as const) {
filterButtons[key].textContent = `${REVIEW_TEXTS.filters[key]} · ${count(key)}`;
filterButtons[key].setAttribute("aria-pressed", String(key === filter));
}
list.replaceChildren();
const shown = rows.filter((r) => FILTERS[filter].includes(verdictOf(r.advice.skill)));
if (shown.length === 0) {
element(list, "li", { className: "analysis-empty", text: REVIEW_TEXTS.emptyFilter });
return;
}
for (const { turn, advice } of shown) {
const item = element(list, "li");
const link = element(item, "a", { className: "analysis-row" });
link.href = routeHash({ screen: "analysis", game: current.id, turn: turn.number });
link.dataset.turn = String(turn.number);
if (turn.number === selected) {
link.setAttribute("aria-current", "true");
}
const verdict = verdictOf(advice.skill);
const word = current.phrases === null ? "" : masterWords(current.phrases, advice).word;
const roll = turn.roll === null ? "" : REVIEW_TEXTS.roll(turn.roll);
const move =
advice.kind === "move"
? advice.played.notation
: REVIEW_TEXTS.cubeActions[advice.action];
link.setAttribute("aria-label", REVIEW_TEXTS.rowLabel(turn.number, roll, move, word));
element(link, "span", { className: "row-number", text: String(turn.number) });
element(link, "span", { className: "row-roll", text: roll });
element(link, "span", { className: "row-move", text: move });
const mark = element(link, "span", { className: "row-word" });
mark.dataset.verdict = verdict;
mark.append(icon(VERDICT_ICONS[verdict]), word);
link.addEventListener("click", (event) => {
if (event.button !== 0 || event.ctrlKey || event.metaKey || event.shiftKey) {
return;
}
event.preventDefault();
choose(turn.number);
});
}
}
// ------------------------------------------------- Chiffres du moteur
function renderNumbers(summary: GameSummary): void {
table.replaceChildren();
const cell = (text: string, className = "") => element(table, "span", { className, text });
cell("");
cell(REVIEW_TEXTS.numbersHead.you, "strong");
cell(REVIEW_TEXTS.numbersHead.computer, "strong");
const rows: [string, (s: GameSummary["player"]) => string][] = [
[REVIEW_TEXTS.numbersRows.errorRate, (s) => signed(-s.errorRate.perDecision)],
[REVIEW_TEXTS.numbersRows.decisions, (s) => String(s.errorRate.decisions)],
[REVIEW_TEXTS.numbersRows.level, (s) => MATCH_TEXTS.levels[s.errorRate.level].name],
[REVIEW_TEXTS.numbersRows.luck, (s) => signed(s.luck.perRoll)],
];
for (const [name, value] of rows) {
cell(name);
cell(value(summary.player));
cell(value(summary.computer));
}
}
// ---------------------------------------------------------- Ensemble
function choose(turn: number): void {
if (turn === selected || turn < 1 || turn > turns.length) {
return;
}
selected = turn;
renderCurve();
renderViewer();
renderList();
options.select(turn);
}
function step(delta: number): void {
choose(selected + delta);
}
window.addEventListener("resize", () => {
if (!section.hidden && data !== null) {
renderCurve();
}
});
return {
show(next) {
data = next;
if (next === null) {
message.textContent = REVIEW_TEXTS.missingGame;
curveCard.hidden = true;
viewer.hidden = true;
listCard.hidden = true;
numbers.hidden = true;
return;
}
turns = gameTurns(next.journal);
curve = chanceCurve(next.journal);
const summary = summarizeAnalysis(next.journal);
const fallback = summary.toReview?.turn ?? 1;
selected = Math.min(Math.max(1, next.turn ?? fallback), Math.max(1, turns.length));
message.textContent = turns.length === 0 ? REVIEW_TEXTS.emptyFilter : "";
curveCard.hidden = turns.length === 0;
viewer.hidden = turns.length === 0;
listCard.hidden = turns.length === 0;
numbers.hidden = turns.length === 0;
renderCurve();
renderViewer();
renderList();
renderNumbers(summary);
},
hide() {
data = null;
drawnTurn = "";
player?.stop();
request++;
},
step,
};
}
+893 -47
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 {
+1
View File
@@ -28,6 +28,7 @@ export const GAMES_TEXTS = {
close: "Fermer",
resume: "Reprendre",
showBranches: "Branches",
summary: "Bilan",
resumeBranch: "Reprendre cette branche",
exportSgf: "Exporter (SGF)",
remove: "Supprimer",
+10
View File
@@ -123,6 +123,7 @@ export function createGamesView(options: GamesViewOptions): GamesView {
"resume",
),
branchesLink(game.id),
...(game.status === "over" ? [summaryLink(game.id)] : []),
button(
GAMES_TEXTS.exportSgf,
run(async () => download(await options.exportSgf(game.id))),
@@ -171,6 +172,15 @@ export function createGamesView(options: GamesViewOptions): GamesView {
return link;
}
/** « Bilan » : le lien vers le bilan d'une partie finie (étape 1e). */
function summaryLink(id: string): HTMLAnchorElement {
const link = element("a", "button secondary");
link.textContent = GAMES_TEXTS.summary;
link.dataset.action = "summary";
link.href = routeHash({ screen: "summary", game: id });
return link;
}
async function renderBranches(id: string, target: HTMLElement): Promise<void> {
const journal = await options.journal(id);
const all = journal === null ? [] : branches(journal);
+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));
}
+115 -21
View File
@@ -8,25 +8,36 @@
//
// 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/revoir-ma-partie.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 +70,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 +124,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 +149,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 +164,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 +173,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 +307,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 +327,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.",
};
+109
View File
@@ -0,0 +1,109 @@
// Un petit plateau qui rejoue un coup pion par pion, sur la position
// d'avant le coup, puis laisse ses traces : la feuille d'explication du
// maître (1d), le coup à revoir du bilan et le visualiseur de l'analyse
// (1e).
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
import type { MoveSteps } from "../domain/game/game.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";
const SLIDE_MS = 160;
const PAUSE_MS = 120;
/** Un plateau SVG vide, dans son cadre. */
export function boardSvg(box: HTMLElement, id: string, className: string): SVGSVGElement {
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
svg.id = id;
svg.setAttribute("class", `board ${className}`);
svg.setAttribute("role", "img");
box.append(svg);
return svg;
}
/**
* Dessine le plateau d'un coup après index demi-coups (0 : avant le
* coup), avec ses traces si traced. Les flèches des traces renvoient à leur
* pointe par identifiant : prefix les distingue de celles du plateau de jeu.
*/
export function drawSteps(
svg: SVGSVGElement,
steps: MoveSteps,
index: number,
traced: boolean,
prefix: string,
): 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,
});
for (const marker of svg.querySelectorAll("marker[id]")) {
marker.id = `${prefix}${marker.id}`;
}
for (const path of svg.querySelectorAll("[marker-end]")) {
const ref = path.getAttribute("marker-end") ?? "";
path.setAttribute("marker-end", ref.replace("url(#", `url(#${prefix}`));
}
}
/** Un plateau qui joue des coups ; un coup demandé arrête le précédent. */
export interface MovePlayer {
/** Joue le coup pion par pion (sans mouvement si le système le
* demande), puis laisse ses traces. */
play(steps: MoveSteps): Promise<void>;
/** Arrête le coup en cours. */
stop(): void;
}
export function createMovePlayer(
svg: SVGSVGElement,
prefix: string,
reducedMotion: MediaQueryList,
): MovePlayer {
let playing = 0;
return {
async play(steps) {
const id = ++playing;
drawSteps(svg, steps, 0, false, prefix);
if (!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);
drawSteps(svg, steps, i, false, prefix);
const start = checkerSpot(svg, from);
const bar = steps.side === "player" ? "bar-computer" : "bar-player";
const victim = steps.hits[i] === true ? checkerSpot(svg, to) : null;
drawSteps(svg, steps, i + 1, false, prefix);
await Promise.all([
start && slideChecker(svg, to, start, SLIDE_MS),
victim && slideChecker(svg, bar, victim, SLIDE_MS),
]);
}
}
if (id === playing) {
drawSteps(svg, steps, steps.halfMoves.length, true, prefix);
}
},
stop() {
playing++;
},
};
}
+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();
},
};
}
+87
View File
@@ -0,0 +1,87 @@
// L'analyse d'une partie en arrière-plan (étape 1e) : les analyses qui
// manquent au bilan et à l'écran d'analyse sont demandées une à une au
// second moteur, rangées au fur et à mesure, et l'ensemble s'arrête dès
// qu'on n'en a plus besoin (une autre partie, une nouvelle partie). Sans
// DOM : testé sous Node (app/tests/review-runner.test.ts).
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
//
// Une analyse déjà partie vers le moteur ne s'interrompt pas : l'arrêt
// prend effet entre deux analyses, et le résultat d'une analyse arrêtée
// n'est pas rangé.
import { type AnalysisTask, missingAnalyses } from "../domain/analysis/game-analysis.js";
import type { GameJournal } from "../domain/game/events.js";
/** Fait une analyse et renvoie le journal qui la contient. */
export type RunAnalysis = (journal: GameJournal, task: AnalysisTask) => Promise<GameJournal>;
export interface ReviewRunner {
/** La partie en cours d'analyse (null : aucune). */
readonly running: string | null;
/**
* Analyse ce qui manque au journal de la partie id ; record reçoit le
* journal après chaque analyse rangée, done une fois tout analysé.
* Une analyse en cours d'une autre partie est arrêtée ; la même partie
* continue sans repartir.
*/
start(
id: string,
journal: GameJournal,
record: (journal: GameJournal) => void,
done?: () => void,
): void;
/** Arrête l'analyse en cours. */
stop(): void;
}
export function createReviewRunner(run: RunAnalysis): ReviewRunner {
// Chaque analyse lancée a son jeton : stop() ou une autre partie
// l'annulent.
let token: object | null = null;
let running: string | null = null;
return {
get running() {
return running;
},
start(id, journal, record, done) {
if (running === id && token !== null) {
return;
}
const mine = {};
token = mine;
running = id;
void (async () => {
let current = journal;
try {
for (;;) {
const task = missingAnalyses(current)[0];
if (task === undefined) {
break;
}
const next = await run(current, task);
if (token !== mine) {
return;
}
current = next;
record(current);
}
} catch {
// Le moteur a échoué : l'analyse s'arrête là, ce qui est
// rangé reste, et la prochaine demande reprendra.
}
if (token === mine) {
token = null;
running = null;
done?.();
}
})();
},
stop() {
token = null;
running = null;
},
};
}
+99
View File
@@ -0,0 +1,99 @@
// Les textes de l'interface du bilan et de l'écran d'analyse (étape 1e).
// Les phrases du maître elles-mêmes (lecture du bilan, dés en mots) 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";
import type { Roll } from "../domain/game/roll.js";
const plural = (n: number, one: string, many: string) => (n > 1 ? many : one);
/** « 41 % ». */
export function percent(chance: number): string {
return `${Math.round(chance * 100)} %`;
}
/** Un nombre à la française, signé, à trois décimales : « +0,021 ». */
export function signed(value: number): string {
const text = Math.abs(value).toFixed(3).replace(".", ",");
return value < -0.0005 ? `−${text}` : value > 0.0005 ? `+${text}` : text;
}
export const REVIEW_TEXTS = {
// Bilan
summaryLabel: "Bilan de la partie",
analysing: "Le maître analyse la partie…",
progress: (done: number, total: number) => `Analyse : ${done} sur ${total}`,
notOver: "Cette partie n'est pas finie : son bilan viendra à la fin.",
missingGame: "Cette partie n'est plus sur ce téléphone.",
counts: {
good: (n: number) => plural(n, "Bon coup", "Bons coups"),
doubtful: (n: number) => plural(n, "Imprécision", "Imprécisions"),
bad: (n: number) => plural(n, "Erreur", "Erreurs"),
},
countsLabel: "Vos décisions, par appréciation",
yourPlay: "Votre jeu",
theDice: "Les dés",
playGauge: (level: string) => `Votre jeu : niveau ${level}`,
diceGauge: (words: string) => `Les dés : ${words}`,
howToRead: "Comment lire ces mesures",
toReview: "Le coup à revoir",
yourMove: (notation: string) => `Votre coup : ${notation}`,
yourDecision: (action: string) => `Votre décision : ${action}`,
reviewMove: "Revoir ce coup",
seeAnalysis: "Voir l'analyse",
newGame: "Nouvelle partie",
close: "Fermer",
cubeActions: {
double: "doubler",
noDouble: "lancer sans doubler",
take: "prendre",
pass: "passer",
} satisfies Record<CubeAction, string>,
toReviewBoard: "La position au moment de la décision à revoir.",
// Analyse
analysisTitle: "Analyse de la partie",
back: "Retour",
curveTitle: "Vos chances de gagner",
curveLabel: (turn: number, total: number, chance: string) =>
`Courbe de vos chances de gagner : coup ${turn} sur ${total}, ${chance}. Flèches gauche et droite pour changer de coup.`,
curveUnknown: "pas encore calculé",
turnOf: (turn: number, total: number) => `Coup ${turn} sur ${total}`,
roll: (roll: Roll) => `${roll[0]}-${roll[1]}`,
previous: "Coup précédent",
next: "Coup suivant",
who: { player: "Vous", computer: "L'ordinateur" },
computerPlayed: (notation: string) => `L'ordinateur a joué ${notation}.`,
computerCannotMove: "L'ordinateur ne pouvait pas jouer.",
playerCannotMove: "Vous ne pouviez pas jouer.",
computerCube: "L'ordinateur a doublé.",
chanceAfter: (chance: string) => `Vos chances de gagner après ce coup : ${chance}`,
theirMove: "Son coup",
masterMove: "Le coup du maître",
yourMoveSegment: "Votre coup",
choiceLabel: "Coup montré sur le plateau",
legendMove: (who: string, notation: string) =>
`${who} : ${notation}, joué pion par pion sur la position d'avant le coup.`,
legendPosition: "La position avant la décision.",
replayFromHere: "Rejouer d'ici",
replayHint: "Une nouvelle branche : la partie d'origine reste intacte.",
listTitle: "Les coups à revoir",
filters: { all: "Tout", errors: "Erreurs", doubtful: "Imprécisions" },
filterLabel: "Filtrer les coups",
emptyFilter: "Aucun coup de ce genre dans la partie.",
rowLabel: (turn: number, roll: string, move: string, word: string) =>
`Coup ${turn}, ${roll}, ${move} : ${word}`,
numbers: "Voir les chiffres du moteur",
numbersRows: {
errorRate: "Erreur par décision",
decisions: "Décisions comptées",
luck: "Chance par tour",
level: "Niveau",
},
numbersHead: { you: "Vous", computer: "L'ordinateur" },
numbersNote:
"Calculés comme GNU Backgammon : équité perdue par décision non forcée, chance des dés par tour, à 2 plis.",
} as const;
+84 -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,28 @@ 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 }
/** #/parties/<identifiant>/bilan : le bilan de fin de partie (1e). */
| { readonly screen: "summary"; readonly game: string }
/** #/parties/<identifiant>/analyse/<coup> : l'écran d'analyse, placé
* sur un coup (un tour de la partie, à partir de 1) ; sans coup :
* #/parties/<identifiant>/analyse. */
| { readonly screen: "analysis"; readonly game: string; readonly turn: number | 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 +74,12 @@ export function parseRoute(hash: string): Route {
if (head === "position") {
return positionRoute(rest.join("/")) ?? unknown;
}
if (head === "tutoriel") {
return tutorialRoute(rest) ?? unknown;
}
if (head === "parties" && rest.length > 1) {
return gameReviewRoute(rest) ?? unknown;
}
if (rest.length > 1) {
return unknown;
}
@@ -78,20 +100,55 @@ 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;
}
}
/** #/parties/<id>/bilan, #/parties/<id>/analyse, #/parties/<id>/analyse/<n>. */
function gameReviewRoute(rest: readonly string[]): Route | null {
const [game = "", part, turn, ...more] = rest;
if (!NAME.test(game) || more.length > 0) {
return null;
}
if (part === "bilan") {
return turn === undefined ? { screen: "summary", game } : null;
}
if (part !== "analyse") {
return null;
}
if (turn === undefined) {
return { screen: "analysis", game, turn: null };
}
return /^[1-9][0-9]{0,3}$/.test(turn) ? { screen: "analysis", game, turn: Number(turn) } : null;
}
/** #/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 +191,27 @@ 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 "summary":
return `#/parties/${route.game}/bilan`;
case "analysis":
return route.turn === null
? `#/parties/${route.game}/analyse`
: `#/parties/${route.game}/analyse/${route.turn}`;
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
);
}
+362
View File
@@ -0,0 +1,362 @@
// Le bilan de fin de partie (#/parties/<id>/bilan, étape 1e ; charte,
// ecrans-etape-1.md, « Bilan ») : un panneau centré sur un voile, à la
// place du panneau de fin de partie. Le résultat et sa lecture, les
// décisions par appréciation, deux jauges (votre jeu, les dés), le coup à
// revoir, puis « Voir l'analyse » et l'action qui suit la partie.
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
//
// Tout ce qu'il affiche vient de l'analyse du moteur (game-analysis.ts) et
// des phrases du maître (docs/maitre/phrases.md) ; tant que l'analyse
// n'est pas prête, il dit où elle en est.
import {
type GameSummary,
readyForSummary,
summarizeAnalysis,
} from "../domain/analysis/game-analysis.js";
import {
diceWords,
type MasterPhrases,
masterWords,
nothingToReview,
summaryReading,
} from "../domain/analysis/phrases.js";
import { masterCubeAction } from "../domain/game/advice.js";
import type { GameJournal } from "../domain/game/events.js";
import type { Game } from "../domain/game/game.js";
import { COMPUTER_LEVELS } from "../domain/game/level.js";
import { VERDICT_ICONS } from "./advice-view.js";
import { icon } from "./icons.js";
import { MATCH_TEXTS } from "./match-texts.js";
import { boardSvg, drawSteps } from "./move-board.js";
import { REVIEW_TEXTS } from "./review-texts.js";
import { routeHash } from "./router.js";
import { TEXTS } from "./texts.js";
/** Ce que montre le bilan. */
export interface SummaryData {
readonly id: string;
readonly journal: GameJournal;
/** La partie, pour dessiner la position du coup à revoir. */
readonly game: Game;
/** Le titre et le détail du résultat (null : partie pas finie). */
readonly end: { readonly title: string; readonly detail: string } | null;
/** L'action qui suit la partie, si c'est la partie à l'écran :
* « Rejouer » ou « Partie suivante ». Sinon, « Nouvelle partie » mène à
* l'écran des choix. */
readonly afterLabel: string | null;
readonly phrases: MasterPhrases | null;
}
export interface SummaryViewOptions {
/** Le voile du bilan (index.html) ; le panneau est construit ici. */
readonly veil: HTMLElement;
/** La croix : revenir au plateau. */
readonly close: () => void;
/** « Rejouer » ou « Partie suivante ». */
readonly again: () => void;
}
export interface SummaryView {
/** Ouvre (ou met à jour) le bilan. */
show(data: SummaryData | null): void;
hide(): void;
readonly isOpen: boolean;
}
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;
}
const DICE_SIDE = {
veryLucky: "for-2",
lucky: "for-1",
none: "even",
unlucky: "against-1",
veryUnlucky: "against-2",
} as const;
export function createSummaryView(options: SummaryViewOptions): SummaryView {
const { veil } = options;
const panel = element(veil, "div", { id: "summary", className: "panel summary-panel" });
panel.setAttribute("role", "dialog");
panel.setAttribute("aria-modal", "true");
panel.setAttribute("aria-labelledby", "summary-title");
veil.addEventListener("click", (event) => {
if (event.target === veil) {
options.close();
}
});
// Ce qui est dessiné, pour ne redessiner qu'en cas de changement (le
// bilan est mis à jour à chaque analyse rangée).
let drawn = "";
function render(data: SummaryData | null): void {
const ready = data !== null && data.phrases !== null && readyForSummary(data.journal);
const summary = ready ? summarizeAnalysis(data.journal) : null;
// Seulement ce que le bilan montre : les analyses qui arrivent
// ensuite en arrière-plan pour l'écran d'analyse (coups de
// l'ordinateur, chances de gagner) ne le redessinent pas, sinon le
// panneau et son mini-plateau sauteraient sous les yeux du joueur.
const shown =
summary === null || data === null || data.phrases === null
? null
: {
counts: summary.counts,
level: summary.player.errorRate.level,
dice: summary.player.luck.rating,
reading: summaryReading(data.phrases, summary),
toReview: summary.toReview?.advice.eventId ?? null,
turn: summary.toReview?.turn ?? null,
};
const key =
data === null
? "missing"
: [
data.id,
data.end?.title ?? "",
data.end?.detail ?? "",
data.afterLabel ?? "",
ready,
JSON.stringify(shown),
].join("|");
if (key === drawn) {
return;
}
drawn = key;
panel.replaceChildren();
delete panel.dataset.ready;
const head = element(panel, "div", { className: "summary-head" });
element(head, "p", { className: "overline", text: TEXTS.endOverline });
const close = element(head, "button", { id: "summary-close", className: "icon-button" });
close.type = "button";
close.append(icon("close"));
close.setAttribute("aria-label", REVIEW_TEXTS.close);
close.onclick = () => options.close();
const title = element(panel, "h2", { id: "summary-title", className: "summary-title" });
if (data === null) {
title.textContent = REVIEW_TEXTS.summaryLabel;
element(panel, "p", { className: "summary-reading", text: REVIEW_TEXTS.missingGame });
return;
}
if (data.end === null) {
title.textContent = REVIEW_TEXTS.summaryLabel;
element(panel, "p", { className: "summary-reading", text: REVIEW_TEXTS.notOver });
actions(data);
return;
}
title.textContent = data.end.title;
const reading = element(panel, "p", {
id: "summary-reading",
className: "summary-reading",
});
reading.textContent =
summary !== null && data.phrases !== null
? `${data.end.detail} ${summaryReading(data.phrases, summary)}`
: data.end.detail;
if (summary === null || data.phrases === null) {
const status = element(panel, "p", {
id: "summary-status",
className: "summary-status",
text: REVIEW_TEXTS.analysing,
});
status.setAttribute("role", "status");
actions(data);
return;
}
panel.dataset.ready = "true";
counts(summary);
gauges(summary, data.phrases);
toReview(data, summary, data.phrases);
actions(data);
}
function counts(summary: GameSummary): void {
const box = element(panel, "div", { id: "summary-counts", className: "summary-counts" });
box.setAttribute("role", "list");
box.setAttribute("aria-label", REVIEW_TEXTS.countsLabel);
const cells = [
{ verdict: "good", n: summary.counts.good, word: REVIEW_TEXTS.counts.good },
{
verdict: "doubtful",
n: summary.counts.doubtful,
word: REVIEW_TEXTS.counts.doubtful,
},
{
verdict: "bad",
n: summary.counts.bad + summary.counts.veryBad,
word: REVIEW_TEXTS.counts.bad,
},
] as const;
for (const cell of cells) {
const item = element(box, "div", { className: "summary-count" });
item.setAttribute("role", "listitem");
item.dataset.verdict = cell.verdict;
element(item, "span", { className: "summary-number", text: String(cell.n) });
const word = element(item, "span", { className: "summary-word" });
word.append(icon(VERDICT_ICONS[cell.verdict]), cell.word(cell.n));
}
}
function gauges(summary: GameSummary, phrases: MasterPhrases): void {
const box = element(panel, "div", { className: "summary-gauges" });
const level = summary.player.errorRate.level;
const levelName = MATCH_TEXTS.levels[level].name;
const play = element(box, "div", { id: "gauge-play", className: "gauge" });
play.setAttribute("aria-label", REVIEW_TEXTS.playGauge(levelName));
play.setAttribute("role", "img");
element(play, "span", { className: "gauge-name", text: REVIEW_TEXTS.yourPlay });
const track = element(play, "span", { className: "gauge-track" });
element(track, "span", {
className: `gauge-fill level-${COMPUTER_LEVELS.indexOf(level) + 1}`,
});
element(play, "span", { className: "gauge-value", text: levelName });
const words = diceWords(phrases, summary.player.luck.rating);
const dice = element(box, "div", { id: "gauge-dice", className: "gauge" });
dice.setAttribute("aria-label", REVIEW_TEXTS.diceGauge(words));
dice.setAttribute("role", "img");
element(dice, "span", { className: "gauge-name", text: REVIEW_TEXTS.theDice });
const centred = element(dice, "span", { className: "gauge-track centred" });
element(centred, "span", {
className: `gauge-fill dice ${DICE_SIDE[summary.player.luck.rating]}`,
});
element(dice, "span", { className: "gauge-value", text: words });
const link = element(box, "a", {
id: "summary-glossary",
className: "link-button summary-glossary",
text: REVIEW_TEXTS.howToRead,
});
link.href = routeHash({ screen: "glossary", term: "taux-d-erreur" });
}
function toReview(data: SummaryData, summary: GameSummary, phrases: MasterPhrases): void {
const card = element(panel, "section", {
id: "summary-review",
className: "summary-review",
});
card.setAttribute("aria-labelledby", "summary-review-title");
element(card, "h3", {
id: "summary-review-title",
className: "overline",
text: REVIEW_TEXTS.toReview,
});
const reviewed = summary.toReview;
if (reviewed === null) {
element(card, "p", { className: "summary-none", text: nothingToReview(phrases) });
return;
}
const advice = reviewed.advice;
const words = masterWords(phrases, advice);
const body = element(card, "div", { className: "summary-review-body" });
const box = element(body, "div", { className: "summary-board-box" });
const svg = boardSvg(box, "summary-board", "summary-board");
svg.setAttribute("aria-label", REVIEW_TEXTS.toReviewBoard);
const halfMoves = advice.kind === "move" ? advice.played.halfMoves : [];
void data.game
.stepsBefore(advice.eventId, "player", halfMoves)
.then((steps) => drawSteps(svg, steps, steps.halfMoves.length, true, "summary-"))
.catch(() => undefined);
const text = element(body, "div", { className: "summary-review-text" });
const verdict = element(text, "p", { className: "summary-verdict" });
verdict.dataset.verdict = words.verdict;
verdict.append(icon(VERDICT_ICONS[words.verdict]), `${words.word} · ${words.context}`);
element(text, "p", {
className: "summary-played",
text:
advice.kind === "move"
? REVIEW_TEXTS.yourMove(advice.played.notation)
: REVIEW_TEXTS.yourDecision(REVIEW_TEXTS.cubeActions[advice.action]),
});
element(text, "p", {
className: "summary-master",
text:
advice.kind === "move" || masterCubeAction(advice) !== advice.action
? words.title
: words.phrase,
});
const links = element(card, "div", { className: "summary-review-actions" });
const revisit = element(links, "a", {
id: "summary-revisit",
className: "button secondary",
text: REVIEW_TEXTS.reviewMove,
});
revisit.href = routeHash({ screen: "analysis", game: data.id, turn: reviewed.turn });
// L'emplacement de « Demander à une IA » (étape 1f).
const slot = element(links, "div", { className: "ask-ai-slot" });
slot.dataset.slot = "ask-ai";
}
function actions(data: SummaryData): void {
// Les actions et le lien des réglages, ensemble : en paysage, en bas
// de la colonne de droite.
const end = element(panel, "div", { className: "summary-end" });
const bar = element(end, "div", { className: "panel-actions summary-actions" });
const analysis = element(bar, "a", {
id: "summary-analysis",
className: "button secondary",
text: REVIEW_TEXTS.seeAnalysis,
});
analysis.href = routeHash({ screen: "analysis", game: data.id, turn: null });
if (data.afterLabel !== null) {
const again = element(bar, "button", {
id: "summary-primary",
text: data.afterLabel,
});
again.type = "button";
again.onclick = () => options.again();
const settings = element(end, "a", {
id: "summary-settings",
className: "panel-link",
text: MATCH_TEXTS.changeSettings,
});
settings.href = routeHash({ screen: "newGame" });
} else {
const start = element(bar, "a", {
id: "summary-primary",
className: "button",
text: REVIEW_TEXTS.newGame,
});
start.href = routeHash({ screen: "newGame" });
}
}
return {
show(data) {
const opening = veil.hidden;
veil.hidden = false;
render(data);
if (opening) {
panel.querySelector<HTMLElement>("#summary-close")?.focus();
}
},
hide() {
if (!veil.hidden) {
veil.hidden = true;
panel.replaceChildren();
delete panel.dataset.ready;
drawn = "";
}
},
get isOpen() {
return !veil.hidden;
},
};
}
+11 -9
View File
@@ -136,15 +136,18 @@ export const TEXTS = {
// Fin de partie
gameOver: "Partie finie",
endOverline: "Fin de partie",
/** Titre du panneau de fin : qui gagne, et comment. */
endTitle: (winner: "player" | "computer", kind: ResultKind, dropped: boolean) => {
if (dropped) {
return winner === "player" ? "L'ordinateur passe" : "Vous passez";
}
return winner === "player"
/** Titre du panneau de fin et du bilan : toujours l'issue, qui gagne
* et comment (un double passé se dit dans le détail). */
endTitle: (winner: "player" | "computer", kind: ResultKind) =>
winner === "player"
? `Vous gagnez ${RESULT_KIND[kind]}`
: `L'ordinateur gagne ${RESULT_KIND[kind]}`;
},
: `L'ordinateur gagne ${RESULT_KIND[kind]}`,
/** Avant le détail des points, quand la partie finit sur un double
* passé : « Vous avez passé son double : 1 point pour lui. ». */
droppedDouble: (winner: "player" | "computer") =>
winner === "player"
? "L'ordinateur a passé votre double : "
: "Vous avez passé son double : ",
/** « 8 points pour lui, videau à 4. » */
endDetail: (winner: "player" | "computer", n: number, cube: number) =>
`${points(n)} pour ${winner === "player" ? "vous" : "lui"}${
@@ -164,7 +167,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;
+318
View File
@@ -0,0 +1,318 @@
// L'analyse de partie (étape 1e) : les tours, le format durable de
// l'analyse et sa migration, les chiffres du bilan à la manière de gnubg,
// et une partie complète analysée par 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 {
chanceCurve,
gameTurns,
levelOfErrorRate,
luckRatingOf,
mainAdvice,
missingAnalyses,
readyForSummary,
replayPoint,
runAnalysis,
summarizeAnalysis,
} from "../src/domain/analysis/game-analysis.js";
import { ADVICE_VERSION, type Advice, type CubeAdvice } from "../src/domain/game/advice.js";
import type { Engine } from "../src/domain/game/engine.js";
import {
appendEvent,
EMPTY_JOURNAL,
type GameJournal,
migrateJournal,
type NewGameEvent,
withAnalysesOf,
} from "../src/domain/game/events.js";
import { Game } from "../src/domain/game/game.js";
import { START_POSITION } from "../src/domain/game/position.js";
import { emptyReview, REVIEW_VERSION } from "../src/domain/game/review.js";
import { loadNodeEngine } from "./support/node-engine.js";
import { playToTheEnd, seededDice } from "./support/play.js";
/** Un journal fait d'événements donnés, à la suite. */
function journalOf(events: readonly NewGameEvent[]): GameJournal {
return events.reduce(appendEvent, EMPTY_JOURNAL);
}
const START: NewGameEvent = {
type: "GameStarted",
position: START_POSITION,
cube: { value: 1, owner: null },
firstTurn: null,
};
const P = START_POSITION;
/** Une petite partie : ouverture (égalité puis 3-1 pour le joueur), un
* coup de chaque camp, puis le joueur double et l'ordinateur passe. */
const SHORT: NewGameEvent[] = [
START,
{ type: "DiceRolled", side: null, roll: [2, 2] },
{ type: "DiceRolled", side: null, roll: [3, 1] },
{ type: "MovePlayed", side: "player", halfMoves: [{ from: 8, to: 5 }], position: P },
{ type: "DiceRolled", side: "computer", roll: [6, 5] },
{ type: "MovePlayed", side: "computer", halfMoves: [{ from: 24, to: 13 }], position: P },
{ type: "CubeOffered", side: "player" },
{ type: "CubeDropped", side: "computer" },
{ type: "GameEnded", winner: "player", kind: "single", points: 1 },
];
test("les tours d'une partie : ouverture, coups, double passé", () => {
const journal = journalOf(SHORT);
const turns = gameTurns(journal);
assert.deepEqual(
turns.map((t) => [t.number, t.side, t.roll, t.move?.id ?? null, t.end?.id ?? null]),
[
[1, "player", [3, 1], "4", null],
[2, "computer", [6, 5], "6", null],
[3, "player", null, null, "9"],
],
);
// L'égalité d'ouverture appartient au premier tour.
assert.deepEqual(
turns[0]?.events.map((e) => e.id),
["2", "3", "4"],
);
// Le jet d'ouverture qui désigne le joueur a une chance, l'égalité non.
const luck = missingAnalyses(journal).filter((t) => t.kind === "luck");
assert.deepEqual(
luck.map((t) => t.eventId),
["3", "5"],
);
});
/** Un conseil fictif sur un coup du joueur. */
function moveAdvice(eventId: string, equityLoss: number, moveCount = 10): Advice {
const move = { notation: "8/5", halfMoves: [], equity: 0, ply: 2 };
return {
version: ADVICE_VERSION,
kind: "move",
eventId,
ply: 2,
position: P,
roll: [3, 1],
opening: true,
moveCount,
rank: equityLoss === 0 ? 1 : 2,
played: move,
best: move,
playedFacts: { hits: 0, madePoints: [] },
bestFacts: { hits: 0, madePoints: [] },
ranking: [],
equityLoss,
skill:
equityLoss < -0.12
? "veryBad"
: equityLoss < -0.06
? "bad"
: equityLoss < -0.03
? "doubtful"
: "none",
};
}
function cubeAdvice(eventId: string, action: CubeAdvice["action"], equityLoss: number): Advice {
return {
version: ADVICE_VERSION,
kind: "cube",
eventId,
ply: 2,
action,
position: P,
equities: { noDouble: 0.5, doubleTake: 0.6, doublePass: 1, optimal: 0.6 },
equityLoss,
skill: equityLoss < -0.06 ? "bad" : "none",
};
}
test("le taux d'erreur, le niveau et la chance à la manière de gnubg", () => {
// arThrsRating de gnubg : 0,032, 0,020, 0,013, 0,008, 0,005 ; les six
// classements de gnubg prennent, dans l'ordre, les noms des six niveaux
// de l'ordinateur (Advanced de gnubg : Intermédiaire).
assert.equal(levelOfErrorRate(0.05), "beginner");
assert.equal(levelOfErrorRate(0.032), "beginner");
assert.equal(levelOfErrorRate(0.031), "amateur");
assert.equal(levelOfErrorRate(0.015), "intermediate");
assert.equal(levelOfErrorRate(0.01), "advanced");
assert.equal(levelOfErrorRate(0.006), "expert");
assert.equal(levelOfErrorRate(0.001), "champion");
// getLuckRating : chance par tour × 10, seuils 0,3 et 0,6.
assert.equal(luckRatingOf(0.07), "veryLucky");
assert.equal(luckRatingOf(0.04), "lucky");
assert.equal(luckRatingOf(0.02), "none");
assert.equal(luckRatingOf(-0.04), "unlucky");
assert.equal(luckRatingOf(-0.07), "veryUnlucky");
const base = journalOf(SHORT);
const journal: GameJournal = {
...base,
advice: {
"4": moveAdvice("4", -0.08),
"7": cubeAdvice("7", "double", 0),
},
review: {
...emptyReview(2),
luck: {
"3": { value: 0.2, rating: "none" },
"5": { value: -0.1, rating: "none" },
},
computer: { "6": moveAdvice("6", 0) },
},
};
assert.ok(readyForSummary(journal));
const summary = summarizeAnalysis(journal);
// Un coup non forcé (0,08) et un double (compté toujours) : 0,04 par
// décision.
assert.equal(summary.player.errorRate.decisions, 2);
assert.ok(Math.abs(summary.player.errorRate.perDecision - 0.04) < 1e-9);
assert.equal(summary.player.errorRate.level, "beginner");
assert.deepEqual(summary.counts, { good: 1, doubtful: 0, bad: 1, veryBad: 0 });
assert.equal(summary.toReview?.advice.eventId, "4");
assert.equal(summary.toReview?.turn, 1);
// Une seule fois la main pour chacun : chance par tour égale au total.
assert.equal(summary.player.luck.rolls, 1);
assert.equal(summary.player.luck.rating, "veryLucky");
assert.equal(summary.computer.luck.rating, "veryUnlucky");
// Gagné 1 point, avec 0,2 de chance contre −0,1 pour l'ordinateur :
// 1 − 0,2 − 0,1.
assert.equal(summary.result, 1);
assert.ok(Math.abs(summary.luckAdjustedResult - 0.7) < 1e-9);
// Rejouer le premier tour : juste avant le coup, avec le même jet.
const first = gameTurns(journal)[0];
assert.ok(first !== undefined);
assert.equal(mainAdvice(journal, first)?.eventId, "4");
assert.equal(replayPoint(journal, first), "3");
// La courbe : le dernier tour finit la partie, gagnée.
assert.deepEqual(
chanceCurve(journal).map((p) => [p.turn, p.chance, p.verdict]),
[
[1, null, "bad"],
[2, null, null],
[3, 1, "good"],
],
);
});
test("un jet sans doubler ne compte que si la décision est serrée (isCloseCubedecision)", () => {
const base = journalOf([
{ ...START, firstTurn: "player" },
{ type: "DiceRolled", side: "player", roll: [3, 1] },
]);
const noDouble = (equities: CubeAdvice["equities"]): GameJournal => ({
...base,
advice: { "2": { ...(cubeAdvice("2", "noDouble", 0) as CubeAdvice), equities } },
});
// Doubler coûterait 0,2 par rapport à l'optimum : pas serré.
const far = noDouble({ noDouble: 0.5, doubleTake: 0.3, doublePass: 1, optimal: 0.5 });
assert.equal(summarizeAnalysis(far).player.errorRate.decisions, 0);
// Doubler est l'optimum : serré, compté.
const close = noDouble({ noDouble: 0.5, doubleTake: 0.6, doublePass: 1, optimal: 0.6 });
assert.equal(summarizeAnalysis(close).player.errorRate.decisions, 1);
// Un bon jet sans doubler n'est pas une décision visible du bilan.
assert.deepEqual(summarizeAnalysis(close).counts, {
good: 0,
doubtful: 0,
bad: 0,
veryBad: 0,
});
});
test("l'analyse de partie enregistrée : migration du format durable", () => {
const journal = journalOf(SHORT);
const review = {
...emptyReview(2),
luck: { "3": { value: 0.1, rating: "none" as const } },
winChances: { "4": 0.55, "99": 0.5 },
unanalysed: ["5", "98"],
};
// Un journal d'avant l'étape 1e se reprend tel quel.
assert.equal(migrateJournal(journal), journal);
// Les entrées rattachées à un événement absent sont oubliées.
const migrated = migrateJournal({ ...journal, review });
assert.deepEqual(migrated.review?.winChances, { "4": 0.55 });
assert.deepEqual(migrated.review?.unanalysed, ["5"]);
assert.deepEqual(migrated.review?.luck, review.luck);
// Une analyse complète et connue ne change pas.
const clean = { ...journal, review: migrated.review };
assert.equal(migrateJournal(clean), clean);
// Une autre version, ou une analyse mal formée, est oubliée.
const other = migrateJournal({
...journal,
review: { ...review, version: REVIEW_VERSION + 1 },
});
assert.equal(other.review, undefined);
const broken = { ...journal, review: { version: REVIEW_VERSION } } as unknown as GameJournal;
assert.equal(migrateJournal(broken).review, undefined);
// Les analyses d'une copie s'ajoutent au journal sans toucher aux
// conseils déjà là.
const live: GameJournal = { ...journal, advice: { "4": moveAdvice("4", 0) } };
const copy: GameJournal = {
...journal,
advice: { "4": moveAdvice("4", -0.5), "7": cubeAdvice("7", "double", 0) },
review,
};
const merged = withAnalysesOf(live, copy);
assert.equal(merged.advice?.["4"]?.equityLoss, 0);
assert.ok(merged.advice?.["7"] !== undefined);
assert.equal(merged.review, review);
});
/** Toutes les analyses qui manquent, une à une. */
async function analyseAll(engine: Engine, journal: GameJournal): Promise<GameJournal> {
let current = journal;
for (let i = 0; i < 500; i++) {
const next = missingAnalyses(current)[0];
if (next === undefined) {
return current;
}
current = await runAnalysis(engine, current, next);
}
throw new Error("l'analyse ne finit pas");
}
test("une partie complète analysée par le moteur, comme une partie d'avant 1d", async () => {
const engine = await loadNodeEngine();
// Une course courte (tests/reference/positions.json, race-1).
const game = Game.start(engine, {
setup: { position: "trsXAADs7mwAAA", firstTurn: "player" },
level: "2ply",
dice: seededDice(7),
});
await playToTheEnd(game, engine, 1);
// Aucun conseil n'a été rangé pendant la partie (comme avant l'étape
// 1d) : tout est à analyser.
assert.equal(game.journal.advice, undefined);
assert.ok(!readyForSummary(game.journal));
const analysed = await analyseAll(engine, game.journal);
assert.ok(readyForSummary(analysed));
assert.deepEqual(missingAnalyses(analysed), []);
const summary = summarizeAnalysis(analysed);
const advices = Object.values(analysed.advice ?? {});
assert.ok(advices.length > 0);
// Le coup à revoir est la plus grosse perte d'équité du joueur, s'il en
// a une.
const worst = advices
.filter((a) => a.skill !== "none")
.reduce<Advice | null>((w, a) => (w === null || a.equityLoss < w.equityLoss ? a : w), null);
assert.equal(summary.toReview?.advice.eventId, worst?.eventId);
// La chance de chaque tour d'un camp est comptée.
const moves = game.events.filter((e) => e.type === "MovePlayed");
assert.equal(summary.player.luck.rolls + summary.computer.luck.rolls, moves.length);
// Les chances de gagner sont des probabilités, et la courbe finit sur
// le résultat.
const curve = chanceCurve(analysed);
assert.ok(curve.every((p) => p.chance !== null && p.chance >= 0 && p.chance <= 1));
const won = game.state.result?.winner === "player";
assert.equal(curve.at(-1)?.chance, won ? 1 : 0);
// Les coups de l'ordinateur sont analysés aussi.
assert.ok(Object.keys(analysed.review?.computer ?? {}).length > 0);
// Le journal analysé se reprend : l'analyse fait partie du format.
const resumed = Game.resume(engine, JSON.parse(JSON.stringify(analysed)));
assert.deepEqual(resumed.journal.review, analysed.review);
});
+211 -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,
@@ -59,6 +81,7 @@ function fakeEngine(overrides: Partial<Engine> = {}): Engine {
skill: "none",
}),
luck: async () => ({ value: 0, rating: "none" }),
evaluate: async () => ({ win: 0.5, equity: 0 }),
encodeMatch: async () => "MATCH",
decodeMatch: async () => ({
matchLength: 0,
@@ -237,10 +260,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 +272,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 +300,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 +356,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 +430,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 });
});
+151
View File
@@ -0,0 +1,151 @@
// L'analyse d'une partie en arrière-plan (app/src/ui/review-runner.ts) :
// une analyse à la fois, rangée au fur et à mesure, arrêtée à la demande
// ou par une autre partie ; et les phrases du bilan.
//
// 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 {
type AnalysisTask,
type GameSummary,
missingAnalyses,
} from "../src/domain/analysis/game-analysis.js";
import { diceWords, nothingToReview, summaryReading } from "../src/domain/analysis/phrases.js";
import {
appendEvent,
EMPTY_JOURNAL,
type GameJournal,
type NewGameEvent,
} from "../src/domain/game/events.js";
import { START_POSITION } from "../src/domain/game/position.js";
import { emptyReview, type GameReview } from "../src/domain/game/review.js";
import { createReviewRunner } from "../src/ui/review-runner.js";
import { loadPhrases } from "./support/advice.js";
const P = START_POSITION;
/** Un tour de l'ordinateur, sans décision du joueur. */
const EVENTS: NewGameEvent[] = [
{ type: "GameStarted", position: P, cube: { value: 1, owner: null }, firstTurn: "computer" },
{ type: "DiceRolled", side: "computer", roll: [6, 5] },
{ type: "MovePlayed", side: "computer", halfMoves: [], position: P },
];
const JOURNAL: GameJournal = EVENTS.reduce(appendEvent, EMPTY_JOURNAL);
/** Une fausse analyse : elle range la tâche sans moteur, quand on la
* libère. */
function fakeRun() {
const waiting: (() => void)[] = [];
const run = async (journal: GameJournal, task: AnalysisTask): Promise<GameJournal> => {
await new Promise<void>((resolve) => waiting.push(resolve));
const review: GameReview = journal.review ?? emptyReview(2);
if (task.kind === "luck") {
return {
...journal,
review: { ...review, luck: { [task.eventId]: { value: 0, rating: "none" } } },
};
}
if (task.kind === "chance") {
return {
...journal,
review: { ...review, winChances: { ...review.winChances, [task.eventId]: 0.5 } },
};
}
return { ...journal, review: { ...review, unanalysed: [task.eventId] } };
};
const release = async () => {
while (waiting.length === 0) {
await new Promise((resolve) => setTimeout(resolve, 1));
}
waiting.shift()?.();
await new Promise((resolve) => setTimeout(resolve, 1));
};
return { run, release };
}
test("l'analyse en arrière-plan range chaque analyse, puis s'arrête une fois tout fait", async () => {
const { run, release } = fakeRun();
const runner = createReviewRunner(run);
const recorded: GameJournal[] = [];
let done = false;
// La chance du jet, le jet sans doubler de l'ordinateur, ses chances
// après le coup.
const total = missingAnalyses(JOURNAL).length;
assert.equal(total, 3);
runner.start(
"a",
JOURNAL,
(j) => recorded.push(j),
() => {
done = true;
},
);
assert.equal(runner.running, "a");
// La même partie redemandée ne repart pas.
runner.start("a", JOURNAL, () => assert.fail("deuxième analyse"));
for (let i = 0; i < total; i++) {
await release();
}
assert.equal(recorded.length, total);
assert.deepEqual(missingAnalyses(recorded.at(-1) as GameJournal), []);
assert.ok(done);
assert.equal(runner.running, null);
});
test("l'analyse s'arrête à la demande, ou pour une autre partie, sans ranger la dernière", async () => {
const { run, release } = fakeRun();
const runner = createReviewRunner(run);
const recorded: string[] = [];
runner.start("a", JOURNAL, () => recorded.push("a"));
runner.stop();
assert.equal(runner.running, null);
await release();
assert.equal(recorded.length, 0);
runner.start("b", JOURNAL, () => recorded.push("b"));
runner.start("c", JOURNAL, () => recorded.push("c"));
assert.equal(runner.running, "c");
await release();
await release();
assert.deepEqual(recorded, ["c"]);
});
/** Un bilan réduit à ce que lisent les phrases. */
function summaryWith(result: number, luckAdjustedResult: number): GameSummary {
const side = {
errorRate: { total: 0, decisions: 0, perDecision: 0, level: "champion" as const },
luck: { total: 0, rolls: 1, perRoll: -0.04, rating: "unlucky" as const, points: 0 },
};
return {
player: side,
computer: side,
counts: { good: 0, doubtful: 0, bad: 0, veryBad: 0 },
toReview: null,
result,
luckAdjustedResult,
};
}
test("les phrases du bilan : ce qui a fait la différence, et les dés en mots", () => {
const phrases = loadPhrases();
assert.equal(diceWords(phrases, "unlucky"), "un peu contre vous");
assert.equal(
summaryReading(phrases, summaryWith(1, 1.2)),
"D'après mon analyse, votre jeu a fait la différence, avec des dés un peu contre vous.",
);
assert.equal(
summaryReading(phrases, summaryWith(-2, -1.5)),
"D'après mon analyse, le jeu de l'ordinateur a fait la différence, avec des dés un peu contre vous.",
);
// Gagnée grâce aux dés, ou perdue à cause d'eux : les dés.
for (const [result, adjusted] of [
[1, -0.3],
[-1, 0.2],
] as const) {
assert.equal(
summaryReading(phrases, summaryWith(result, adjusted)),
"D'après mon analyse, les dés ont fait la différence, plus que le jeu.",
);
}
assert.ok(nothingToReview(phrases).startsWith("Aucun coup à revoir"));
});
+20 -2
View File
@@ -28,11 +28,19 @@ 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" }],
["#/parties/mh2k3x-a1b2c3/bilan", { screen: "summary", game: "mh2k3x-a1b2c3" }],
["#/parties/mh2k3x-a1b2c3/analyse", { screen: "analysis", game: "mh2k3x-a1b2c3", turn: null }],
["#/parties/mh2k3x-a1b2c3/analyse/7", { screen: "analysis", game: "mh2k3x-a1b2c3", turn: 7 }],
["#/menu", { screen: "menu" }],
["#/nouvelle-partie", { screen: "newGame" }],
["#/explication/12", { screen: "explanation", event: "12" }],
[
"#/position/4HPwATDgc%2FABMA/cAkAAAAAAAAA",
{ screen: "position", position: START, match: MATCH },
@@ -63,9 +71,19 @@ 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",
"#/parties/ab/autre",
"#/parties/ab/bilan/2",
"#/parties/ab/analyse/0",
"#/parties/ab/analyse/x",
"#/parties/ab/analyse/3/plus",
"#/parties/AB/bilan",
]) {
assert.equal(parseRoute(hash).screen, "unknown", hash);
}
+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));
});
+232
View File
@@ -0,0 +1,232 @@
// Étapes du scénario docs/scenarios/revoir-ma-partie.feature (ADR 0008) :
// la partie se joue par le domaine sur le vrai moteur, avec des dés
// imposés ; son analyse passe par l'analyse en arrière-plan de l'interface
// (review-runner.ts, sans DOM), le bilan par le domaine
// (game-analysis.ts) et les phrases du maître, et l'ancienne partie par le
// stockage des parties sur un localStorage simulé.
//
// 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 {
chanceCurve,
gameTurns,
missingAnalyses,
readyForSummary,
replayPoint,
runAnalysis,
summarizeAnalysis,
} from "../../src/domain/analysis/game-analysis.js";
import { diceWords, summaryReading } from "../../src/domain/analysis/phrases.js";
import type { Engine } from "../../src/domain/game/engine.js";
import type { GameJournal } from "../../src/domain/game/events.js";
import { Game } from "../../src/domain/game/game.js";
import { parseHalfMoves } from "../../src/domain/game/position.js";
import { fixedDice } from "../../src/domain/game/roll.js";
import { memoryStorage, openGameLibrary } from "../../src/storage/open-library.js";
import { createReviewRunner } from "../../src/ui/review-runner.js";
import { loadPhrases } from "../support/advice.js";
import { loadNodeEngine } from "../support/node-engine.js";
setDefaultTimeout(60_000);
/** Le joueur au trait : un pion sur son point 6, un sur son point 1 ;
* l'ordinateur : un sur son point 1, un sur son point 2 (Position ID de
* gnubg, chacun vu de lui-même). */
const BEAROFF = "BQAACAIAAAAAAA";
const phrases = loadPhrases();
let engine: Engine;
let game: Game;
let dice: number[];
/** Le journal analysé de la partie. */
let analysed: GameJournal;
/** La partie d'origine, avant « Rejouer d'ici ». */
let original: { head: string; events: number };
/** Les analyses rangées par l'analyse en arrière-plan. */
let recorded: number;
BeforeAll(async () => {
engine = await loadNodeEngine();
});
/** Analyse ce qui manque au journal, comme l'application en
* arrière-plan. */
async function analyse(journal: GameJournal): Promise<GameJournal> {
const runner = createReviewRunner((j, task) => runAnalysis(engine, j, task));
recorded = 0;
let latest = journal;
await new Promise<void>((resolve) => {
runner.start(
"partie",
journal,
(j) => {
latest = j;
recorded++;
},
resolve,
);
});
return latest;
}
async function playMove(move: string): Promise<void> {
for (const halfMove of parseHalfMoves(move)) {
assert.ok(await game.playHalfMove(halfMove), `demi-coup refusé : ${move}`);
}
await game.confirmMove();
}
Given(
"il vous reste deux pions, sur vos points 6 et 1, et à l'ordinateur deux pions sur ses points 1 et 2",
() => {
dice = [];
// Les dés sont tirés au moment voulu : ceux qu'impose le scénario.
game = Game.start(engine, {
setup: { position: BEAROFF, firstTurn: "player" },
level: "2ply",
dice: () => {
const die = dice.shift();
if (die === undefined) {
throw new Error("plus de dés imposés");
}
return die;
},
});
},
);
Given("vous avez {int}-{int} à jouer", async (a: number, b: number) => {
dice.push(a, b);
await game.roll();
assert.deepEqual(game.state.roll, [a, b]);
});
When(/^vous choisissez (?:cette fois )?le coup (.+)$/, async (move: string) => {
await playMove(move);
});
/** L'ordinateur, sûr de sortir ses deux pions au jet suivant, double ; le
* joueur passe. */
async function computerDoublesAndPlayerPasses(): Promise<void> {
await game.playComputerTurn();
assert.equal(game.state.phase, "doubled", "l'ordinateur ne double pas");
await game.drop();
assert.equal(game.state.phase, "over");
}
When("l'ordinateur, sûr de gagner, double et que vous passez", async () => {
await computerDoublesAndPlayerPasses();
analysed = await analyse(game.journal);
});
Then("la partie est finie, gagnée par l'ordinateur", () => {
assert.equal(game.state.result?.winner, "computer");
});
Then("le bilan compte une grosse erreur", () => {
assert.ok(readyForSummary(analysed));
assert.equal(summarizeAnalysis(analysed).counts.veryBad, 1);
});
Then("sa phrase dit ce qui a fait la différence, et comment étaient vos dés", () => {
const summary = summarizeAnalysis(analysed);
const reading = summaryReading(phrases, summary);
assert.ok(reading.startsWith("D'après mon analyse, "), reading);
assert.ok(!reading.includes("!"), reading);
// Perdue : le jeu de l'ordinateur a fait la différence, si le résultat
// corrigé de la chance reste négatif ; sinon les dés.
if (summary.luckAdjustedResult < 0) {
assert.ok(reading.includes(diceWords(phrases, summary.player.luck.rating)), reading);
} else {
assert.ok(reading.includes("les dés ont fait la différence"), reading);
}
});
Then("le coup à revoir est votre coup {}, le premier de la partie", (move: string) => {
const toReview = summarizeAnalysis(analysed).toReview;
assert.equal(toReview?.turn, 1);
assert.equal(toReview?.advice.kind, "move");
assert.equal(toReview?.advice.kind === "move" && toReview.advice.played.notation, move);
});
Then("le maître y aurait joué {}", (move: string) => {
const advice = summarizeAnalysis(analysed).toReview?.advice;
assert.equal(advice?.kind === "move" && advice.best.notation, move);
assert.equal(advice?.skill, "veryBad");
});
Then("la courbe de vos chances de gagner finit à {int} %", (percent: number) => {
const curve = chanceCurve(analysed);
assert.equal(curve.length, gameTurns(analysed).length);
assert.equal(curve.at(-1)?.chance, percent / 100);
assert.ok(curve.every((p) => p.chance !== null && p.chance >= 0 && p.chance <= 1));
});
When("vous rejouez d'ici le coup à revoir", () => {
game.recordAnalyses(analysed);
original = { head: game.journal.head ?? "", events: game.events.length };
const toReview = summarizeAnalysis(game.journal).toReview;
const turn = gameTurns(game.journal)[(toReview?.turn ?? 0) - 1];
assert.ok(turn !== undefined);
const point = replayPoint(game.journal, turn);
assert.ok(point !== null);
game.goBackTo(point);
});
Then("vous avez de nouveau {int}-{int} à jouer", (a: number, b: number) => {
assert.equal(game.state.phase, "moving");
assert.equal(game.state.turn, "player");
assert.deepEqual(game.state.roll, [a, b]);
});
Then("la partie est finie, gagnée par vous", () => {
assert.equal(game.state.result?.winner, "player");
});
Then("la partie a deux branches, dont la partie d'origine, intacte", () => {
const branches = game.branches;
assert.equal(branches.length, 2);
const origin = branches.find((b) => b.forkedFrom === null);
assert.equal(origin?.head, original.head);
assert.ok(origin?.over);
// L'analyse de la partie d'origine reste attachée à ses événements.
assert.ok(game.journal.review !== undefined);
});
Given("une partie finie enregistrée sans aucune analyse", async () => {
dice = [6, 1];
game = Game.start(engine, {
setup: { position: BEAROFF, firstTurn: "player" },
level: "2ply",
dice: fixedDice(dice),
});
await game.roll();
await playMove("6/5 5/off");
await computerDoublesAndPlayerPasses();
const { library } = await openGameLibrary({ localStorage: memoryStorage() });
await library.save("ancienne", game.journal, { level: "2ply" });
const saved = await library.load("ancienne");
assert.ok(saved !== null);
analysed = saved.journal;
assert.equal(analysed.advice, undefined);
assert.equal(analysed.review, undefined);
});
When("vous ouvrez son bilan", () => {
assert.ok(!readyForSummary(analysed));
});
Then("le maître l'analyse d'abord en arrière-plan", async () => {
const before = missingAnalyses(analysed).length;
analysed = await analyse(analysed);
assert.equal(recorded, before);
assert.ok(readyForSummary(analysed));
});
Then("le bilan nomme ensuite le coup à revoir", () => {
const advice = summarizeAnalysis(analysed).toReview?.advice;
assert.equal(advice?.kind === "move" && advice.played.notation, "6/off");
});
+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,
),
);
});
+82 -11
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,19 +231,26 @@ 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);
});
test("les textes de fin de partie emploient les mots du glossaire", () => {
assert.equal(TEXTS.endTitle("computer", "gammon", false), "L'ordinateur gagne un gammon");
assert.equal(TEXTS.endTitle("computer", "gammon"), "L'ordinateur gagne un gammon");
assert.equal(TEXTS.endDetail("computer", 8, 4), "8 points pour lui, videau à 4.");
assert.equal(TEXTS.endTitle("player", "single", false), "Vous gagnez la partie simple");
assert.equal(TEXTS.endTitle("player", "single"), "Vous gagnez la partie simple");
assert.equal(TEXTS.endDetail("player", 1, 1), "1 point pour vous.");
assert.equal(TEXTS.endTitle("player", "single", true), "L'ordinateur passe");
// Un double passé : le titre donne l'issue, le détail dit comment.
assert.equal(
`${TEXTS.droppedDouble("computer")}${TEXTS.endDetail("computer", 1, 1)}`,
"Vous avez passé son double : 1 point pour lui.",
);
assert.equal(
TEXTS.computerMoved([6, 4], "24/18 13/9"),
"L'ordinateur a joué 6-4 : 24/18 13/9.",
@@ -106,3 +106,81 @@ 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.
### 2026-11-10 — L'analyse de partie rattachée au journal
Avec le bilan de fin de partie et l'écran d'analyse (étape 1e), le journal
d'une partie (`GameJournal`) gagne un second champ facultatif, `review`
(`GameReview`, `app/src/domain/game/review.ts`) : le reste de l'analyse
de la partie par le moteur, à côté des conseils du maître (`advice`) :
- `luck` : la chance de chaque jet (`DiceRolled`, jet d'ouverture compris)
pour le camp qui le joue ;
- `winChances` : les chances de gagner du joueur après chaque coup
(`MovePlayed`), de 0 à 1 ;
- `computer` : les décisions de l'ordinateur, analysées comme celles du
joueur (même format que les conseils) ;
- `unanalysed` : les décisions que le moteur n'analyse pas (videau
inaccessible, position de départ), pour ne plus les redemander ;
- `version` (`REVIEW_VERSION`, 1) et `ply`, le niveau des analyses (2).
Comme les conseils, ce n'est pas un événement : les événements gardent leur
version (2). Les analyses qui manquent (dont tous les conseils d'une partie
jouée avant l'étape 1d) sont calculées en arrière-plan sur le second moteur,
une à une, rangées au fur et à mesure, et l'ensemble s'arrête quand on n'en
a plus besoin (`app/src/ui/review-runner.ts`).
- À la reprise (`migrateJournal`), une analyse d'une autre version ou mal
formée est oubliée ; ses entrées rattachées à un événement absent aussi.
Un journal enregistré avant l'étape 1e n'a pas de champ `review` et se
reprend tel quel. Testé dans `app/tests/game-analysis.test.ts`.
- Une analyse faite en arrière-plan sur une copie du journal s'y range
sans toucher aux conseils déjà là (`withAnalysesOf`).
- Le schéma IndexedDB ne change pas : le journal est enregistré tel quel.
La bibliothèque gagne `saveAnalyses`, qui enregistre le journal analysé
d'une partie sans en faire la partie en cours ni changer la date de sa
dernière action (l'ordre de « Mes parties »).
+74
View File
@@ -85,3 +85,77 @@ 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).
### 2026-11-10 — Adresses du bilan et de l'analyse d'une partie
Ajoutées avec le bilan de fin de partie et l'écran d'analyse (étape 1e),
dans le même schéma, sous l'adresse de la partie dans « Mes parties » :
| Écran | Adresse |
| --- | --- |
| Bilan de fin de partie | `#/parties/<id>/bilan` |
| Analyse d'une partie, placée sur un coup | `#/parties/<id>/analyse/<coup>` |
| Analyse d'une partie, sur le coup à revoir | `#/parties/<id>/analyse` |
`<id>` est l'identifiant de la partie enregistrée ; `<coup>` le numéro d'un
tour de la partie (ce qu'un camp a fait quand il avait la main : doubler ou
non, lancer, jouer), à partir de 1, sur sa ligne courante.
- Le bilan remplace le panneau de fin de partie dès que l'analyse est
prête : l'application va à l'adresse du bilan, par-dessus celle de la
partie (`#/`). Il monte au-dessus de la partie, comme une
feuille ; le fermer (croix, retour du téléphone) laisse voir la partie
finie, sans le panneau. « Mes parties » donne le bilan de chaque partie
finie.
- L'analyse est une page. Choisir un autre coup (courbe, flèches, liste)
remplace l'adresse : le retour du téléphone quitte l'écran au lieu de
repasser par chaque coup. « Rejouer d'ici » ramène à la partie (`#/`),
reprise juste avant la décision du coup.
- Comme la feuille d'explication, ces adresses n'ont de sens que sur
l'appareil qui a joué la partie. Une partie absente donne un message,
jamais une page cassée.
+3 -3
View File
@@ -19,9 +19,9 @@ 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, 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 |
| 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 (fait) | 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 |
| 1h. Progression | Historique des parties et courbe du taux d'erreur | 1b, 1e |
-1
View File
@@ -23,7 +23,6 @@ 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
+4
View File
@@ -40,9 +40,12 @@ 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 |
| [Courbe des chances de gagner](courbe-des-chances.md) | Win chance curve |
| [Demi-coup](demi-coup.md) | Half-move |
| [Course](course.md) | Race |
| [Doubler](doubler.md) | Double |
@@ -68,4 +71,5 @@ exportée. `make test` vérifie le lien dans les deux sens : chaque fiche avec
| [Règle de Jacoby](regle-de-jacoby.md) | Jacoby rule |
| [SGF](sgf.md) | SGF |
| [Sortie](sortie.md) | Bear-off |
| [Taux d'erreur](taux-d-erreur.md) | Error rate |
| [Videau](videau.md) | Cube |
+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)
+24
View File
@@ -0,0 +1,24 @@
---
term: Courbe des chances de gagner
english: Win chance curve
code: WinChanceCurve
---
# Courbe des chances de gagner
**En bref** : vos chances de gagner la partie après chaque coup, selon le moteur, tracées coup après coup en pourcentage.
Après chaque coup, des deux camps, le moteur évalue la position et donne
vos chances de gagner la partie, sans tenir compte du videau. La courbe
monte quand la partie tourne en votre faveur, descend sinon ; la ligne en
pointillé marque 50 %. Le dernier point est le résultat : 100 % si vous avez
gagné, 0 % sinon.
Les points en couleur sont vos coups commentés par le maître, avec la
couleur de leur [appréciation](appreciation.md) ; les petits points sont les
coups de l'ordinateur. Une chute juste après l'un de vos coups montre ce
qu'une erreur a coûté ; une chute après un jet montre la part des dés.
C'est le « graphe d'équité » de GNU Backgammon, en pourcentage plutôt qu'en
équité, plus parlant pour un débutant.
Voir aussi : [Équité](equite.md), [Taux d'erreur](taux-d-erreur.md), [Chance](chance.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)
+36
View File
@@ -0,0 +1,36 @@
---
term: Taux d'erreur
english: Error rate
code: ErrorRate
---
# Taux d'erreur
**En bref** : l'équité que vous avez perdue en moyenne à chaque décision de la partie, d'après le moteur ; plus il est bas, mieux vous avez joué.
Le moteur compare chacun de vos coups et chacune de vos décisions de videau
au meilleur choix, et note l'équité perdue (0 pour le meilleur coup). Le
taux d'erreur additionne ces pertes et les divise par le nombre de
décisions qui comptent : les coups où vous aviez le choix (un coup forcé ne
compte pas), vos doubles, vos prises et vos refus, et vos jets sans doubler
quand doubler se discutait. C'est le calcul de GNU Backgammon (« Error rate
per decision »).
Le bilan traduit ce taux en niveau, avec les seuils de GNU Backgammon et
les noms des niveaux de l'ordinateur, dans le même ordre :
| Équité perdue par décision | Classement de GNU Backgammon | Niveau dans Ustagammon |
| --- | --- | --- |
| moins de 0,005 | Super Grandmaster | Champion |
| 0,005 à 0,008 | Grandmaster | Expert |
| 0,008 à 0,013 | Master | Avancé |
| 0,013 à 0,020 | Advanced | Intermédiaire |
| 0,020 à 0,032 | Intermediate | Amateur |
| 0,032 et plus | Beginner | Débutant |
Le bilan montre à côté la [chance](chance.md) de vos dés sur la partie : par
tour, la chance de vos jets ; « un peu » pour vous ou contre vous au-delà de
0,03 par tour, « nettement » au-delà de 0,06, comme GNU Backgammon. La
phrase du bilan compare enfin le résultat à ce qu'il aurait été sans la
chance des deux camps : si l'écart vient surtout des dés, elle le dit.
Voir aussi : [Appréciation](appreciation.md), [Chance](chance.md), [Équité](equite.md), [Courbe des chances de gagner](courbe-des-chances.md)
+13 -5
View File
@@ -78,7 +78,11 @@ Maquette « Portrait révisé : conseil du maître après le coup ». Une ligne
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).
bouge pas) ;
- tant qu'elle est affichée, elle prend la place de « L'ordinateur a joué… »
(les traces laiton et ↻ restent) ;
- **en paysage** : une pastille (pictogramme et mot d'appréciation) en tête
de la bande centrale, à gauche de la consigne ; la toucher ouvre la feuille.
### Les mots
@@ -89,7 +93,7 @@ Maquette « Portrait révisé : conseil du maître après le coup ». Une ligne
| 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 ».
Pour le videau : « Bonne décision » remplace « Bon coup ».
« Grosse erreur » est la recommandation de design ; `docs/etape-1.md` écrit
« gaffe », et le choix final revient à Pierre (ton).
@@ -130,13 +134,17 @@ permet de le déterminer, sinon rien.
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.
- **Le rang de votre coup** n'est cité que pour un bon coup qui n'est pas le
meilleur et pour une imprécision, et seulement s'il vaut 2 ou 3. Jamais pour
une erreur ni une grosse erreur : le rang mesure mal la gravité (« 2e
choix » peut cacher un écart de 0,2). Le classement complet reste dans
« Voir les chiffres du moteur ».
- **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. »
- Erreur : « J'aurais joué {meilleur}. Mon coup fait votre point 5. »
- Videau, prise juste : « Prendre était le bon choix. »
- Videau, double manqué : « Ici, j'aurais doublé. »
## 1e. Bilan et analyse
+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
+283
View File
@@ -0,0 +1,283 @@
# Les phrases du maître
Les mots et les phrases du conseil du maître (étape 1d) et du bilan de fin
de partie (étape 1e), 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 |
| `{dice}` | la chance de vos dés sur la partie, en mots (bilan, étape 1e) |
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
## bilan.jeu.gagne
Bilan de fin de partie (étape 1e), partie gagnée, quand le résultat corrigé
de la chance (celui qu'on aurait eu sans les dés, à la manière de gnubg)
reste en votre faveur. {dice} : la chance de vos dés sur la partie (sections
bilan.des.…).
- D'après mon analyse, votre jeu a fait la différence, avec des dés {dice}.
## bilan.jeu.perdu
Partie perdue, le résultat corrigé de la chance restant en faveur de
l'ordinateur.
- D'après mon analyse, le jeu de l'ordinateur a fait la différence, avec des dés {dice}.
## bilan.des
Partie gagnée ou perdue, le résultat corrigé de la chance allant dans
l'autre sens que le résultat : les dés des deux camps ont pesé plus que les
erreurs.
- D'après mon analyse, les dés ont fait la différence, plus que le jeu.
## bilan.des.tres-pour
- nettement pour vous
## bilan.des.pour
- un peu pour vous
## bilan.des.neutres
- équilibrés
## bilan.des.contre
- un peu contre vous
## bilan.des.tres-contre
- nettement contre vous
## bilan.aucun-coup
- Aucun coup à revoir : vous avez joué comme je l'aurais fait.
@@ -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.
-46
View File
@@ -1,46 +0,0 @@
# Fin de partie et analyse (1e)
- **Date** : 2026-11-03
- **Étape** : 1e (`docs/etape-1.md`)
- **Statut** : prévu (après 1d)
- **Exécutant** : sous-agent general-purpose, dans un worktree
## Objectif
Que le joueur comprenne sa partie : le bilan de fin de partie (ADR 0011), puis un écran d'analyse avec graphe d'équité, liste des erreurs, visualiseur et « Rejouer d'ici ».
## Consignes
Respecter les ADR 0007, 0008, 0011 et 0013. Chaque écran a son adresse : `#/parties/<id>/bilan` et `#/parties/<id>/analyse/<coup>`, à ajouter au tableau de l'ADR 0013 par un amendement. Pour la charte, suivre les maquettes « Fin de partie » du canevas. Aucune nouvelle dépendance : le graphe est dessiné en SVG à la main.
1. **Analyse de la partie.** Elle réutilise les analyses rangées par 1d (coups et videau) et complète celles qui manquent en arrière-plan, de façon progressive et annulable. Elle donne :
- le taux d'erreur à la manière de gnubg ;
- la chance aux dés (`luck`) pour chaque camp ;
- le décompte des coups par appréciation.
2. **Bilan**, dans le panneau de fin de partie existant :
- le résultat ;
- les coups par appréciation ;
- le taux d'erreur comparé à la chance, en phrases simples ;
- « le coup à revoir » : la plus grosse erreur, avec sa position, le coup joué et le meilleur coup ;
- un emplacement pour le bouton « Demander à une IA » (1f) ;
- « Voir l'analyse ».
3. **Écran d'analyse** :
- un graphe d'équité où toucher un point ouvre la position ;
- la liste des erreurs, filtrable par appréciation ;
- un visualiseur pas à pas qui montre sur le plateau le coup joué contre le meilleur coup, avec des pions animés ;
- « Rejouer d'ici », qui crée une branche (domaine de 1b) sans toucher l'original.
4. **Tests** :
- scénarios Gherkin : bilan, coup à revoir, rejouer d'ici, chacun avec des dés imposés ;
- fiches du glossaire : taux d'erreur, graphe d'équité si absentes ;
- `make lint`, `make test`, `make test-browser`, avec des captures pour la recette design.
## Critères de réussite
- Après une partie, le bilan nomme le coup à revoir, conforme à l'analyse du moteur.
- L'analyse s'ouvre par son adresse.
- « Rejouer d'ici » crée une branche.
- Les trois commandes passent.
## Résultat
À remplir à la clôture.
@@ -1,64 +0,0 @@
# Conseil du maître (1d)
- **Date** : 2026-11-03
- **Étape** : 1d (`docs/etape-1.md`)
- **Statut** : prévu (après 1c)
- **Exécutant** : sous-agent general-purpose, dans un worktree
## Objectif
Le coach bienveillant, « mis en avant mais discret » (Pierre, 2026-11-03) :
après chaque coup et chaque décision de videau du joueur, jamais avant
(ADR 0011), une appréciation et une phrase du maître fondées sur les chiffres
du moteur.
## Références
- ADR 0011 ; maquette « conseil du maître après le coup » du canevas
https://claude.ai/artifact/CQw6vbDpi6XQqVWQa4sANe ; règles de la charte
(`docs/identite/charte/`, dont interaction.md) ; la ligne réservée de 44 px
de la révision UX.
- Moteur : `analyseMove`, `analyseCube`, `luck`, seuils `SKILL_THRESHOLDS`
(gnubg : 0,03 / 0,06 / 0,12).
## Consignes
1. **Analyse en arrière-plan** : après la validation d'un coup (ou une
décision de videau), l'analyse à 2 plis part dans le Worker sans retarder le
tour de l'ordinateur ; son résultat est rattaché au coup dans le journal
(événement d'analyse rétrocompatible, ou cache séparé justifié) pour servir
au bilan (1e) sans recalcul.
2. **Ligne du maître** : icône d'appréciation, mot en couleur (« Bon coup. »,
« Imprécision. », « Erreur. », « Grosse erreur. » (choix de Pierre du 2026-11-09, plutôt que « gaffe ») : correspondance avec les seuils
de gnubg documentée), phrase courte en italique, chevron. Toucher ouvre la
feuille d'explication : coup joué et meilleur coup montrés sur le plateau
(pions animés, ADR 0011 « comparaison visuelle »), écart d'équité seulement
sur demande, lien vers la fiche du glossaire du concept en jeu si le moteur
permet de le déterminer, sinon rien.
3. **Phrases du maître** : gabarits versionnés en français dans une source
unique sous `docs/` (CC BY-SA 4.0). Référence de ton, approuvée par Pierre
le 2026-11-09 : la section « Les phrases du maître » de
`docs/identite/charte/ecrans-etape-1.md` (1re personne et vouvoiement,
faits du moteur uniquement, moins de 140 caractères, ton sobre sans
exclamation, formules qui tournent pour les bons coups, exemples). Un test
vérifie la longueur et l'absence de « ! » sur chaque gabarit rendu. Les
phrases livrées passent par la session « identité » puis par Pierre pour
la relecture finale.
4. **Discret** : rien n'apparaît pendant que le joueur réfléchit ; la ligne
s'efface à l'action suivante ; désactivable (réglage de 1c et menu).
5. **Videau** : même traitement pour doubler, ne pas doubler, prendre, passer.
6. Scénarios Gherkin (bon coup, erreur avec meilleur coup montré, conseil
désactivé, décision de videau commentée) avec positions et dés imposés ;
tests unitaires des gabarits (chaque gabarit rendu sur des analyses réelles
du moteur). `make lint`, `make test`, `make test-browser`, captures pour la
recette design.
## Critères de réussite
Après une erreur volontaire dans une position connue, la ligne du maître dit
« Erreur. », nomme le coup recommandé par le moteur et le montre sur demande ;
le tour de l'ordinateur n'est pas retardé ; les trois commandes passent.
## Résultat
À remplir à la clôture.
@@ -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.
+16 -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,12 @@ 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 |
| [revoir-ma-partie.feature](revoir-ma-partie.feature) | Revoir sa partie : bilan, coup à revoir, analyse, rejouer d'ici | Étape 1e |
| [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

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