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>
This commit is contained in:
+8
-8
@@ -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),
|
||||
});
|
||||
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
});
|
||||
|
||||
|
||||
@@ -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
@@ -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 |
|
||||
|
||||
@@ -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).
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user