Match, niveaux et réglages : écran Nouvelle partie, Crawford, Jacoby (1c) #49

Manually merged
pierre merged 8 commits from match-et-reglages into main 2026-11-10 04:20:15 +00:00
17 changed files with 378 additions and 190 deletions
Showing only changes of commit 67bd207337 - Show all commits
+8 -8
View File
@@ -75,7 +75,7 @@ import {
import { type PromptText, TEXTS } from "./texts.js";
import { seenAfter } from "./tutorial-nav.js";
import { createTutorialView, type TutorialAccess } from "./tutorial-view.js";
import { type Updates, updateBannerShown } from "./updates.js";
import { betweenTurns, type Updates, updateBannerShown } from "./updates.js";
/** Glissement d'un pion de l'ordinateur, et pause entre deux pions
* (interaction.md : on suit le coup sans l'attendre). */
@@ -766,18 +766,17 @@ export function startApp(options: AppOptions): void {
ui.helpButton.href = helpLink;
ui.menuHelp.href = helpLink;
ui.menuInstall.hidden = !installMenuShown(settings, installer.installed);
const moveInProgress =
busy || (game !== null && game.pendingHalfMoves.length > 0 && !ui.game.hidden);
// Le bandeau de mise à jour attend la fin du tour, même hors de
// l'écran de jeu : la partie enregistrée reprend alors sans perte.
const quiet =
!busy &&
(game === null || betweenTurns(game.state.phase, game.pendingHalfMoves.length));
if (updateMessage !== null) {
ui.updateBanner.hidden = false;
ui.updateText.textContent = updateMessage;
ui.updateApply.hidden = true;
} else {
const shown = updateBannerShown(
updates?.available ?? false,
moveInProgress,
!ui.banner.hidden,
);
const shown = updateBannerShown(updates?.available ?? false, quiet, !ui.banner.hidden);
ui.updateBanner.hidden = !shown;
ui.updateText.textContent = HOME_TEXTS.updateAvailable;
ui.updateApply.hidden = false;
@@ -1542,6 +1541,7 @@ export function startApp(options: AppOptions): void {
},
placed: () => haptic("checkerPlaced"),
reducedMotion,
landscape: () => layout() === "landscape",
replace: (hash) => navigation.replace(hash),
});
+1
View File
@@ -177,6 +177,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);
}
+1 -1
View File
@@ -325,7 +325,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)?.dt);
const target = opened?.section ?? (term === null ? undefined : terms.get(term)?.card);
if (target !== undefined) {
target.scrollIntoView({ block: "start" });
} else if (closed !== null) {
+5 -5
View File
@@ -68,7 +68,7 @@ export const HOME_TEXTS = {
installed: "Ustagammon est installé : ouvrez-le depuis son icône.",
// Mise à jour
updateAvailable: "Nouvelle version disponible.",
updateAvailable: "Nouvelle version prête.",
updateApply: "Mettre à jour",
updateCheck: "Rechercher une mise à jour",
updateChecking: "Recherche d'une mise à jour…",
@@ -80,8 +80,8 @@ export const HOME_TEXTS = {
tutorialMenu: "Les règles (tutoriel)",
tutorialProgress: "Étapes du tutoriel",
tutorialStepLink: (n: number, title: string) => `Étape ${n} : ${title}`,
tutorialSkip: "Passer",
tutorialSkipLabel: "Passer le tutoriel",
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",
@@ -90,8 +90,8 @@ export const HOME_TEXTS = {
next: "Continuer",
replay: "Revoir",
replayLabel: "Revoir le coup",
take: "Je prends",
drop: "Je passe",
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",
+38 -14
View File
@@ -95,6 +95,8 @@ export interface TutorialViewOptions {
/** 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;
}
@@ -148,8 +150,11 @@ export function fillParagraph(target: HTMLElement, paragraph: Paragraph): void {
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. */
function drawPath(svg: SVGSVGElement, g: Geometry): void {
* (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;
@@ -164,9 +169,10 @@ function drawPath(svg: SVGSVGElement, g: Geometry): void {
group.setAttribute("class", "tutorial-path");
group.setAttribute("role", "img");
group.setAttribute("aria-label", HOME_TEXTS.pathLabel);
const width = g.pointWidth * 0.22;
const px = (n: number) => n / pxPerUnit;
const width = px(4);
for (const [className, stroke] of [
["path-edge", width * 2.4],
["path-edge", px(8)],
["path-dots", width],
] as const) {
const path = document.createElementNS(SVG_NS, "path");
@@ -174,21 +180,26 @@ function drawPath(svg: SVGSVGElement, g: Geometry): void {
path.setAttribute("d", d);
path.setAttribute("stroke-width", String(stroke));
if (className === "path-dots") {
path.setAttribute("stroke-dasharray", `0.1 ${width * 2.6}`);
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 = width * 2.2;
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(width * 1.2));
head.setAttribute("stroke-width", String(px(3)));
group.append(head);
// Sous les zones à toucher, toujours dessinées en dernier.
svg.insertBefore(group, svg.lastChild);
// 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). */
@@ -215,9 +226,12 @@ export function createTutorialView(options: TutorialViewOptions): TutorialView {
replay.append(icon("replay"));
replay.setAttribute("aria-label", HOME_TEXTS.replayLabel);
replay.title = HOME_TEXTS.replayLabel;
const text = element(section, "div", { id: "tutorial-text", className: "tutorial-text" });
const task = element(section, "p", { id: "tutorial-task", className: "tutorial-task" });
const feedback = element(section, "p", {
// 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",
});
@@ -287,8 +301,16 @@ export function createTutorialView(options: TutorialViewOptions): TutorialView {
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 width = svg.clientWidth || Math.min(window.innerWidth, 640);
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);
}
@@ -337,7 +359,7 @@ export function createTutorialView(options: TutorialViewOptions): TutorialView {
};
renderBoard(svg, g, boardModel(current), decor);
if (screen?.path === true) {
drawPath(svg, g);
drawPath(svg, g, (svg.getBoundingClientRect().width || g.width) / g.width);
}
}
@@ -412,6 +434,7 @@ export function createTutorialView(options: TutorialViewOptions): TutorialView {
function markSolved(): void {
solved = true;
section.toggleAttribute("data-solved", true);
next.classList.remove("secondary");
take.disabled = true;
drop.disabled = true;
@@ -553,6 +576,7 @@ export function createTutorialView(options: TutorialViewOptions): TutorialView {
selected = null;
targets = new Map();
solved = false;
section.toggleAttribute("data-solved", false);
feedback.textContent = "";
feedback.dataset.verdict = "";
task.hidden = true;
+20 -9
View File
@@ -1,8 +1,9 @@
// 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 le bandeau « Nouvelle
// version disponible » ; « Mettre à jour » active la nouvelle version et
// recharge la page, qui reprend la partie enregistrée.
// 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).
@@ -25,17 +26,27 @@ export interface Updates {
export const SKIP_WAITING = "skip-waiting";
/**
* Le bandeau « Nouvelle version disponible » est-il montré ? Quand une
* version attend, jamais pendant un coup en cours (des pions déjà déplacés,
* ou l'ordinateur qui joue), ni par-dessus le bandeau « Reprendre
* l'ancienne ». Fonction pure, testée sous Node.
* 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,
moveInProgress: boolean,
betweenTwoTurns: boolean,
otherBanner: boolean,
): boolean {
return available && !moveInProgress && !otherBanner;
return available && betweenTwoTurns && !otherBanner;
}
/**
@@ -113,11 +113,11 @@ Then("le tutoriel salue votre coup", () => {
assert.ok(screen.right.length > 0);
});
When("vous répondez « Je passe »", async () => {
When("vous répondez « Passer »", async () => {
answeredRight = (await shouldTake(engine, screen.position)) === false;
});
When("vous répondez « Je prends »", async () => {
When("vous répondez « Prendre »", async () => {
answeredRight = (await shouldTake(engine, screen.position)) === true;
});
+18 -7
View File
@@ -20,7 +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 { updateBannerShown } from "../../src/ui/updates.js";
import { betweenTurns, updateBannerShown } from "../../src/ui/updates.js";
import { loadNodeEngine } from "../support/node-engine.js";
setDefaultTimeout(30_000);
@@ -164,23 +164,34 @@ Then("il passe à clair, puis à sombre, puis revient à automatique", () => {
// ------------------------------------------------------------ Mise à jour
// Le bandeau dépend de deux choses : une version attend (le service worker
// l'a installée), et aucun coup n'est en cours de saisie. La partie est
// celle du domaine, sur le vrai moteur.
// 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;
});
Given("vous avez déjà déplacé un pion de votre coup", async () => {
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(!updateBannerShown(updateAvailable, game.pendingHalfMoves.length > 0, false));
assert.ok(!shownNow());
});
When("vous avez joué votre coup", async () => {
@@ -189,7 +200,7 @@ When("vous avez joué votre coup", async () => {
});
Then("l'application vous propose de mettre à jour", () => {
assert.ok(updateBannerShown(updateAvailable, game.pendingHalfMoves.length > 0, false));
assert.ok(shownNow());
// Jamais par-dessus le bandeau « Reprendre l'ancienne ».
assert.ok(!updateBannerShown(updateAvailable, false, true));
assert.ok(!shownNow(true));
});
+1 -1
View File
@@ -19,7 +19,7 @@ Repris du cadrage (modules, écrans clés, IA générative en V1) et des ADR 001
| --- | --- | --- |
| 1a. Moteur pour l'analyse (fait) | Niveaux de jeu (plis et bruit de GNU Backgammon : débutant à expert), classement des coups d'un jet avec équités, analyse d'un coup joué et d'une décision de videau, chance d'un jet ; exposés par `engine.h`, `engine-cli` et le Worker, avec positions de référence comparées à gnubg de bureau | — |
| 1b. Parties enregistrées (fait) | Stockage IndexedDB derrière l'interface de stockage existante : plusieurs parties, reprise automatique, branches (« Rejouer d'ici » crée une branche, l'originale reste intacte et rechargeable), export SGF avec variantes | — |
| 1c. Match et réglages | 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 |
| 1c. Match et réglages (fait) | Partie libre ou match en N points, règle Crawford, règle Jacoby, choix du niveau de l'ordinateur, conseil du maître activable ou non ; écran de nouvelle partie | 1a, 1b |
| 1d. Conseil du maître | Après chaque coup du joueur, jamais avant (ADR 0011) : appréciation du moteur (bon coup, imprécision, erreur, grosse erreur) avec mot, icône et couleur, phrase du maître fondée sur les chiffres du moteur, coup recommandé montré sur demande ; aussi pour les décisions de videau ; calcul en arrière-plan sans retarder le jeu | 1a |
| 1e. Fin de partie et analyse | Bilan (ADR 0011) : résultat, coups par appréciation, taux d'erreur comparé à la chance, « le coup à revoir » ; écran d'analyse : graphe d'équité (toucher un point ouvre la position), liste des erreurs filtrable, visualiseur pas à pas coup joué contre meilleur coup, « Rejouer d'ici » | 1a, 1b, 1d |
| 1f. Prompts à copier | Gabarits versionnés et testés (`docs/prompts/` ou équivalent, source unique), bouton « Demander à une IA » sur le plateau, le conseil du maître, l'analyse et la fin de partie ; le moteur décide, l'IA explique | 1d, 1e |
-102
View File
@@ -1,102 +0,0 @@
# Match, niveaux et réglages (1c)
- **Date** : 2026-11-03
- **Étape** : 1c (`docs/etape-1.md`)
- **Statut** : fait (2026-11-09), en attente de vérification
- **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
2026-11-09, branche `match-et-reglages` (sur `accueil-tutoriel-aide` puis
`main` fusionnées).
**Fait**
1. **Moteur** (une seule recompilation, `engine.c` et `engine-cli/main.c`) :
`engine_level_double_decision` et `engine_level_take_decision` reprennent
`ComputerTurn` (gnubg, `play.c` l. 1312-1398 et 1139-1235) : accès au
videau et videau < 4096, pré-filtre « fenêtre de marché » (évaluation sans
videau à un pli de moins, `GetDoublePointDeadCube`), double facultatif
seulement à 0 pli avec plus de 0,001 de gain, réponse au double selon la
décision (castors joués comme une prise). `engine_match_after_game` :
score, fin du match, partie Crawford suivante (`play.c` l. 1817-1820 ;
partie Crawford déjà jouée lue comme gnubg, l. 303 et 4277). engine-cli :
`double`, `take`, `aftergame`, `matchid` étendu aux matchs. Tests dans
`tests/test_engine.sh`.
2. **Domaine** : `Match`, `MatchAfterGame` (`match.ts`), niveaux montrés
(`level.ts` : Débutant beginner, Amateur casual, Intermédiaire
intermediate, Avancé advanced, Expert 0ply, Champion 2ply ; documenté
dans `docs/glossaire/niveau-de-jeu.md`). `Game` : niveau et match portés
par `GameStarted`, ordinateur par `levelMove`/`levelDouble`/`levelTake`,
`GameEnded.match` donné par le moteur, `nextGame` pour la partie suivante.
`EngineCube` étendu au match (vu du joueur au trait) ; le Match ID est
écrit par le moteur, liens de position compris. Chaque partie d'un match
est une partie enregistrée à part.
3. **Format** : événements en version 2, migration testée des journaux de la
version 1 (niveau 2 plis explicite) ; résumé avec `match` facultatif ;
quatre réglages nouveaux avec valeur par défaut. Amendement de l'ADR 0007.
4. **Écran `#/nouvelle-partie`** (charte 1c) : segments partie libre/match,
longueurs 3/5/7/11, phrases et liens Crawford/Jacoby, six barres de
niveau, conseil du maître, barre d'action collée en bas ; choix gardés.
Ouvert par le menu (lien), l'accueil et la fin de partie ; « Partie
suivante » dans un match qui continue. Adresse ajoutée à l'ADR 0013.
5. **Score** : ligne « Match en N points · partie Crawford », pastilles dans
les bandeaux, case « sur N » dans la colonne de commandes en paysage,
texte pour le lecteur d'écran ; videau caché pendant la partie Crawford ;
panneau de fin de partie et de fin de match.
6. **Tests** : `jouer-un-match.feature` (niveau, match en 3 points jusqu'au
bout avec dés imposés, Crawford, Jacoby), `match.test.ts`, tests du
domaine (match, migration), `tests/browser/match.mjs` (écran, match en
3 points joué par l'interface jusqu'au bout, rechargement en cours de
match ; captures dans `build/browser-shots/match/`). `play.mjs` adapté au
passage par l'écran « Nouvelle partie ».
**Écarts**
- Le moteur C a changé (fonctions ajoutées, rien de gnubg modifié).
- Le « conseil du maître » n'est qu'un réglage gardé : il n'existe pas
encore (1d).
- « Mes parties » liste chaque partie d'un match séparément, sans afficher
le match ; le résumé porte pourtant `match` pour le faire plus tard.
- Le numéro de la partie dans le match n'est pas gardé (SGF : `game:0`).
- Longueur par défaut d'un match : 5 points (la charte ne la fixe pas).
**Suites** : affichage du match dans « Mes parties » ; recette design des
captures ; essai sur le Fairphone 4 (temps du niveau Champion).
+3 -3
View File
@@ -10,7 +10,7 @@ Fonctionnalité: Apprendre les règles avec 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. « Passer » vous emmène jouer.
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
@@ -36,7 +36,7 @@ Fonctionnalité: Apprendre les règles avec le tutoriel
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 « Je passe »
Quand vous répondez « Passer »
Alors le maître dit qu'il aurait pris
Quand vous répondez « Je prends »
Quand vous répondez « Prendre »
Alors le tutoriel salue votre réponse
+7 -4
View File
@@ -52,13 +52,16 @@ Fonctionnalité: Régler l'application
Scénario: Comment mettre l'application à jour ?
Dès qu'une nouvelle version d'Ustagammon est publiée, l'application la
télécharge d'elle-même, puis affiche « Nouvelle version disponible »,
jamais pendant que vous jouez un coup. « Mettre à jour » l'installe et
vous ramène où vous en étiez. Dans le menu, « Rechercher une mise à
télécharge d'elle-même, puis affiche en bas de l'écran « Nouvelle
version prête. », seulement entre deux tours, jamais pendant que vous
jouez un coup. « Mettre à jour » l'installe et vous ramène où vous en
étiez. Dans le menu, « Rechercher une mise à
jour » vérifie tout de suite s'il y en a une.
Étant donné qu'une nouvelle version d'Ustagammon est disponible
Et que vous avez déjà déplacé un pion de votre coup
Et que vous avez des dés à jouer
Alors l'application ne vous propose pas encore de mettre à jour
Quand vous déplacez un pion de votre coup
Alors l'application ne vous propose pas encore de mettre à jour
Quand vous avez joué votre coup
Alors l'application vous propose de mettre à jour
+2 -4
View File
@@ -1,7 +1,7 @@
# Les règles du backgammon
Tutoriel d'Ustagammon, en huit étapes. Texte sous licence CC BY-SA 4.0,
première version à valider par Pierre. Format : voir README.md.
validé par Pierre le 9 novembre 2026. Format : voir README.md.
## 1. Le but du jeu
@@ -101,9 +101,7 @@ Vous avez 6 et 3 : sortez deux pions.
- jet: 2-1
- coup: 2/off 1/off
Une partie gagnée vaut 1 point. Si l'adversaire n'a sorti aucun pion quand vous finissez, c'est un [gammon](gammon.md) : 2 points. S'il lui reste en plus un pion sur la barre ou dans votre camp, c'est un [backgammon](backgammon.md) : 3 points.
Dans une partie libre contre l'ordinateur, le gammon et le backgammon ne comptent double ou triple qu'une fois le videau doublé : c'est la [règle de Jacoby](regle-de-jacoby.md).
Une partie gagnée vaut 1 point. Si l'adversaire n'a sorti aucun pion, c'est un [gammon](gammon.md) : 2 points ; s'il lui reste en plus un pion sur la barre ou dans votre camp, c'est un [backgammon](backgammon.md) : 3 points. En partie libre, gammon et backgammon ne comptent que si le videau a été doublé : c'est la [règle de Jacoby](regle-de-jacoby.md).
## 8. Le videau
+133 -15
View File
@@ -11,7 +11,8 @@
// Lancé par tests/browser.sh. Le second site sert la même chose depuis un
// dossier que ce script modifie : il y publie une deuxième version pour
// essayer la mise à jour. Captures de la recette design (Chromium) dans
// <dossier des captures>/accueil-tutoriel/, en 412×915 et 915×412.
// <dossier des captures>/accueil-tutoriel/, en 412×915 et 915×412, et aussi
// en 844×320 pour le tutoriel.
import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
import { chromium, firefox } from "playwright";
@@ -29,6 +30,8 @@ const SIZES = [
[412, 915],
[915, 412],
];
// Le tutoriel est aussi vérifié dans le plus petit paysage de la recette.
const TUTORIAL_SIZES = [...SIZES, [844, 320]];
const browser = await browserType.launch();
const problems = [];
@@ -102,12 +105,12 @@ async function until(page, what, test, arg, ms = 30_000) {
throw new Error(`${what} : attente dépassée (${JSON.stringify(last)})`);
}
/** Range une capture de l'écran courant dans les deux tailles (Chromium). */
async function capture(page, name, settle = async () => {}) {
/** Range une capture de l'écran courant dans chaque taille (Chromium). */
async function capture(page, name, settle = async () => {}, sizes = SIZES) {
if (!full) {
return;
}
for (const [width, height] of SIZES) {
for (const [width, height] of sizes) {
await page.setViewportSize({ width, height });
await sleep(250);
await settle();
@@ -139,6 +142,60 @@ async function tutorialReady(page, hash) {
}, hash);
}
/** Recette du tutoriel dans la taille courante : numéros des flèches de
* 12 px au moins ; en paysage, rien ne défile et les boutons sont en vue. */
async function checkTutorialView(page, name) {
const view = await page.evaluate(() => {
const svg = document.getElementById("tutorial-board");
const box = svg.getBoundingClientRect();
const vb = svg.viewBox.baseVal;
const scale = Math.min(box.width / vb.width, box.height / vb.height);
const numbers = [...svg.querySelectorAll(".point-number")]
.map((t) => Number(t.getAttribute("font-size")) * scale);
const buttons = ["tutorial-back", "tutorial-next", "tutorial-drop", "tutorial-take"]
.map((id) => document.getElementById(id))
.filter((el) => el !== null && el.closest("[hidden]") === null)
.map((el) => {
const r = el.getBoundingClientRect();
return { id: el.id, top: r.top, bottom: r.bottom };
});
return {
landscape: document.documentElement.classList.contains("landscape"),
minNumber: numbers.length === 0 ? 0 : Math.min(...numbers),
scroll: document.scrollingElement.scrollHeight - innerHeight,
body: (() => {
const b = document.querySelector("#tutorial .tutorial-body");
return b.scrollHeight - b.clientHeight;
})(),
height: innerHeight,
buttons,
};
});
const size = `${name} en ${await page.evaluate(() => `${innerWidth}x${innerHeight}`)}`;
check(view.minNumber >= 12, `${size} : numéros des flèches de ${view.minNumber.toFixed(1)} px`);
if (view.landscape) {
check(view.scroll <= 1, `${size} : la page défile de ${view.scroll} px`);
check(view.body <= 1, `${size} : le texte défile de ${view.body} px`);
if (view.body > tutorialBodyScroll.px) {
tutorialBodyScroll = { px: view.body, where: size };
}
for (const b of view.buttons) {
check(b.top >= 0 && b.bottom <= view.height + 0.5,
`${size} : « ${b.id} » hors de l'écran (${b.top}-${b.bottom} pour ${view.height})`);
}
}
tutorialViews++;
tutorialMinNumber = Math.min(tutorialMinNumber, view.minNumber);
}
let tutorialViews = 0;
let tutorialBodyScroll = { px: 0, where: "" };
let tutorialMinNumber = Infinity;
/** Capture d'un écran du tutoriel, recette comprise, dans les trois tailles. */
async function tutorialCapture(page, name) {
await capture(page, name, () => checkTutorialView(page, name), TUTORIAL_SIZES);
}
/** Touche une case du plateau du tutoriel. */
async function touch(page, place) {
await page.click(`#tutorial-board [data-place="${place}"]`);
@@ -210,8 +267,17 @@ async function playTouches(page, touches) {
if (step === 2) {
check(await page.locator("#tutorial-board .tutorial-path").count() === 1,
"étape 2 : pas de chemin en pointillés");
// Sous les pions et sous les numéros des flèches.
const order = await page.evaluate(() => {
const children = [...document.getElementById("tutorial-board").children];
const at = (selector) => children.findIndex((c) => c.matches(selector));
const numbers = children.filter((c) => c.matches(".point-number")).map((c) => children.indexOf(c));
return { path: at(".tutorial-path"), pieces: at("g.pieces"), firstNumber: Math.min(...numbers) };
});
check(order.path >= 0 && order.path < order.pieces && order.path < order.firstNumber,
`étape 2 : le chemin passe au-dessus des pions ou des numéros ${JSON.stringify(order)}`);
}
await capture(page, `tutoriel-${step}`);
await tutorialCapture(page, `tutoriel-${step}`);
screens++;
await page.click("#tutorial-next");
const practice = await page.evaluate(() => location.hash.endsWith("/a-vous"));
@@ -222,11 +288,11 @@ async function playTouches(page, touches) {
await tutorialReady(page, avous);
check(await page.evaluate(() => document.getElementById("tutorial-next").classList.contains("secondary")),
`étape ${step}, à vous : « Continuer » principal avant le geste`);
await capture(page, `tutoriel-${step}-a-vous`);
await tutorialCapture(page, `tutoriel-${step}-a-vous`);
if (step === 8) {
await page.click("#tutorial-drop");
check(await verdict(page) === "wrong", "étape 8 : « Je passe » accepté");
await capture(page, "tutoriel-8-a-vous-refuse");
check(await verdict(page) === "wrong", "étape 8 : « Passer » accepté");
await tutorialCapture(page, "tutoriel-8-a-vous-refuse");
await page.click("#tutorial-take");
await until(page, "étape 8 juste", () =>
document.getElementById("tutorial-feedback").dataset.verdict === "right");
@@ -238,7 +304,7 @@ async function playTouches(page, touches) {
await until(page, "pion revenu", () => document.querySelectorAll("#tutorial-board .hit.can-move").length > 0);
const words = await page.textContent("#tutorial-feedback");
check(!/erreur/i.test(words), `étape ${step} : « erreur » dans le refus`);
await capture(page, `tutoriel-${step}-a-vous-refuse`);
await tutorialCapture(page, `tutoriel-${step}-a-vous-refuse`);
await playTouches(page, moves.right);
check(await verdict(page) === "right", `étape ${step} : bon coup refusé`);
check(await page.locator("#tutorial-board .hit.can-move").count() === 0,
@@ -248,13 +314,13 @@ async function playTouches(page, touches) {
`étape ${step} : « Continuer » pas principal après le bon geste`);
const vibrated = await page.evaluate(() => window.__vibrations.at(-1));
check(vibrated === "12", `étape ${step} : vibration ${vibrated}`);
await capture(page, `tutoriel-${step}-a-vous-reussi`);
await tutorialCapture(page, `tutoriel-${step}-a-vous-reussi`);
screens++;
await page.click("#tutorial-next");
}
await until(page, "fin du tutoriel", () => location.hash === "#/tutoriel/fin" &&
document.getElementById("tutorial").dataset.ready !== undefined);
await capture(page, "tutoriel-fin");
await tutorialCapture(page, "tutoriel-fin");
// Le retour du téléphone : l'écran d'avant.
await page.goBack();
await tutorialReady(page, "#/tutoriel/8/a-vous");
@@ -262,7 +328,9 @@ async function playTouches(page, touches) {
await until(page, "fin", () => location.hash === "#/tutoriel/fin");
await page.click("#tutorial-next");
await until(page, "première partie", () => location.hash === "#/" && window.__shown("game"));
log(`tutoriel : ${screens} écrans, gestes refusés puis justes sur les 4 écrans « à vous », fin`);
log(`tutoriel : ${screens} écrans, gestes refusés puis justes sur les 4 écrans « à vous », fin` +
(full ? ` ; recette sur ${tutorialViews} vues, numéros de ${tutorialMinNumber.toFixed(1)} px au moins, ` +
`texte défilant au plus de ${tutorialBodyScroll.px} px${tutorialBodyScroll.px > 0 ? ` (${tutorialBodyScroll.where})` : ""}` : ""));
// Le choix ne revient plus : l'accueil, ensuite.
await page.goto(base);
@@ -391,8 +459,27 @@ if (browserName === "chromium") {
};
open.onerror = () => resolve(null);
}));
const gameBefore = await journal();
check(await page.isHidden("#update-banner"), "bandeau de mise à jour sans nouvelle version");
// Si vous avez la main (jet d'ouverture), le bandeau attend la fin du
// tour : on publie la nouvelle version d'abord, puis on joue le coup.
const toPlay = () => page.evaluate(() => {
if (document.body.classList.contains("busy")) {
return "busy";
}
const usable = (id) => {
const b = document.getElementById(id);
return b !== null && !b.hidden && !b.disabled;
};
for (const id of ["roll", "again", "confirm", "skip", "take"]) {
if (usable(id)) {
return id;
}
}
if (document.querySelector("#board .hit.can-land")) {
return "land";
}
return document.querySelector("#board .hit.can-move") ? "move" : "busy";
});
// Une deuxième version publiée : autre version.json, autre sw.js.
writeFileSync(`${updateDir}/version.json`, '{"commit": "0123456789abcdef0123", "dirty": false}\n');
const sw = readFileSync(`${updateDir}/sw.js`, "utf8");
@@ -400,9 +487,40 @@ if (browserName === "chromium") {
await page.click("#menu-button");
await page.waitForSelector("#menu", { state: "visible" });
await page.click("#menu-update");
await until(page, "nouvelle version trouvée", () =>
document.getElementById("update-text")?.textContent !== "Recherche d'une mise à jour…", undefined, 120_000);
// Pendant votre coup, jamais de bandeau : seulement entre deux tours.
let waited = false;
for (let i = 0; i < 40; i++) {
const todo = await toPlay();
if (todo === "roll" || todo === "again") {
break;
}
if (todo === "busy") {
await sleep(100);
continue;
}
if (todo === "move" || todo === "land" || todo === "confirm") {
await sleep(400);
check(await page.isHidden("#update-banner"), `bandeau de mise à jour pendant votre coup (${todo})`);
waited = true;
}
await page.click(todo === "move" ? "#board .hit.can-move" : todo === "land" ? "#board .hit.can-land" : `#${todo}`);
await sleep(200);
}
await until(page, "bandeau de mise à jour", () => window.__shown("update-banner") && window.__shown("update-apply"), undefined, 120_000);
check((await page.textContent("#update-text")) === "Nouvelle version disponible.",
check((await page.textContent("#update-text")) === "Nouvelle version prête.",
`bandeau : « ${await page.textContent("#update-text")} »`);
// En bas, au-dessus de la barre d'actions, comme « Reprendre l'ancienne ».
const place = await page.evaluate(() => {
const banner = document.getElementById("update-banner").getBoundingClientRect();
const actions = document.getElementById("actions").getBoundingClientRect();
const top = document.querySelector(".topbar").getBoundingClientRect();
return { bottom: banner.bottom, top: banner.top, actions: actions.top, bar: top.bottom };
});
check(place.bottom <= place.actions && place.top > place.bar,
`bandeau de mise à jour mal placé : ${JSON.stringify(place)}`);
const gameBefore = await journal();
await capture(page, "09-mise-a-jour");
await Promise.all([page.waitForEvent("load", { timeout: 60_000 }), page.click("#update-apply")]);
await until(page, "partie reprise", () => location.hash === "#/" && !document.body.classList.contains("busy"), undefined, 60_000);
@@ -410,7 +528,7 @@ if (browserName === "chromium") {
await page.goto(`${updateBase}#/a-propos`);
await until(page, "nouvelle version", () =>
document.getElementById("about-version")?.textContent === "Version publiée : 0123456789ab.");
log("mise à jour : deuxième version publiée, bandeau, « Mettre à jour », partie retrouvée, nouvelle version");
log(`mise à jour : deuxième version publiée, bandeau ${waited ? "retenu pendant votre coup, puis " : ""}entre deux tours, « Mettre à jour », partie retrouvée, nouvelle version`);
// Rien de neuf : « Vous avez la dernière version ».
await page.goto(`${updateBase}#/`);
await until(page, "partie", () => !document.body.classList.contains("busy"), undefined, 60_000);
+9 -3
View File
@@ -815,7 +815,7 @@ async function checkAddresses() {
const seen = await tab.evaluate(() => {
const bar = document.getElementById("help-bar").getBoundingClientRect();
const target = document.querySelector(
"#help-play .question.open, #help-glossary dt.current",
"#help-play .question.open, #help-glossary .term-card.current",
).getBoundingClientRect();
return { scrolled: window.scrollY, barTop: bar.top, barBottom: bar.bottom, top: target.top };
});
@@ -882,9 +882,15 @@ async function checkAddresses() {
}
// Le bouton « Aide » ouvre la question du moment de la partie
// (help-context.ts) ; ici, l'onglet « Jouer » sans question.
const helpHref = await tab.getAttribute("#help-button", "href");
// L'adresse est lue au moment du toucher : la partie qui vient de
// s'ouvrir peut encore changer de moment (jet d'ouverture).
const helpHref = await tab.evaluate(() => {
const button = document.getElementById("help-button");
const href = button.getAttribute("href");
button.click();
return href;
});
check(/^#\/aide(\/[a-z0-9-]+)?$/.test(helpHref), `bouton Aide : ${helpHref}`);
await tab.click("#help-button");
await expectScreen(tab, "aide par son lien", (s) => s.hash === helpHref && s.help && s.play);
if (helpHref !== "#/aide") {
await tab.goBack();
+128 -11
View File
@@ -1293,7 +1293,7 @@ html.rotated .banner {
box-shadow: var(--shadow-raised);
}
.glossary dt {
.glossary .term-card {
padding-top: var(--space-3);
font-weight: 700;
}
@@ -1337,7 +1337,7 @@ html.rotated .banner {
}
.help .question,
.glossary dt {
.glossary .term-card {
scroll-margin-top: calc(var(--help-bar-height) + 12px + env(safe-area-inset-top));
}
@@ -1875,13 +1875,6 @@ html.bare .topbar {
width: 100%;
}
/* Bandeau de mise à jour : en haut, discret, jamais sur la barre
* d'actions ni sur « Reprendre l'ancienne ». */
.update-banner {
top: var(--space-2);
bottom: auto;
}
/* Tutoriel. */
.tutorial-head {
display: flex;
@@ -2039,8 +2032,132 @@ html.bare .topbar {
stroke: var(--brand-noyer);
}
/* En paysage, le plateau du tutoriel tient dans la hauteur de l'écran. */
html.landscape .tutorial-board,
/* En paysage, comme la position partagée : le plateau à gauche sur toute
* la hauteur (avec la colonne gauche du cadre), et à droite une colonne
* avec la barre de progression, le titre, le texte, la consigne « à vous »
* et les boutons, le tout sans défilement de la page. Si le texte est trop
* long pour l'écran, lui seul défile : les boutons restent en vue. */
html.landscape .tutorial {
position: fixed;
inset: 0;
display: grid;
grid-template-columns: minmax(0, 1fr) clamp(288px, 44vw, 400px);
grid-template-rows: auto auto minmax(0, 1fr) auto auto;
max-width: none;
margin: 0;
padding: 0;
overflow: hidden;
background: var(--background);
}
html.landscape .tutorial > * {
grid-column: 2;
margin-inline: 0;
padding-inline: var(--space-4) calc(var(--space-4) + env(safe-area-inset-right));
}
html.landscape .tutorial .overline {
display: none;
}
html.landscape .tutorial-head {
grid-row: 1;
min-height: 44px;
padding-top: 0;
}
html.landscape .tutorial-title {
grid-row: 2;
margin: 0 0 var(--space-1);
padding-right: calc(44px + var(--space-4));
font-size: 20px;
line-height: 26px;
}
/* La boîte du plateau s'efface : le plateau et « Revoir » se placent
* eux-mêmes dans la grille. */
html.landscape .tutorial-board-box {
display: contents;
}
html.landscape .tutorial-board {
grid-row: 1 / -1;
grid-column: 1;
width: 100%;
height: 100%;
margin: 0;
aspect-ratio: auto;
background: var(--frame);
}
html.landscape #tutorial-replay {
position: static;
grid-row: 2;
grid-column: 2;
justify-self: end;
align-self: start;
margin: -9px calc(var(--space-2) + env(safe-area-inset-right)) 0 0;
padding: 0;
}
html.landscape .tutorial-body {
grid-row: 3;
min-height: 0;
overflow-y: auto;
font-size: 15px;
line-height: 20px;
}
html.landscape .tutorial-text {
margin: 0;
padding-right: 0;
}
html.landscape .tutorial-text p {
margin-bottom: var(--space-2);
}
html.landscape .tutorial-task {
margin: 0;
padding: var(--space-2) 12px;
}
html.landscape .tutorial-feedback {
min-height: 0;
margin: var(--space-2) 0 0;
}
html.landscape .tutorial-cube {
grid-row: 4;
margin-top: var(--space-2);
}
/* La réponse au double donnée et saluée, les deux boutons n'ont plus
* d'usage : en paysage, leur place revient au texte. */
html.landscape .tutorial[data-solved] .tutorial-cube {
display: none;
}
/* Écran très bas (844 × 320) : texte un cran plus petit. */
@media (max-height: 360px) {
html.landscape .tutorial-body {
font-size: 14px;
line-height: 18px;
}
html.landscape .tutorial-title {
font-size: 18px;
line-height: 24px;
}
}
html.landscape .tutorial-nav {
grid-row: 5;
margin: 0;
padding-block: var(--space-2) calc(var(--space-2) + env(safe-area-inset-bottom));
}
/* Le premier lancement, en paysage : le plateau tient dans la hauteur. */
html.landscape .welcome-board {
width: min(100%, calc(70vh * 640 / 520));
height: auto;
+2 -1
View File
@@ -196,7 +196,8 @@
<span class="banner-timer" aria-hidden="true"></span>
</div>
<!-- « Nouvelle version disponible » : jamais pendant un coup en cours. -->
<!-- « Nouvelle version prête. » : en bas, comme « Reprendre l'ancienne »,
seulement entre deux tours. -->
<div id="update-banner" class="banner update-banner" role="status" hidden>
<span id="update-text"></span>
<button id="update-apply" type="button" class="secondary"></button>