diff --git a/app/src/ui/app.ts b/app/src/ui/app.ts index b89b692..a2e7029 100644 --- a/app/src/ui/app.ts +++ b/app/src/ui/app.ts @@ -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), }); diff --git a/app/src/ui/board-view.ts b/app/src/ui/board-view.ts index 35eeead..13c4ca2 100644 --- a/app/src/ui/board-view.ts +++ b/app/src/ui/board-view.ts @@ -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); } diff --git a/app/src/ui/help.ts b/app/src/ui/help.ts index db0379e..5dc24c6 100644 --- a/app/src/ui/help.ts +++ b/app/src/ui/help.ts @@ -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) { diff --git a/app/src/ui/home-texts.ts b/app/src/ui/home-texts.ts index 622635f..b662d27 100644 --- a/app/src/ui/home-texts.ts +++ b/app/src/ui/home-texts.ts @@ -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", diff --git a/app/src/ui/tutorial-view.ts b/app/src/ui/tutorial-view.ts index 815734d..7c6b2c1 100644 --- a/app/src/ui/tutorial-view.ts +++ b/app/src/ui/tutorial-view.ts @@ -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; diff --git a/app/src/ui/updates.ts b/app/src/ui/updates.ts index 7e4201e..ff8b265 100644 --- a/app/src/ui/updates.ts +++ b/app/src/ui/updates.ts @@ -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; } /** diff --git a/app/tests/steps/apprendre-les-regles.steps.ts b/app/tests/steps/apprendre-les-regles.steps.ts index 396ec7d..802a7ff 100644 --- a/app/tests/steps/apprendre-les-regles.steps.ts +++ b/app/tests/steps/apprendre-les-regles.steps.ts @@ -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; }); diff --git a/app/tests/steps/regler-l-application.steps.ts b/app/tests/steps/regler-l-application.steps.ts index 54dafbb..dbfb7f7 100644 --- a/app/tests/steps/regler-l-application.steps.ts +++ b/app/tests/steps/regler-l-application.steps.ts @@ -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)); }); diff --git a/docs/etape-1.md b/docs/etape-1.md index 715c358..7dd86f3 100644 --- a/docs/etape-1.md +++ b/docs/etape-1.md @@ -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 | diff --git a/docs/plans/2026-11-03-match-et-reglages.md b/docs/plans/2026-11-03-match-et-reglages.md deleted file mode 100644 index 86ff681..0000000 --- a/docs/plans/2026-11-03-match-et-reglages.md +++ /dev/null @@ -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). diff --git a/docs/scenarios/apprendre-les-regles.feature b/docs/scenarios/apprendre-les-regles.feature index 96b362c..423bb84 100644 --- a/docs/scenarios/apprendre-les-regles.feature +++ b/docs/scenarios/apprendre-les-regles.feature @@ -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 diff --git a/docs/scenarios/regler-l-application.feature b/docs/scenarios/regler-l-application.feature index 9a56668..c4083bf 100644 --- a/docs/scenarios/regler-l-application.feature +++ b/docs/scenarios/regler-l-application.feature @@ -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 diff --git a/docs/tutoriel/regles.md b/docs/tutoriel/regles.md index ea1371c..df1dcd8 100644 --- a/docs/tutoriel/regles.md +++ b/docs/tutoriel/regles.md @@ -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 diff --git a/tests/browser/accueil.mjs b/tests/browser/accueil.mjs index 104fec8..039b5d6 100644 --- a/tests/browser/accueil.mjs +++ b/tests/browser/accueil.mjs @@ -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 -// /accueil-tutoriel/, en 412×915 et 915×412. +// /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); diff --git a/tests/browser/play.mjs b/tests/browser/play.mjs index 840ec8b..e54373d 100644 --- a/tests/browser/play.mjs +++ b/tests/browser/play.mjs @@ -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(); diff --git a/web/app.css b/web/app.css index 787ff77..f23d9fb 100644 --- a/web/app.css +++ b/web/app.css @@ -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; diff --git a/web/index.html b/web/index.html index 7cc04fd..f885694 100644 --- a/web/index.html +++ b/web/index.html @@ -196,7 +196,8 @@ - +