From 949bfd952e72d8e3e106950b878ae0efe2d35f8a Mon Sep 17 00:00:00 2001 From: Pierre MARTIN Date: Tue, 3 Nov 2026 07:00:38 +0100 Subject: [PATCH 01/15] =?UTF-8?q?Plan=20:=20saisie=20du=20coup,=20videau?= =?UTF-8?q?=20confirm=C3=A9,=20pages=20de=20test=20retir=C3=A9es?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- docs/plans/2026-11-03-saisie-du-coup.md | 65 +++++++++++++++++++++++++ 1 file changed, 65 insertions(+) create mode 100644 docs/plans/2026-11-03-saisie-du-coup.md diff --git a/docs/plans/2026-11-03-saisie-du-coup.md b/docs/plans/2026-11-03-saisie-du-coup.md new file mode 100644 index 0000000..219bb4f --- /dev/null +++ b/docs/plans/2026-11-03-saisie-du-coup.md @@ -0,0 +1,65 @@ +# Saisie du coup, videau confirmé, pages de test du moteur retirées + +- **Date** : 2026-11-03 +- **Étape** : 0e (retours du premier test de Pierre sur le téléphone) +- **Statut** : en cours +- **Exécutant** : sous-agent general-purpose, dans un worktree + +## Objectif + +Rendre la saisie du coup aussi confortable que dans CBG, d'après les retours de +Pierre du 2026-11-03 (points 1, 6, 7, 8, 9 et 11 de son message), et retirer du +site les pages de test du moteur. + +## Consignes + +Règles de code : ADR 0007 (domaine pur, aucune règle de backgammon +réimplémentée : tout vient du moteur via le domaine). Glossaire et scénarios : +ADR 0008 (chaque comportement visible a son scénario Gherkin dans +`docs/scenarios/`, chaque nouveau terme sa fiche). Aucune nouvelle dépendance. + +1. **Pages de test du moteur retirées** (« supprimer la partie test du moteur + totalement ») : plus de `/moteur/` sur le site ni dans `make deploy`. + `make bench` (mesure sous Node) reste : déplacer ce dont il a besoin hors de + `web/`. Mettre à jour `tests/test_site.sh`, le Makefile, `deploy/`, le + service worker, `docs/guide-test-etape-0.md` et ajouter un amendement daté à + l'ADR 0009. +2. **Un seul coup possible, le pion avance** : quand le joueur touche un pion + (ou une case) et qu'une seule case d'arrivée est possible pour ce pion, le + demi-coup est joué aussitôt, sans second toucher. S'il y en a plusieurs, la + sélection actuelle reste. +3. **Coup à valider, annulable** : quand tous les demi-coups sont joués (ou + qu'aucun autre n'est possible), le coup n'est pas envoyé tout de suite. Le + joueur le valide en touchant ses dés (ou un bouton « Valider »). Tant qu'il + ne l'a pas validé, « Annuler » défait le dernier demi-coup, un par un. Le + journal d'événements ne reçoit `MovePlayed` qu'à la validation. +4. **Dés joués marqués** : chaque dé déjà utilisé par un demi-coup est + visiblement estompé. Pour un double, quatre dés, marqués un par un. +5. **Dés de l'adversaire touchables** : toucher la zone des dés de l'un ou + l'autre côté du plateau lance les dés quand le joueur peut lancer (et valide + le coup en attente de validation). +6. **Confirmation du videau** : toucher le videau ou « Doubler » ouvre une + confirmation courte (« Doubler à 2 ? L'ordinateur pourra prendre ou + passer. » : Doubler / Annuler) avant d'envoyer le double. + +Tests : scénarios Gherkin mis à jour ou ajoutés (validation du coup, annulation +demi-coup par demi-coup, confirmation du videau), tests unitaires, et +`tests/browser/play.mjs` adapté (Playwright est validé par Pierre). Lancer +`make lint`, `make test` et `make test-browser`. + +Rapporter : ce qui a été fait, les écarts, les résultats des trois commandes, +et un brouillon de description de MR (7 sections, voir `docs/organisation.md`). +Commits fréquents sur la branche du worktree ; ne pas pousser, ne pas ouvrir de +MR : la session principale s'en charge. + +## Critères de réussite + +- `make lint`, `make test` et `make test-browser` passent. +- Dans le navigateur : un pion à destination unique avance en un toucher ; un + coup complet attend la validation ; « Annuler » défait un demi-coup à la + fois ; les dés joués sont estompés ; le videau demande confirmation. +- `https://…/moteur/` n'existe plus dans le site construit (`make site`). + +## Résultat + +À remplir à la clôture. From ce2bd46ec3d54d7be0304c1e0ba3a7ca968d2b11 Mon Sep 17 00:00:00 2001 From: Pierre MARTIN Date: Tue, 3 Nov 2026 07:00:38 +0100 Subject: [PATCH 02/15] =?UTF-8?q?Pages=20de=20test=20du=20moteur=20retir?= =?UTF-8?q?=C3=A9es=20du=20site=20;=20make=20bench=20garde=20sa=20mesure?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- Makefile | 4 +- docs/adr/0009-socle-pwa-typescript.md | 21 ++++ docs/deploiement.md | 7 +- engine-wasm/bench.mjs | 4 +- {web/moteur => engine-wasm}/benchmark.mjs | 17 ++-- tests/test_site.sh | 24 +++-- web/moteur/bench-page.mjs | 98 ------------------- web/moteur/bench.html | 35 ------- web/moteur/engine-client.mjs | 66 ------------- web/moteur/index.html | 48 --------- web/moteur/page.mjs | 71 -------------- web/moteur/style.css | 113 ---------------------- 12 files changed, 50 insertions(+), 458 deletions(-) rename {web/moteur => engine-wasm}/benchmark.mjs (91%) delete mode 100644 web/moteur/bench-page.mjs delete mode 100644 web/moteur/bench.html delete mode 100644 web/moteur/engine-client.mjs delete mode 100644 web/moteur/index.html delete mode 100644 web/moteur/page.mjs delete mode 100644 web/moteur/style.css diff --git a/Makefile b/Makefile index 3883141..b4cd0cc 100644 --- a/Makefile +++ b/Makefile @@ -107,8 +107,6 @@ lint: ## Vérifie le style et les erreurs courantes deploy: site ## Sert l'application sur le réseau local (Ctrl+C pour arrêter) @ip="$$(ip -4 route get 1.1.1.1 2>/dev/null | awk '{ for (i = 1; i < NF; i++) if ($$i == "src") print $$(i + 1) }')"; \ echo "Application : http://$${ip:-ADRESSE-DU-PC}:$(WEB_PORT)/"; \ - echo "Test du moteur : http://$${ip:-ADRESSE-DU-PC}:$(WEB_PORT)/moteur/"; \ - echo "Page de mesure : http://$${ip:-ADRESSE-DU-PC}:$(WEB_PORT)/moteur/bench.html"; \ echo "À ouvrir depuis le téléphone, sur le même Wi-Fi. Ctrl+C pour arrêter." @docker run --rm --init -p $(WEB_PORT):8080 -v "$(CURDIR)/$(BUILD_DIR)/site:/site:ro" \ $(BUILD_C_IMAGE) python3 -m http.server 8080 --directory /site @@ -123,7 +121,7 @@ SITE_ICONS := icone.svg icone-192.png icone-512.png icone-maskable-192.png icone # Le site tel qu'il sera publié, reconstruit entièrement à chaque appel : # - web/ à la racine : la page de l'application, son style, sa police, le -# manifeste, le Web Worker du moteur, et les pages de test sous moteur/ ; +# manifeste et le Web Worker du moteur ; # - sous icons/, le logo et les icônes de la charte, copiés depuis # docs/identite/logo/ (une seule source, ADR 0010) ; # - l'application compilée (build/web/src/) sous app/ ; diff --git a/docs/adr/0009-socle-pwa-typescript.md b/docs/adr/0009-socle-pwa-typescript.md index c9cbed2..72bdfb4 100644 --- a/docs/adr/0009-socle-pwa-typescript.md +++ b/docs/adr/0009-socle-pwa-typescript.md @@ -68,3 +68,24 @@ vérification des dépendances. standard ») est tenu même au niveau fort (2 plis) : c'est le niveau standard de l'ordinateur à l'étape 0d. - Firefox sur le téléphone reste à vérifier, sans bloquer. + +## Amendements + +### 2026-11-03 — Pages de test du moteur retirées du site + +Décidé par Pierre après son premier test sur le téléphone : « supprimer la +partie test du moteur totalement ». + +- Les pages de test et de mesure de l'étape 0b (`web/moteur/` : `index.html`, + `bench.html` et leurs scripts) sont supprimées. Le site publié n'a plus rien + sous `/moteur/`, ni dans le cache du service worker ; `make deploy` + n'annonce plus que l'application, et l'aide ne renvoie plus vers le test du + moteur. +- Les mesures du Fairphone 4 ci-dessus, faites avec `bench.html`, restent la + référence. Pour les refaire, la mesure se lance sous Node sur le PC + (`make bench`) : son code partagé est déplacé de `web/moteur/benchmark.mjs` + vers `engine-wasm/benchmark.mjs`. Une mesure sur le téléphone demanderait de + remettre une page de mesure, à décider le moment venu. +- Le moteur reste vérifié par la suite de contrat sur WebAssembly + (`make test`) et, dans l'application, par la partie jouée dans un navigateur + (`make test-browser`). diff --git a/docs/deploiement.md b/docs/deploiement.md index ea30cc3..13b27b2 100644 --- a/docs/deploiement.md +++ b/docs/deploiement.md @@ -2,8 +2,8 @@ Ustagammon est un site statique (PWA, ADR 0009) : des fichiers HTML, JavaScript, le moteur en WebAssembly et ses données. L'application est à la -racine ; les pages de test du moteur de l'étape 0b restent publiées sous -`/moteur/` (test du moteur) et `/moteur/bench.html` (mesure). Il est publié sur +racine ; les pages de test du moteur de l'étape 0b ont été retirées (ADR +0009, amendement du 2026-11-03). Il est publié sur CapRover, machine `sans-pub` (https://captain.cloud.sans.pub), app `ustagammon`, sur https://ustagammon.sans.pub. @@ -112,8 +112,7 @@ ssh sans.pub 'sudo docker service ps srv-captain--ustagammon --format "{{.Image} ``` Puis ouvrir le site sur le téléphone : le plateau s'affiche et une consigne -remplace « Chargement du moteur… ». La page de test du moteur -(https://ustagammon.sans.pub/moteur/) doit afficher « Moteur prêt ». +remplace « Chargement du moteur… ». ## Retour arrière diff --git a/engine-wasm/bench.mjs b/engine-wasm/bench.mjs index f45b59c..a414894 100644 --- a/engine-wasm/bench.mjs +++ b/engine-wasm/bench.mjs @@ -1,4 +1,4 @@ -// bench.mjs -- la mesure de la page bench.html, sous Node, sur le PC +// bench.mjs -- mesure du moteur WebAssembly sous Node, sur le PC // (`make bench`). Premier ordre de grandeur seulement : le chiffre qui compte // est celui du téléphone. // @@ -10,7 +10,7 @@ import os from "node:os"; import { fileURLToPath } from "node:url"; import { gzipSync } from "node:zlib"; import createEngineModule from "../build/wasm/engine.mjs"; -import { DOWNLOADED_FILES, formatReport, measureTimes } from "../web/moteur/benchmark.mjs"; +import { DOWNLOADED_FILES, formatReport, measureTimes } from "./benchmark.mjs"; import { commands, loadEngine } from "../web/engine-wasm.mjs"; const root = new URL("../", import.meta.url); diff --git a/web/moteur/benchmark.mjs b/engine-wasm/benchmark.mjs similarity index 91% rename from web/moteur/benchmark.mjs rename to engine-wasm/benchmark.mjs index b9c3348..213e877 100644 --- a/web/moteur/benchmark.mjs +++ b/engine-wasm/benchmark.mjs @@ -1,5 +1,7 @@ -// benchmark.mjs -- mesure du moteur WebAssembly, partagée par la page de -// mesure (bench.html, dans le navigateur) et `make bench` (sous Node). +// benchmark.mjs -- mesure du moteur WebAssembly sous Node, pour `make bench` +// (engine-wasm/bench.mjs). Elle servait aussi à la page de mesure du site, +// retirée avec les pages de test du moteur (ADR 0009, amendement du +// 2026-11-03). // // Copyright (C) 2026 les auteurs d'Ustagammon. // Licence : GPLv3 ou ultérieure (voir LICENSE). @@ -20,13 +22,12 @@ export const TIME_THRESHOLDS = [ // Taille totale à télécharger, compressée : moins de 40 Mo. export const DOWNLOAD_LIMIT_BYTES = 40e6; -// Fichiers téléchargés pour faire tourner le moteur dans la page de test, -// chemins relatifs à la racine du site (web/ ; wasm/ est build/wasm/). +// Fichiers téléchargés pour faire tourner le moteur dans l'application +// (hors code de l'application elle-même, quelques dizaines de Ko), chemins +// relatifs à la racine du site (web/ ; wasm/ est build/wasm/). export const DOWNLOADED_FILES = [ - "moteur/index.html", - "moteur/style.css", - "moteur/page.mjs", - "moteur/engine-client.mjs", + "index.html", + "app.css", "engine-worker.mjs", "engine-wasm.mjs", "wasm/engine.mjs", diff --git a/tests/test_site.sh b/tests/test_site.sh index ff3b846..774c058 100755 --- a/tests/test_site.sh +++ b/tests/test_site.sh @@ -1,8 +1,9 @@ #!/usr/bin/env bash # Vérifie l'image de production (deploy/) avec le site assemblé (make package) : # construit l'image en local, la lance, et contrôle ce que reçoit un -# navigateur (application, pages de test du moteur, aide, manifeste, service -# worker, types MIME, compression, politique de sécurité, version). +# navigateur (application, aide, manifeste, service worker, types MIME, +# compression, politique de sécurité, version), et l'absence des anciennes +# pages de test du moteur. set -euo pipefail cd "$(dirname "$0")/.." @@ -71,10 +72,12 @@ check "wasm/engine.mjs" "text/javascript" check "wasm/engine.wasm" "application/wasm" check "wasm/engine.data" "application/octet-stream" check "version.json" "application/json" -# Pages de test du moteur (étape 0b). -check "moteur/" "text/html" -check "moteur/bench.html" "text/html" -check "moteur/engine-client.mjs" "text/javascript" +# Les pages de test du moteur (étape 0b) sont retirées (ADR 0009, +# amendement du 2026-11-03) : plus rien sous /moteur/. +[ ! -e build/site/moteur ] || report "build/site/moteur/ existe encore" +for gone in moteur/ moteur/bench.html; do + curl -s -o /dev/null -w "%{http_code}" "$base/$gone" | grep -qx 404 || report "/$gone : 404 attendu" +done # Aide : copies du glossaire et des scénarios (ADR 0008). check "docs/scenarios/jouer-une-partie.feature" "text/plain" check "docs/glossaire/README.md" "text/markdown" @@ -94,15 +97,16 @@ done < <(grep -o '"src": "[^"]*"' <<<"$manifest" | sed 's/"src": "\(.*\)"/\1/') # Le service worker met en cache chaque fichier du site, avec une version. sw="$(curl -s "$base/sw.js")" grep -qE '^const VERSION = "[0-9a-f]{16}";' <<<"$sw" || report "sw.js : version absente" -for file in ./ index.html app/main.js wasm/engine.data wasm/engine.wasm moteur/ \ +for file in ./ index.html app/main.js wasm/engine.data wasm/engine.wasm \ docs/glossaire/videau.md version.json fonts/AlegreyaSans-Bold.woff2 icons/favicon.svg; do grep -qF " \"$file\"," <<<"$sw" || report "sw.js : $file absent de la liste à mettre en cache" done listed="$(grep -c '^ "' <<<"$sw")" present="$(find build/site -type f ! -name sw.js | wc -l)" -# Chaque fichier, plus « ./ » et « moteur/ » pour les deux index.html. -[ "$listed" -eq "$((present + 2))" ] \ - || report "sw.js liste $listed adresses pour $present fichiers (plus 2 dossiers)" +# Chaque fichier, plus « ./ » pour index.html. +[ "$listed" -eq "$((present + 1))" ] \ + || report "sw.js liste $listed adresses pour $present fichiers (plus « ./ »)" +grep -q '"moteur/' <<<"$sw" && report "sw.js met encore en cache des pages de test du moteur" grep -o '^ "[^"]*"' <<<"$sw" | tr -d ' "' | while read -r file; do curl -sf -o /dev/null "$base/$file" || echo "sw.js : $file introuvable sur le site" done | grep . && report "sw.js liste des fichiers introuvables" diff --git a/web/moteur/bench-page.mjs b/web/moteur/bench-page.mjs deleted file mode 100644 index f6a47c1..0000000 --- a/web/moteur/bench-page.mjs +++ /dev/null @@ -1,98 +0,0 @@ -// bench-page.mjs -- page de mesure (bench.html) : chronomètre le moteur -// dans un Web Worker et produit le rapport à copier. -// -// Copyright (C) 2026 les auteurs d'Ustagammon. -// Licence : GPLv3 ou ultérieure (voir LICENSE). - -import { DOWNLOADED_FILES, DOWNLOAD_LIMIT_BYTES, formatReport, measureTimes, summarize } - from "./benchmark.mjs"; -import { startEngine } from "./engine-client.mjs"; - -const $ = (id) => document.getElementById(id); - -// Taille d'un fichier du site, brute et compressée gzip (comme le ferait un -// serveur qui compresse ses réponses). -// name est relatif à la racine du site, un dossier au-dessus de cette page. -async function fileSize(name) { - const blob = await (await fetch("../" + name, { cache: "no-store" })).blob(); - const gzipped = blob.stream().pipeThrough(new CompressionStream("gzip")); - const gzipBytes = (await new Response(gzipped).arrayBuffer()).byteLength; - return { name, bytes: blob.size, gzipBytes }; -} - -function addRow(cells) { - const row = $("table").insertRow(); - for (const text of cells) { - row.insertCell().textContent = text; - } -} - -async function measure() { - $("start").disabled = true; - $("results").hidden = true; - $("table").replaceChildren(); - - $("status").textContent = "Chargement du moteur…"; - const start = performance.now(); - const engine = startEngine(); - await engine.ready; - const loadMs = performance.now() - start; - - const { positions } = await (await fetch("../wasm/positions.json")).json(); - let memoryBytes = 0; - const times = await measureTimes({ - bestMove: (...args) => engine.bestMove(...args).then(keepMemory), - cubeDecision: (...args) => engine.cubeDecision(...args).then(keepMemory), - }, positions, (done, total) => { - $("status").textContent = "Calcul " + done + " sur " + total + "…"; - }); - function keepMemory(answer) { - memoryBytes = answer.memoryBytes; - return answer; - } - - $("status").textContent = "Mesure de la taille à télécharger…"; - const files = await Promise.all(DOWNLOADED_FILES.map(fileSize)); - - for (const s of summarize(times)) { - addRow([s.label, Math.round(s.medianMs) + " ms", Math.round(s.maxMs) + " ms", - s.limitMs + " ms", s.ok ? "OK" : "dépassé"]); - } - const gzipTotal = files.reduce((sum, f) => sum + f.gzipBytes, 0); - addRow(["Taille compressée", "", (gzipTotal / 1e6).toFixed(1).replace(".", ",") + " Mo", - DOWNLOAD_LIMIT_BYTES / 1e6 + " Mo", gzipTotal < DOWNLOAD_LIMIT_BYTES ? "OK" : "dépassé"]); - - $("report").value = formatReport({ - environment: navigator.userAgent, files, loadMs, memoryBytes, times, - }); - $("results").hidden = false; - $("status").textContent = "Mesure terminée."; -} - -// Copie dans le presse-papiers. L'API moderne n'existe que sur les pages -// sécurisées (https ou localhost) : sur le réseau local en http, on -// sélectionne le texte et on utilise l'ancienne commande de copie. -async function copyReport() { - try { - if (navigator.clipboard) { - await navigator.clipboard.writeText($("report").value); - } else { - $("report").select(); - if (!document.execCommand("copy")) { - throw new Error(); - } - } - $("copy-status").textContent = "Rapport copié : collez-le dans la conversation."; - } catch { - $("report").select(); - $("copy-status").textContent = "Copie impossible : le texte est sélectionné, " - + "copiez-le à la main (appui long, puis Copier)."; - } -} - -$("start").onclick = () => measure().catch((error) => { - $("status").textContent = "La mesure a échoué : " + error.message; - $("status").classList.add("error"); - $("start").disabled = false; -}); -$("copy").onclick = copyReport; diff --git a/web/moteur/bench.html b/web/moteur/bench.html deleted file mode 100644 index ca4d92a..0000000 --- a/web/moteur/bench.html +++ /dev/null @@ -1,35 +0,0 @@ - - - - - -Ustagammon : mesure du moteur - - - - -
-

Mesure du moteur

-

Cette page chronomètre le moteur sur les positions de référence : meilleur coup - aux niveaux rapide et fort, décision de videau au niveau fort. Elle mesure aussi - la taille à télécharger et la mémoire utilisée. Comptez une à quelques minutes ; - gardez l'écran allumé et la page au premier plan.

- -

- - - -

Page de test

-
- - diff --git a/web/moteur/engine-client.mjs b/web/moteur/engine-client.mjs deleted file mode 100644 index f8ddfc5..0000000 --- a/web/moteur/engine-client.mjs +++ /dev/null @@ -1,66 +0,0 @@ -// engine-client.mjs -- le moteur vu depuis les pages de test : démarre le -// Web Worker (../engine-worker.mjs, partagé avec l'application) et lui envoie -// les commandes. -// -// Copyright (C) 2026 les auteurs d'Ustagammon. -// Licence : GPLv3 ou ultérieure (voir LICENSE). -// -// Toutes les fonctions renvoient une promesse de { result, ms, memoryBytes } : -// result est l'objet JSON d'engine-cli, ms le temps de calcul mesuré dans le -// Worker. La promesse est rejetée si le moteur signale une erreur. - -import { commands } from "../engine-wasm.mjs"; - -export function startEngine() { - const worker = new Worker(new URL("../engine-worker.mjs", import.meta.url), { type: "module" }); - const pending = new Map(); - let nextId = 1; - let resolveReady, rejectReady; - const ready = new Promise((resolve, reject) => { - resolveReady = resolve; - rejectReady = reject; - }); - - worker.onmessage = (event) => { - const message = event.data; - if (message.type === "ready") { - resolveReady(message); - } else if (message.type === "failed") { - rejectReady(new Error(message.message)); - } else { - const { resolve, reject } = pending.get(message.id); - pending.delete(message.id); - if (message.error) { - reject(new Error(message.error)); - } else { - resolve(message); - } - } - }; - // Erreur au chargement du Worker lui-même (fichier absent, navigateur - // trop ancien pour les Workers en module ES…). - worker.onerror = (event) => { - rejectReady(new Error(event.message || "le Web Worker du moteur n'a pas démarré")); - }; - - function run(args) { - const id = nextId++; - return new Promise((resolve, reject) => { - pending.set(id, { resolve, reject }); - worker.postMessage({ id, args: args.map(String) }); - }); - } - - return { - // Promesse de { loadMs, memoryBytes } une fois le moteur chargé. - ready, - bestMove: (positionId, matchId, dice, ply) => - run(commands.bestMove(positionId, matchId, dice, ply)), - cubeDecision: (positionId, matchId, ply) => - run(commands.cubeDecision(positionId, matchId, ply)), - evaluate: (positionId, matchId, ply) => - run(commands.evaluate(positionId, matchId, ply)), - reference: (positionId, matchId, dice, ply) => - run(commands.reference(positionId, matchId, dice, ply)), - }; -} diff --git a/web/moteur/index.html b/web/moteur/index.html deleted file mode 100644 index 614357b..0000000 --- a/web/moteur/index.html +++ /dev/null @@ -1,48 +0,0 @@ - - - - - -Ustagammon : test du moteur - - - - -
-

Test du moteur

-

Le moteur GNU Backgammon, compilé en WebAssembly, calcule dans le navigateur.

-

Chargement du moteur…

- -
- - - -
- - - -
- -
- - - -

Page de mesure · L'application

-
- - diff --git a/web/moteur/page.mjs b/web/moteur/page.mjs deleted file mode 100644 index a51c175..0000000 --- a/web/moteur/page.mjs +++ /dev/null @@ -1,71 +0,0 @@ -// page.mjs -- page de test du moteur (index.html) : meilleur coup d'une -// position, avec son temps de calcul. -// -// Copyright (C) 2026 les auteurs d'Ustagammon. -// Licence : GPLv3 ou ultérieure (voir LICENSE). - -import { startEngine } from "./engine-client.mjs"; - -const $ = (id) => document.getElementById(id); -const engine = startEngine(); - -engine.ready.then( - ({ loadMs }) => { - $("status").textContent = "Moteur prêt, chargé en " + Math.round(loadMs) + " ms."; - $("best-move").disabled = false; - }, - (error) => { - $("status").textContent = "Le moteur n'a pas pu se charger : " + error.message; - $("status").classList.add("error"); - }, -); - -// Exemples : les positions de référence avec des dés. -fetch("../wasm/positions.json") - .then((response) => response.json()) - .then(({ positions }) => { - for (const p of positions.filter((p) => p.type === "move")) { - const option = new Option(p.id + " : " + p.description, p.id); - option.position = p; - $("example").add(option); - } - }) - .catch(() => {}); // la page reste utilisable sans les exemples - -$("example").onchange = () => { - const p = $("example").selectedOptions[0].position; - if (p) { - $("position-id").value = p.position_id; - $("match-id").value = p.match_id; - [$("die1").value, $("die2").value] = p.dice; - } -}; - -function showResult(move, details, time) { - $("move").textContent = move; - $("details").textContent = details; - $("time").textContent = time; - $("result").hidden = false; -} - -$("form").onsubmit = async (event) => { - event.preventDefault(); - $("best-move").disabled = true; - $("result").hidden = true; - try { - const { result, ms } = await engine.bestMove($("position-id").value.trim(), - $("match-id").value.trim(), [$("die1").value, $("die2").value], $("ply").value); - const best = result.best_move; - if (best) { - showResult(best.move, "Équité " + best.equity.toFixed(3).replace(".", ",") - + ", chances de gain " + (100 * best.probabilities.win).toFixed(1).replace(".", ",") - + " %, évalué à " + best.ply + " pli(s).", Math.round(ms) + " ms"); - } else { - showResult("aucun coup possible", "", Math.round(ms) + " ms"); - } - } catch (error) { - showResult("erreur", error.message, ""); - } finally { - $("best-move").disabled = false; - } -}; diff --git a/web/moteur/style.css b/web/moteur/style.css deleted file mode 100644 index 9bdc915..0000000 --- a/web/moteur/style.css +++ /dev/null @@ -1,113 +0,0 @@ -/* Pages de test du moteur : mise en forme minimale, lisible sur téléphone. */ - -:root { - --text: #1d1d1b; - --background: #fbfaf7; - --muted: #5f5e5a; - --accent: #1f5c4a; - --error: #a3261b; - --border: #cfccc4; -} - -@media (prefers-color-scheme: dark) { - :root { - --text: #ecebe6; - --background: #1b1c1a; - --muted: #a9a8a2; - --accent: #7cc4a8; - --error: #f08a7e; - --border: #4a4a46; - } -} - -* { - box-sizing: border-box; -} - -body { - margin: 0; - font: 17px/1.5 system-ui, sans-serif; - color: var(--text); - background: var(--background); -} - -main { - max-width: 40rem; - margin: 0 auto; - padding: 1rem; -} - -h1 { - font-size: 1.5rem; -} - -label { - display: block; - margin-bottom: 0.75rem; -} - -input, -select, -button, -textarea { - display: block; - width: 100%; - margin-top: 0.25rem; - padding: 0.5rem; - font: inherit; - color: inherit; - background: transparent; - border: 1px solid var(--border); - border-radius: 6px; -} - -.row { - display: flex; - gap: 0.75rem; -} - -.row label { - flex: 1; -} - -button { - margin: 0.5rem 0 1rem; - font-weight: 600; - color: var(--background); - background: var(--accent); - border-color: var(--accent); -} - -button:disabled { - opacity: 0.5; -} - -textarea { - height: 16rem; - font: 13px/1.4 monospace; -} - -table { - width: 100%; - border-collapse: collapse; -} - -th, -td { - padding: 0.25rem; - text-align: left; - border-bottom: 1px solid var(--border); -} - -.status { - color: var(--muted); -} - -.error { - color: var(--error); -} - -.result { - padding: 0.5rem 1rem; - border-left: 4px solid var(--accent); -} From 2c5278965b2fc05f42de9ddc4784c998c8e47daa Mon Sep 17 00:00:00 2001 From: Pierre MARTIN Date: Tue, 3 Nov 2026 07:04:26 +0100 Subject: [PATCH 03/15] =?UTF-8?q?Domaine=20:=20coup=20=C3=A0=20valider,=20?= =?UTF-8?q?demi-coups=20d=C3=A9faits=20un=20par=20un,=20d=C3=A9s=20jou?= =?UTF-8?q?=C3=A9s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- app/src/domain/game/game.ts | 137 +++++++++++++++++++++++++++++------ app/tests/full-game.test.ts | 2 + app/tests/game.test.ts | 100 +++++++++++++++++++++++++ app/tests/move-steps.test.ts | 3 + 4 files changed, 218 insertions(+), 24 deletions(-) diff --git a/app/src/domain/game/game.ts b/app/src/domain/game/game.ts index f6bb4f7..fa40436 100644 --- a/app/src/domain/game/game.ts +++ b/app/src/domain/game/game.ts @@ -79,9 +79,10 @@ export class Game { readonly #dice: DiceSource; readonly #ply: Ply; #journal: GameJournal = EMPTY_JOURNAL; - // Coup du joueur en cours de saisie : pas encore dans le journal. - #pending: HalfMove[] = []; - #pendingPosition: PositionId | null = null; + // Coup du joueur en cours de saisie : pas encore dans le journal (il n'y + // entre qu'à sa validation, confirmMove). Pour chaque demi-coup saisi, la + // position après lui, et si le coup est alors complet, selon le moteur. + #pending: PendingHalfMove[] = []; private constructor(engine: Engine, options: GameOptions) { this.#engine = engine; @@ -141,12 +142,37 @@ export class Game { /** Les demi-coups déjà saisis du coup en cours. */ get pendingHalfMoves(): readonly HalfMove[] { - return this.#pending; + return this.#pending.map((p) => p.halfMove); + } + + /** Les demi-coups saisis forment un coup complet (le moteur le dit) : il + * n'attend plus que d'être validé (confirmMove) ou défait + * (undoLastHalfMove). */ + get moveComplete(): boolean { + return this.#pending.at(-1)?.complete === true; } /** La position à afficher : après les demi-coups déjà saisis. */ get displayedPosition(): PositionId { - return this.#pendingPosition ?? this.state.position; + return this.#pending.at(-1)?.position ?? this.state.position; + } + + /** + * Les dés du jet à jouer (quatre pour un double), et pour chacun s'il est + * déjà utilisé par un demi-coup saisi ; liste vide hors de la saisie d'un + * coup du joueur. Un demi-coup utilise le dé qui l'a produit dans + * nextHalfMoves (de from vers from - dé, ou vers la sortie) : aucune + * règle n'est jugée ici, la légalité vient du moteur. + */ + get diceToPlay(): DieToPlay[] { + const state = this.state; + if (state.turn !== "player" || state.phase !== "moving" || state.roll === null) { + return []; + } + const [a, b] = state.roll; + const dice = a === b ? [a, a, a, a] : [a, b]; + const used = assignDice(dice, this.pendingHalfMoves); + return dice.map((value, i) => ({ value, used: used[i] === true })); } /** Le plateau affiché (après les demi-coups déjà saisis), décodé par le @@ -205,7 +231,7 @@ export class Game { */ async nextHalfMoves(): Promise { const state = this.state; - if (state.turn !== "player" || state.phase !== "moving") { + if (state.turn !== "player" || state.phase !== "moving" || this.moveComplete) { return []; } const roll = rollToPlay(state); @@ -220,7 +246,7 @@ export class Game { const moves = [...candidates.values()]; const checks = await Promise.all( moves.map((h) => - this.#engine.checkHalfMoves(state.position, roll, [...this.#pending, h]), + this.#engine.checkHalfMoves(state.position, roll, [...this.pendingHalfMoves, h]), ), ); return moves.filter((_, i) => checks[i]?.valid === true); @@ -274,34 +300,51 @@ export class Game { /** * Le joueur déplace un pion (un dé). Renvoie false si ce demi-coup ne - * mène à aucun coup légal : il est refusé et rien ne change. Quand les - * demi-coups forment un coup complet, le coup est joué. + * mène à aucun coup légal (ou si le coup est déjà complet) : il est + * refusé et rien ne change. Le coup n'est pas joué pour autant : une fois + * complet (moveComplete), il attend d'être validé (confirmMove). */ async playHalfMove(halfMove: HalfMove): Promise { const state = this.#expect("player", "moving"); - const halfMoves = [...this.#pending, halfMove]; - const check = await this.#engine.checkHalfMoves( - state.position, - rollToPlay(state), - halfMoves, - ); + if (this.moveComplete) { + return false; + } + const check = await this.#engine.checkHalfMoves(state.position, rollToPlay(state), [ + ...this.pendingHalfMoves, + halfMove, + ]); if (!check.valid || check.position === null) { return false; } - if (check.complete) { - this.cancelHalfMoves(); - await this.#playMove("player", halfMoves, check.position); - } else { - this.#pending = halfMoves; - this.#pendingPosition = check.position; - } + this.#pending = [ + ...this.#pending, + { halfMove, position: check.position, complete: check.complete }, + ]; return true; } - /** Oublie les demi-coups saisis du coup en cours. */ + /** Défait le dernier demi-coup saisi du coup en cours (rien s'il n'y en a + * pas) : il n'a jamais été dans le journal. */ + undoLastHalfMove(): void { + this.#pending = this.#pending.slice(0, -1); + } + + /** Oublie tous les demi-coups saisis du coup en cours. */ cancelHalfMoves(): void { this.#pending = []; - this.#pendingPosition = null; + } + + /** Le joueur valide son coup complet : il entre dans le journal + * (MovePlayed) et le tour passe. Erreur si le coup n'est pas complet. */ + async confirmMove(): Promise { + this.#expect("player", "moving"); + const last = this.#pending.at(-1); + if (last?.complete !== true) { + throw new Error("le coup n'est pas complet : jouez encore"); + } + const halfMoves = this.pendingHalfMoves; + this.cancelHalfMoves(); + await this.#playMove("player", halfMoves, last.position); } /** Le joueur double, avant de lancer les dés ; l'ordinateur prend ou passe @@ -454,6 +497,52 @@ export class Game { } } +/** Un dé du jet à jouer, et s'il est déjà utilisé par un demi-coup saisi. */ +export interface DieToPlay { + readonly value: number; + readonly used: boolean; +} + +/** Un demi-coup saisi, pas encore validé. */ +interface PendingHalfMove { + readonly halfMove: HalfMove; + /** La position après lui, vue du joueur. */ + readonly position: PositionId; + /** Les demi-coups saisis jusqu'à lui forment un coup complet. */ + readonly complete: boolean; +} + +/** + * Quels dés les demi-coups ont utilisés : chaque demi-coup reçoit un dé + * différent qui le produit (de from vers from - dé, ou vers la sortie si le + * dé dépasse), comme les candidats de nextHalfMoves. Les dés sont essayés du + * plus petit au plus grand : un pion sorti l'est avec le plus petit dé + * suffisant. Si aucune répartition ne convient, les premiers dés sont + * marqués, un par demi-coup. + */ +function assignDice(dice: readonly number[], halfMoves: readonly HalfMove[]): boolean[] { + const order = dice.map((_, i) => i).sort((i, j) => (dice[i] ?? 0) - (dice[j] ?? 0)); + const used = dice.map(() => false); + const fits = (h: HalfMove, die: number) => Math.max(h.from - die, OFF) === h.to; + const place = (k: number): boolean => { + const h = halfMoves[k]; + if (h === undefined) { + return true; + } + for (const i of order) { + if (!used[i] && fits(h, dice[i] ?? 0)) { + used[i] = true; + if (place(k + 1)) { + return true; + } + used[i] = false; + } + } + return false; + }; + return place(0) ? used : dice.map((_, i) => i < halfMoves.length); +} + /** Le jet à jouer, en phase moving. */ function rollToPlay(state: GameState): Roll { if (state.roll === null) { diff --git a/app/tests/full-game.test.ts b/app/tests/full-game.test.ts index 627e979..d698b49 100644 --- a/app/tests/full-game.test.ts +++ b/app/tests/full-game.test.ts @@ -45,6 +45,8 @@ async function playToTheEnd(game: Game, engine: Awaited { + const game = Game.start(fakeEngine(), { dice: fixedDice([3, 1]) }); + await assert.rejects(game.confirmMove(), /pas complet/); + await game.playHalfMove({ from: 8, to: 5 }); + await assert.rejects(game.confirmMove(), /pas complet/); + assert.equal(game.events.length, 2); +}); + +test("Annuler défait les demi-coups un par un, sans toucher au journal", async () => { + const game = Game.start(fakeEngine(), { dice: fixedDice([3, 1]) }); + await game.playHalfMove({ from: 8, to: 5 }); + await game.playHalfMove({ from: 6, to: 5 }); + const journal = game.journal; + + game.undoLastHalfMove(); + assert.deepEqual(game.pendingHalfMoves, [{ from: 8, to: 5 }]); + assert.equal(game.moveComplete, false); + assert.equal(game.displayedPosition, "HALF"); + + game.undoLastHalfMove(); + assert.deepEqual(game.pendingHalfMoves, []); + assert.equal(game.displayedPosition, START_POSITION); + game.undoLastHalfMove(); // rien à défaire : sans effet + assert.equal(game.journal, journal); + + // Le coup se ressaisit ensuite normalement. + assert.equal(await game.playHalfMove({ from: 8, to: 5 }), true); + assert.equal(await game.playHalfMove({ from: 6, to: 5 }), true); + assert.equal(game.moveComplete, true); +}); + +test("les dés utilisés par les demi-coups saisis sont marqués", async () => { + const game = Game.start(fakeEngine(), { dice: fixedDice([3, 1]) }); + assert.deepEqual(game.diceToPlay, [ + { value: 3, used: false }, + { value: 1, used: false }, + ]); + await game.playHalfMove({ from: 8, to: 5 }); + assert.deepEqual(game.diceToPlay, [ + { value: 3, used: true }, + { value: 1, used: false }, + ]); + await game.playHalfMove({ from: 6, to: 5 }); + assert.deepEqual( + game.diceToPlay.map((d) => d.used), + [true, true], + ); + await game.confirmMove(); + assert.deepEqual(game.diceToPlay, []); +}); + +test("un double donne quatre dés, marqués un par un ; une sortie prend le plus petit dé suffisant", async () => { + const engine = fakeEngine({ + checkHalfMoves: async () => ({ valid: true, complete: false, position: "P" }), + }); + const double = Game.start(engine, { + dice: fixedDice([2, 2]), + setup: { position: "X", firstTurn: "player" }, + }); + await double.roll(); + assert.equal(double.diceToPlay.length, 4); + await double.playHalfMove({ from: 13, to: 11 }); + await double.playHalfMove({ from: 11, to: 9 }); + assert.deepEqual( + double.diceToPlay.map((d) => d.used), + [true, true, false, false], + ); + + const bearOff = Game.start(engine, { + dice: fixedDice([6, 4]), + setup: { position: "X", firstTurn: "player" }, + }); + await bearOff.roll(); + // 3/off : le 4 suffit, le 6 reste. + await bearOff.playHalfMove({ from: 3, to: 0 }); + assert.deepEqual(bearOff.diceToPlay, [ + { value: 6, used: false }, + { value: 4, used: true }, + ]); + // 6/2 demande le 4 : la sortie a donc pris le 6. + await bearOff.playHalfMove({ from: 6, to: 2 }); + assert.deepEqual( + bearOff.diceToPlay.map((d) => d.used), + [true, true], + ); +}); + test("sans coup possible, le tour passe quand le joueur le laisse passer", async () => { const engine = fakeEngine({ legalMoves: async () => [] }); const game = Game.start(engine, { @@ -192,6 +289,7 @@ test("annuler repart du parent ; l'action suivante ouvre une branche", async () const game = Game.start(fakeEngine(), { dice: fixedDice([3, 1, 6, 5, 4, 2]) }); await game.playHalfMove({ from: 8, to: 5 }); await game.playHalfMove({ from: 6, to: 5 }); + await game.confirmMove(); const played = game.events.at(-1); assert.equal(played?.type, "MovePlayed"); @@ -200,6 +298,7 @@ test("annuler repart du parent ; l'action suivante ouvre une branche", async () assert.equal(game.state.turn, "player"); await game.playHalfMove({ from: 8, to: 5 }); await game.playHalfMove({ from: 6, to: 5 }); + await game.confirmMove(); // Deux coups accrochés au même jet : l'ancien reste dans le journal. const moves = game.journal.events.filter((e) => e.type === "MovePlayed"); @@ -244,6 +343,7 @@ test("une partie se reprend depuis son journal ; un journal d'une autre version const game = Game.start(fakeEngine(), { dice: fixedDice([3, 1]) }); await game.playHalfMove({ from: 8, to: 5 }); await game.playHalfMove({ from: 6, to: 5 }); + await game.confirmMove(); const saved = JSON.parse(JSON.stringify(game.journal)); const resumed = Game.resume(fakeEngine(), saved); diff --git a/app/tests/move-steps.test.ts b/app/tests/move-steps.test.ts index 7657036..23f1ebc 100644 --- a/app/tests/move-steps.test.ts +++ b/app/tests/move-steps.test.ts @@ -42,6 +42,7 @@ async function playerMove(position: PositionId, dice: [number, number], move: st for (const halfMove of parseHalfMoves(move)) { assert.equal(await game.playHalfMove(halfMove), true, formatHalfMoves([halfMove])); } + await game.confirmMove(); return game; } @@ -59,6 +60,7 @@ test("un coup simple : un plateau par demi-coup, sans pion frappé", async () => for (const halfMove of parseHalfMoves("8/5 6/5")) { await game.playHalfMove(halfMove); } + await game.confirmMove(); const steps = await game.lastMoveSteps(); assert.ok(steps !== null); assert.equal(steps.side, "player"); @@ -121,6 +123,7 @@ test("le coup de l'ordinateur, vu dans sa propre numérotation", async () => { for (const halfMove of parseHalfMoves("8/5 6/5")) { await game.playHalfMove(halfMove); } + await game.confirmMove(); await game.playComputerTurn(); const steps = await game.lastMoveSteps(); assert.ok(steps !== null); From 2d934529d5f6afa14d1944f65878d3839fc49319 Mon Sep 17 00:00:00 2001 From: Pierre MARTIN Date: Tue, 3 Nov 2026 07:04:26 +0100 Subject: [PATCH 04/15] =?UTF-8?q?Saisie=20du=20coup=20:=20pion=20=C3=A0=20?= =?UTF-8?q?destination=20unique,=20validation,=20Annuler,=20d=C3=A9s=20est?= =?UTF-8?q?omp=C3=A9s,=20videau=20confirm=C3=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- app/src/ui/app.ts | 133 ++++++++++++--- app/src/ui/board-model.ts | 13 ++ app/src/ui/board-view.ts | 41 +++-- app/src/ui/help.ts | 4 +- app/src/ui/texts.ts | 5 +- app/tests/steps/jouer-une-partie.steps.ts | 109 ++++++++++++- app/tests/ui.test.ts | 14 +- docs/scenarios/jouer-une-partie.feature | 42 +++++ tests/browser/play.mjs | 187 ++++++++++++++++++---- web/app.css | 33 +++- web/index.html | 9 ++ 11 files changed, 510 insertions(+), 80 deletions(-) diff --git a/app/src/ui/app.ts b/app/src/ui/app.ts index 21c535a..9a23ffd 100644 --- a/app/src/ui/app.ts +++ b/app/src/ui/app.ts @@ -13,8 +13,22 @@ import type { GameJournal } from "../domain/game/events.js"; import type { Game, MoveSteps, SideBoards } from "../domain/game/game.js"; import { formatHalfMoves, type HalfMove } from "../domain/game/position.js"; import { otherSide, type Side } from "../domain/game/side.js"; -import { boardModel, moveTargets, moveTrace, type Place, placeOf } from "./board-model.js"; -import { checkerSpot, NO_MARKS, renderBoard, type ShownDie, slideChecker } from "./board-view.js"; +import { + boardModel, + moveTargets, + moveTrace, + onlyHalfMove, + type Place, + placeOf, +} from "./board-model.js"; +import { + checkerSpot, + type DiceAction, + NO_MARKS, + renderBoard, + type ShownDie, + slideChecker, +} from "./board-view.js"; import { showHelp } from "./help.js"; import { TEXTS } from "./texts.js"; @@ -51,6 +65,7 @@ export function startApp(options: AppOptions): void { double: byId("double"), take: byId("take"), drop: byId("drop"), + confirm: byId("confirm"), cancel: byId("cancel"), skip: byId("skip"), replay: byId("replay"), @@ -61,12 +76,19 @@ export function startApp(options: AppOptions): void { game: byId("game"), help: byId("help"), helpContent: byId("help-content"), + doubleDialog: byId("double-dialog"), + doubleQuestion: byId("double-question"), + doubleConfirm: byId("double-confirm"), + doubleCancel: byId("double-cancel"), }; ui.board.setAttribute("aria-label", TEXTS.boardLabel); ui.roll.textContent = TEXTS.roll; ui.double.textContent = TEXTS.double; ui.take.textContent = TEXTS.take; ui.drop.textContent = TEXTS.drop; + ui.confirm.textContent = TEXTS.confirm; + ui.doubleConfirm.textContent = TEXTS.double; + ui.doubleCancel.textContent = TEXTS.cancel; ui.cancel.textContent = TEXTS.cancel; ui.skip.textContent = TEXTS.skip; ui.replay.textContent = TEXTS.replay; @@ -131,9 +153,9 @@ export function startApp(options: AppOptions): void { shownMove === null ? null : moveTrace(shownMove.side, shownMove.halfMoves, shownMove.hits), - // Mêmes conditions que les boutons « Lancer les dés » et - // « Doubler ». - canRoll: !busy && state.turn === "player" && state.phase === "rolling", + // Mêmes conditions que les boutons « Lancer les dés », + // « Valider » et « Doubler ». + diceAction: busy ? null : diceAction(game), canDouble: !busy && canDouble, }); } @@ -156,6 +178,7 @@ export function startApp(options: AppOptions): void { show(ui.roll, player && state.phase === "rolling"); show(ui.double, player && state.phase === "rolling"); ui.double.disabled = busy || !canDouble; + show(ui.confirm, player && state.phase === "moving" && game.moveComplete); show(ui.cancel, player && state.phase === "moving" && !stuck); show(ui.skip, stuck); ui.cancel.disabled = busy || game.pendingHalfMoves.length === 0; @@ -168,6 +191,7 @@ export function startApp(options: AppOptions): void { ui.roll, ui.take, ui.drop, + ui.confirm, ui.skip, ui.replay, ui.again, @@ -304,11 +328,22 @@ export function startApp(options: AppOptions): void { await g.skipTurn(); }); - ui.double.onclick = () => - act(async (g) => { + // Doubler demande confirmation : un double proposé ne se reprend pas. + ui.double.onclick = () => { + if (busy || game === null || !canDouble) { + return; + } + ui.doubleQuestion.textContent = TEXTS.confirmDouble(2 * game.state.cube.value); + ui.doubleDialog.showModal(); + }; + ui.doubleCancel.onclick = () => ui.doubleDialog.close(); + ui.doubleConfirm.onclick = () => { + ui.doubleDialog.close(); + void act(async (g) => { await g.double(); info = g.state.phase === "over" ? "" : TEXTS.computerTakes(g.state.cube.value); }); + }; ui.take.onclick = () => act((g) => { @@ -322,9 +357,17 @@ export function startApp(options: AppOptions): void { info = ""; }); + // Le coup saisi, complet, entre dans la partie : le tour passe. + ui.confirm.onclick = () => + act(async (g) => { + info = ""; + await g.confirmMove(); + }); + + // Annuler défait le dernier demi-coup saisi, un par un. ui.cancel.onclick = () => { if (!busy && game !== null) { - game.cancelHalfMoves(); + game.undoLastHalfMove(); selected = null; shownMove = null; void refresh(); @@ -344,15 +387,30 @@ export function startApp(options: AppOptions): void { ui.newGame.onclick = newGame; ui.again.onclick = newGame; - // Toucher le plateau : un pion jouable, puis une de ses cases d'arrivée. + /** Joue un demi-coup du joueur ; le coup attend ensuite sa suite ou sa + * validation. */ + function playHalfMove(halfMove: HalfMove): void { + void act(async (g) => { + info = ""; + await g.playHalfMove(halfMove); + }); + } + + // Toucher le plateau : un pion jouable, puis une de ses cases d'arrivée + // (ou aucune, s'il n'en a qu'une : il y va tout de suite). ui.board.addEventListener("click", (event) => { const target = event.target as Element | null; - // Toucher les dés en attente ou le videau : comme leurs boutons. + // Toucher les dés (d'un côté ou de l'autre du plateau) ou le videau : + // comme leurs boutons. const action = target?.closest("[data-action]")?.getAttribute("data-action"); if (action === "roll" && !ui.roll.hidden && !ui.roll.disabled) { ui.roll.click(); return; } + if (action === "confirm" && !ui.confirm.hidden && !ui.confirm.disabled) { + ui.confirm.click(); + return; + } if (action === "double" && !ui.double.hidden && !ui.double.disabled) { ui.double.click(); return; @@ -363,10 +421,12 @@ export function startApp(options: AppOptions): void { } const halfMove = selected === null ? undefined : targets.get(selected)?.get(place); if (halfMove !== undefined) { - void act(async (g) => { - info = ""; - await g.playHalfMove(halfMove); - }); + playHalfMove(halfMove); + return; + } + const only = place === selected ? null : onlyHalfMove(targets, place); + if (only !== null) { + playHalfMove(only); return; } selected = targets.has(place) && place !== selected ? place : null; @@ -405,6 +465,7 @@ export function startApp(options: AppOptions): void { ui.double, ui.take, ui.drop, + ui.confirm, ui.cancel, ui.skip, ui.replay, @@ -452,19 +513,44 @@ function rollBefore(game: Game): readonly number[] { return rolled?.type === "DiceRolled" ? rolled.roll : []; } -/** Les dés à montrer : le dernier jet, de la couleur de qui l'a lancé - * (au jet d'ouverture, un dé chacun). Ils sont barrés si le jet ne permet - * aucun coup : le joueur est bloqué (stuck), ou l'ordinateur vient de passer - * son tour sans bouger. */ +/** Ce que fait un toucher sur les dés : lancer quand le joueur peut lancer, + * valider quand son coup est complet. */ +function diceAction(game: Game): DiceAction | null { + const state = game.state; + if (state.turn !== "player") { + return null; + } + if (state.phase === "rolling") { + return "roll"; + } + return state.phase === "moving" && game.moveComplete ? "confirm" : null; +} + +/** Les dés à montrer : le dernier jet, de la couleur de qui l'a lancé (au + * jet d'ouverture, un dé chacun ; quatre pour un double). Pendant que le + * joueur saisit son coup, chaque dé déjà utilisé est estompé. Ils sont + * barrés si le jet ne permet aucun coup : le joueur est bloqué (stuck), ou + * l'ordinateur vient de passer son tour sans bouger. */ function shownDice(game: Game, stuck: boolean): ShownDie[] { + const toPlay = game.diceToPlay; const rolled = game.events.findLast((e) => e.type === "DiceRolled"); if (rolled?.type !== "DiceRolled") { return []; } - const blocked = stuck || computerCouldNotMove(game); - const sides: Side[] = - rolled.side === null ? ["player", "computer"] : [rolled.side, rolled.side]; - return rolled.roll.map((value, i) => ({ value, side: sides[i] ?? "player", blocked })); + // Au jet d'ouverture, un dé chacun. + const sideOf = (i: number): Side => rolled.side ?? (i === 0 ? "player" : "computer"); + if (toPlay.length > 0) { + return toPlay.map((die, i) => ({ + value: die.value, + side: sideOf(i), + blocked: stuck, + used: die.used, + })); + } + const blocked = computerCouldNotMove(game); + const [a, b] = rolled.roll; + const values = a === b ? [a, a, a, a] : [a, b]; + return values.map((value, i) => ({ value, side: sideOf(i), blocked, used: false })); } /** La dernière chose jouée (fin de partie mise à part) est un tour de @@ -519,6 +605,9 @@ function prompt(game: Game, selecting: boolean, canDouble: boolean): string { if (state.phase === "rolling") { return canDouble ? TEXTS.yourRoll : TEXTS.yourRollNoDouble; } + if (state.phase === "moving" && game.moveComplete) { + return TEXTS.confirmOrUndo; + } if (state.phase === "moving" && state.roll !== null) { return selecting ? TEXTS.pickTarget : TEXTS.yourMove(state.roll); } diff --git a/app/src/ui/board-model.ts b/app/src/ui/board-model.ts index d07fe8f..bbf19f1 100644 --- a/app/src/ui/board-model.ts +++ b/app/src/ui/board-model.ts @@ -82,6 +82,19 @@ export function moveTargets(halfMoves: readonly HalfMove[]): Map>, + place: Place, +): HalfMove | null { + const tos = targets.get(place); + if (tos === undefined || tos.size !== 1) { + return null; + } + return [...tos.values()][0] ?? null; +} + /** Ce qui reste dessiné d'un coup une fois joué. */ export interface MoveTrace { /** Le camp qui a joué. */ diff --git a/app/src/ui/board-view.ts b/app/src/ui/board-view.ts index c6ad52e..b96d4ab 100644 --- a/app/src/ui/board-view.ts +++ b/app/src/ui/board-view.ts @@ -20,8 +20,13 @@ export interface ShownDie { readonly side: Side; /** Le jet ne permet aucun coup : le dé est estompé et barré. */ readonly blocked: boolean; + /** Le dé est déjà utilisé par un demi-coup saisi : il est estompé. */ + readonly used: boolean; } +/** Ce que fait un toucher sur les dés : lancer, ou valider le coup saisi. */ +export type DiceAction = "roll" | "confirm"; + /** Ce qui s'ajoute aux pions : dés, videau, mises en évidence. */ export interface BoardDecor { readonly dice: readonly ShownDie[]; @@ -33,8 +38,10 @@ export interface BoardDecor { readonly targets: ReadonlySet; /** Les traces du dernier coup de l'ordinateur, tant qu'on les montre. */ readonly trace: MoveTrace | null; - /** Le joueur peut lancer les dés : ses dés en attente se touchent. */ - readonly canRoll: boolean; + /** Ce que fait un toucher sur la zone des dés, d'un côté ou de l'autre + * du plateau (null : rien). Pour « roll », les dés du joueur en attente + * sont dessinés. */ + readonly diceAction: DiceAction | null; /** Le joueur peut doubler : le videau se touche. */ readonly canDouble: boolean; } @@ -45,7 +52,7 @@ export const NO_MARKS = { selected: null, targets: new Set(), trace: null, - canRoll: false, + diceAction: null, canDouble: false, } as const; @@ -158,9 +165,16 @@ export function renderBoard(svg: SVGSVGElement, board: BoardModel, decor: BoardD if (decor.trace !== null) { drawTrace(svg, board, decor.trace); } - if (decor.canRoll) { - const center = waitingDice(svg); - addAction(hits, "roll", TEXTS.roll, center, 2 * DIE + 8 + MIN_TARGET / 2); + if (decor.diceAction !== null) { + if (decor.diceAction === "roll") { + waitingDice(svg); + } + // Une zone sur chaque moitié du plateau : les dés en attente (à + // gauche) comme le dernier jet (à droite) se touchent. + const label = decor.diceAction === "roll" ? TEXTS.roll : TEXTS.confirm; + for (const x of [FRAME + HALF_WIDTH / 2, RIGHT_HALF + HALF_WIDTH / 2]) { + addAction(hits, decor.diceAction, label, { x, y: MIDDLE }, HALF_WIDTH - 2 * FRAME); + } } if (decor.canDouble) { cube.classList.add("can-double"); @@ -493,7 +507,9 @@ function drawDice(svg: SVGSVGElement, dice: readonly ShownDie[]): void { const x = center - total / 2 + i * (DIE + gap); const y = MIDDLE - DIE / 2; const group = add(svg, "g", { - class: die.blocked ? "die die-blocked" : "die", + class: ["die", die.blocked ? "die-blocked" : "", die.used ? "die-used" : ""] + .filter(Boolean) + .join(" "), "data-value": die.value, }); add(group, "rect", { class: `die-${die.side}`, x, y, width: DIE, height: DIE, rx: 6 }); @@ -535,9 +551,8 @@ function cubeCenter(cube: Cube): Spot { } /** Les dés du joueur en attente d'être lancés, faces vides, au milieu de la - * moitié gauche (la droite garde le dernier jet de l'ordinateur). Renvoie - * leur centre. */ -function waitingDice(svg: SVGSVGElement): Spot { + * moitié gauche (la droite garde le dernier jet de l'ordinateur). */ +function waitingDice(svg: SVGSVGElement): void { const center = { x: FRAME + HALF_WIDTH / 2, y: MIDDLE }; const gap = 8; const group = add(svg, "g", { class: "die die-waiting" }); @@ -551,13 +566,13 @@ function waitingDice(svg: SVGSVGElement): Spot { rx: 6, }); } - return center; } /** * Une zone à toucher qui fait la même chose qu'un bouton (lancer les dés, - * doubler) : au moins MIN_TARGET de côté, centrée sur ce qu'on touche, avec - * un rôle et un nom accessibles. Les boutons restent pour le clavier. + * valider le coup, doubler) : au moins MIN_TARGET de côté, centrée sur ce + * qu'on touche, avec un rôle et un nom accessibles. Les boutons restent + * pour le clavier. */ function addAction( hits: Element, diff --git a/app/src/ui/help.ts b/app/src/ui/help.ts index e064764..67afdd5 100644 --- a/app/src/ui/help.ts +++ b/app/src/ui/help.ts @@ -91,10 +91,8 @@ export async function showHelp(container: HTMLElement): Promise { renderFeature(container, feature); } renderGlossary(container, glossary); - const about = element(container, "p", `${TEXTS.about} `); + const about = element(container, "p", TEXTS.about); about.className = "about"; - const link = element(about, "a", TEXTS.engineTestLink); - link.href = "moteur/"; container.dataset.loaded = "true"; } catch { status.textContent = TEXTS.helpFailed; diff --git a/app/src/ui/texts.ts b/app/src/ui/texts.ts index fa01cb4..22c62b5 100644 --- a/app/src/ui/texts.ts +++ b/app/src/ui/texts.ts @@ -36,6 +36,7 @@ export const TEXTS = { double: "Doubler", take: "Prendre", drop: "Passer", + confirm: "Valider", cancel: "Annuler", skip: "Continuer", replay: "Revoir", @@ -60,6 +61,8 @@ export const TEXTS = { yourMove: (dice: readonly number[]) => `Jouez ${roll(dice)} : touchez un pion, puis sa case d'arrivée.`, pickTarget: "Touchez une case d'arrivée en surbrillance, ou un autre pion.", + confirmOrUndo: + "Touchez vos dés ou « Valider » pour jouer ce coup, ou « Annuler » pour reprendre un pion.", youCannotMove: (dice: readonly number[]) => `Aucun coup possible avec ${roll(dice)}.`, pressContinue: "Touchez « Continuer » : le tour passe à l'ordinateur.", thinking: "L'ordinateur réfléchit…", @@ -67,6 +70,7 @@ export const TEXTS = { `L'ordinateur a joué ${roll(dice)} : ${move} (${seconds(ms)}).`, computerCannotMove: (dice: readonly number[]) => `L'ordinateur ne peut pas jouer ${roll(dice)}.`, + confirmDouble: (value: number) => `Doubler à ${value} ? L'ordinateur pourra prendre ou passer.`, computerDoubles: "L'ordinateur vous propose de doubler : prenez ou passez.", computerTakes: (value: number) => `L'ordinateur prend : la partie vaut maintenant ${points(value)}.`, @@ -90,5 +94,4 @@ export const TEXTS = { helpLoading: "Chargement de l'aide…", helpFailed: "L'aide n'a pas pu se charger.", about: "Ustagammon est un logiciel libre (GPLv3). L'ordinateur joue avec GNU Backgammon.", - engineTestLink: "Test du moteur", } as const; diff --git a/app/tests/steps/jouer-une-partie.steps.ts b/app/tests/steps/jouer-une-partie.steps.ts index 0cb5b91..4244d4c 100644 --- a/app/tests/steps/jouer-une-partie.steps.ts +++ b/app/tests/steps/jouer-une-partie.steps.ts @@ -80,6 +80,15 @@ async function play(text: string): Promise { return true; } +/** Joue les demi-coups puis valide le coup, complet. */ +async function playAndConfirm(text: string): Promise { + if (!(await play(text))) { + return false; + } + await game.confirmMove(); + return true; +} + function lastEvent(): GameEvent { const event = game.events.at(-1); assert.ok(event !== undefined, "aucun événement"); @@ -160,12 +169,62 @@ Then("la position n'a pas changé", () => { assert.equal(lastEvent().type, "DiceRolled"); }); +// ------------------------------------------ Valider ou reprendre son coup + +Then("votre coup attend votre validation", async () => { + assert.equal(accepted, true); + assert.equal(game.moveComplete, true); + // Rien n'est encore dans le journal, et plus aucun pion ne bouge. + assert.equal(lastEvent().type, "DiceRolled"); + assert.equal(game.state.turn, "player"); + assert.deepEqual(await game.nextHalfMoves(), []); +}); + +Then("vos dés sont tous marqués comme joués", () => { + assert.ok(game.diceToPlay.length > 0); + assert.ok(game.diceToPlay.every((die) => die.used)); +}); + +When("vous validez votre coup", async () => { + await game.confirmMove(); +}); + +Then("seul votre {int} est marqué comme joué", (value: number) => { + const used = game.diceToPlay.filter((die) => die.used); + assert.deepEqual( + used.map((die) => die.value), + [value], + ); +}); + +Then("chacun de vos pions jouables n'a plus qu'une case d'arrivée", async () => { + const halfMoves = await game.nextHalfMoves(); + assert.ok(halfMoves.length > 0); + const starts = halfMoves.map((h) => h.from); + assert.equal(new Set(starts).size, starts.length); +}); + +When("vous annulez le dernier demi-coup", () => { + game.undoLastHalfMove(); +}); + +Then("il ne reste de joué que {word}", (text: string) => { + assert.deepEqual(game.pendingHalfMoves, parseHalfMoves(text)); + assert.equal(game.moveComplete, false); + assert.equal(lastEvent().type, "DiceRolled"); +}); + +Then("aucun de vos dés n'est marqué comme joué", () => { + assert.ok(game.diceToPlay.length > 0); + assert.ok(game.diceToPlay.every((die) => !die.used)); +}); + // ------------------------------------------------------- L'ordinateur joue Given("c'est au tour de l'ordinateur", async () => { // Vous commencez avec 3-1, puis l'ordinateur tirera 6-4. game = Game.start(engine, { dice: fixedDice([3, 1, 6, 4]) }); - assert.equal(await play("8/5 6/5"), true); + assert.equal(await playAndConfirm("8/5 6/5"), true); assert.equal(game.state.turn, "computer"); }); @@ -287,6 +346,52 @@ Then("vous pouvez revoir son coup", async () => { // ------------------------------------------------------------------ Videau +// La confirmation elle-même est une question de l'interface (une fenêtre +// « Doubler à 2 ? ») : ces étapes vérifient que rien n'est proposé tant que +// vous n'avez pas confirmé, et ce qui suit votre confirmation. + +Given("vous pouvez doubler", async () => { + game = Game.start(engine, { setup: { position: START_POSITION, firstTurn: "player" } }); + assert.equal(game.state.phase, "rolling"); +}); + +When("vous demandez à doubler", async () => { + assert.equal(await game.canDouble(), true); +}); + +When("vous changez d'avis", () => { + // Vous refermez la question : la partie n'est pas touchée. +}); + +Then("rien n'est proposé à l'ordinateur", () => { + assert.ok(!game.events.some((e) => e.type === "CubeOffered")); + assert.equal(game.state.phase, "rolling"); + assert.equal(game.state.turn, "player"); + assert.deepEqual(game.state.cube, { value: 1, owner: null }); +}); + +When("vous confirmez", async () => { + await game.double(); +}); + +Then("l'ordinateur prend ou passe, comme le recommande le moteur", async () => { + const decision = await engine.cubeDecision( + START_POSITION, + { value: 1, owner: "centered" }, + STANDARD_PLY, + ); + const offered = game.events.findIndex((e) => e.type === "CubeOffered"); + const answer = game.events[offered + 1]; + assert.ok(offered >= 0 && answer !== undefined); + if (decision.take) { + assert.equal(answer.type, "CubeTaken"); + assert.deepEqual(game.state.cube, { value: 2, owner: "computer" }); + } else { + assert.equal(answer.type, "CubeDropped"); + assert.deepEqual(game.state.result, { winner: "player", kind: "single", points: 1 }); + } +}); + Given("l'ordinateur vous propose de doubler", async () => { game = Game.start(engine, { setup: { position: COMPUTER_DOUBLES, firstTurn: "computer" } }); await game.playComputerTurn(); @@ -346,7 +451,7 @@ Given("un joueur a sorti tous ses pions", async () => { dice: fixedDice([6, 5]), }); await game.roll(); - assert.equal(await play("6/off 5/off"), true); + assert.equal(await playAndConfirm("6/off 5/off"), true); }); Then("le résultat indique le gagnant et les points gagnés", () => { diff --git a/app/tests/ui.test.ts b/app/tests/ui.test.ts index 55a4a2f..4ba786d 100644 --- a/app/tests/ui.test.ts +++ b/app/tests/ui.test.ts @@ -12,7 +12,14 @@ import type { BoardSide } from "../src/domain/game/engine.js"; import { appendEvent, EMPTY_JOURNAL } from "../src/domain/game/events.js"; import { parseHalfMoves, START_POSITION } from "../src/domain/game/position.js"; import { createGameStore } from "../src/storage/game-store.js"; -import { boardModel, moveTargets, moveTrace, placeOf, pointSlot } from "../src/ui/board-model.js"; +import { + boardModel, + moveTargets, + moveTrace, + onlyHalfMove, + placeOf, + pointSlot, +} from "../src/ui/board-model.js"; import { parseFeature, parseGlossaryEntry, parseGlossaryIndex } from "../src/ui/help-text.js"; import { TEXTS } from "../src/ui/texts.js"; @@ -65,6 +72,11 @@ test("les demi-coups possibles donnent les pions jouables et leurs cases d'arriv assert.deepEqual([...targets.keys()], ["p8", "bar-player", "p3"]); assert.deepEqual([...(targets.get("p8")?.keys() ?? [])], ["p5", "p7"]); assert.deepEqual(targets.get("p3")?.get("off-player"), { from: 3, to: 0 }); + // Un seul toucher suffit pour un pion à destination unique. + assert.deepEqual(onlyHalfMove(targets, "p3"), { from: 3, to: 0 }); + assert.deepEqual(onlyHalfMove(targets, "bar-player"), { from: 25, to: 22 }); + assert.equal(onlyHalfMove(targets, "p8"), null); + assert.equal(onlyHalfMove(targets, "p13"), null); }); test("traces d'un coup simple : une flèche par demi-coup, départs et arrivées", () => { diff --git a/docs/scenarios/jouer-une-partie.feature b/docs/scenarios/jouer-une-partie.feature index 5e5e2d1..55d8b57 100644 --- a/docs/scenarios/jouer-une-partie.feature +++ b/docs/scenarios/jouer-une-partie.feature @@ -11,11 +11,40 @@ Fonctionnalité: Jouer une partie contre l'ordinateur Et le premier jet de dés désigne qui commence Scénario: Jouer un coup permis + Touchez un pion, puis sa case d'arrivée ; s'il n'en a qu'une, il y va dès + que vous le touchez. Chaque dé joué s'estompe. Une fois tous vos dés + joués, votre coup attend que vous le validiez : touchez vos dés, d'un côté + ou de l'autre du plateau, ou « Valider ». + Étant donné que c'est à vous de jouer avec le jet 3-1 Quand vous jouez 8/5 6/5 + Alors votre coup attend votre validation + Et vos dés sont tous marqués comme joués + Quand vous validez votre coup Alors votre coup est accepté Et c'est ensuite au tour de l'ordinateur + Scénario: Un pion qui n'a qu'une case d'arrivée + Après 8/5, il ne vous reste que le 1 : chaque pion jouable n'a plus + qu'une case d'arrivée, et il y va dès que vous le touchez. + + Étant donné que c'est à vous de jouer avec le jet 3-1 + Quand vous jouez 8/5 + Alors seul votre 3 est marqué comme joué + Et chacun de vos pions jouables n'a plus qu'une case d'arrivée + + Scénario: Reprendre un pion avant de valider + Tant que vous n'avez pas validé votre coup, « Annuler » reprend le + dernier pion déplacé ; touchez-le encore pour reprendre le précédent. + + Étant donné que c'est à vous de jouer avec le jet 3-1 + Quand vous jouez 8/5 6/5 + Et que vous annulez le dernier demi-coup + Alors il ne reste de joué que 8/5 + Quand vous annulez le dernier demi-coup + Alors la position n'a pas changé + Et aucun de vos dés n'est marqué comme joué + Scénario: Un coup interdit est refusé La case 12 est tenue par l'ordinateur, qui y a cinq pions : vous ne pouvez pas vous y poser. @@ -54,6 +83,19 @@ Fonctionnalité: Jouer une partie contre l'ordinateur Et votre pion frappé part sur la barre Et vous pouvez revoir son coup + Scénario: Doubler, après confirmation + Avant de lancer les dés, vous pouvez doubler en touchant le videau ou + « Doubler ». L'application vous demande de confirmer : une fois proposé, + un double ne se reprend pas. + + Étant donné que vous pouvez doubler + Quand vous demandez à doubler + Mais que vous changez d'avis + Alors rien n'est proposé à l'ordinateur + Quand vous demandez à doubler + Et que vous confirmez + Alors l'ordinateur prend ou passe, comme le recommande le moteur + Scénario: Prendre le double de l'ordinateur Étant donné que l'ordinateur vous propose de doubler Quand vous prenez diff --git a/tests/browser/play.mjs b/tests/browser/play.mjs index 4ef20f5..19e99e2 100644 --- a/tests/browser/play.mjs +++ b/tests/browser/play.mjs @@ -1,6 +1,7 @@ // play.mjs -- joue une partie complète par l'interface, dans un vrai // navigateur sans écran (Playwright), sur le site servi par l'image de -// production : dés, coups au toucher, coups de l'ordinateur, videau, reprise +// production : dés, coups au toucher (validés, annulés), coups de +// l'ordinateur, videau (avec sa confirmation), reprise // après rechargement, aide, puis hors ligne (service worker). // // Copyright (C) 2026 les auteurs d'Ustagammon. @@ -101,7 +102,7 @@ async function waitForPlayer() { const b = document.getElementById(id); return !b.hidden && !b.disabled; }; - for (const id of ["again", "take", "skip", "roll"]) { + for (const id of ["again", "take", "skip", "confirm", "roll"]) { if (usable(id)) { return id; } @@ -161,7 +162,8 @@ async function checkTraces() { // L'ordinateur n'a pas pu jouer : son jet reste barré, avec un message. const info = await page.textContent("#info"); const blocked = await page.locator(".die-blocked").count(); - check(/^L'ordinateur ne peut pas jouer \d-\d\.$/.test(info ?? "") && blocked === 2, + check(/^L'ordinateur ne peut pas jouer \d-\d\.$/.test(info ?? "") && blocked >= 2 && + blocked === (await shownDice()).dice, `jet de l'ordinateur sans coup mal montré : « ${info} », ${blocked} dés barrés`); log(`l'ordinateur ne peut pas jouer : « ${info} »`); } @@ -175,18 +177,35 @@ async function checkTraces() { return traces; } -/** La zone à toucher d'une action du plateau (dés ou videau) : un bouton - * accessible, d'au moins 44 px de côté. Renvoie son sélecteur. */ -async function touchTarget(action, label) { - const selector = `[data-action="${action}"]`; - const zone = page.locator(selector); - check((await zone.count()) === 1, `zone ${action} absente du plateau`); - check((await zone.getAttribute("role")) === "button" && - (await zone.getAttribute("aria-label")) === label, `zone ${action} sans rôle accessible`); - const box = await zone.boundingBox(); - check(box.width >= 44 && box.height >= 44, - `zone ${action} trop petite : ${Math.round(box.width)} x ${Math.round(box.height)} px`); - return selector; +/** Une zone à toucher d'une action du plateau (dés ou videau) : count zones + * (les dés en ont une de chaque côté du plateau), chacune un bouton + * accessible d'au moins 44 px de côté. Renvoie le locator de la zone index. */ +async function touchTarget(action, label, count = 1, index = 0) { + const zones = page.locator(`[data-action="${action}"]`); + check((await zones.count()) === count, + `zone ${action} : ${await zones.count()} sur le plateau, ${count} attendues`); + for (const zone of await zones.all()) { + check((await zone.getAttribute("role")) === "button" && + (await zone.getAttribute("aria-label")) === label, `zone ${action} sans rôle accessible`); + const box = await zone.boundingBox(); + check(box.width >= 44 && box.height >= 44, + `zone ${action} trop petite : ${Math.round(box.width)} x ${Math.round(box.height)} px`); + } + return zones.nth(index); +} + +/** Le nombre de dés montrés, et de dés estompés (déjà joués). */ +async function shownDice() { + return { + dice: await page.locator("g.die[data-value]").count(), + used: await page.locator("g.die-used").count(), + }; +} + +/** Le dernier jet du joueur, d'après le journal. */ +function lastPlayerRoll(journal) { + const rolled = journal.events.filter((e) => e.type === "DiceRolled").at(-1); + return rolled?.roll ?? []; } /** Observe la page pendant ms millisecondes : le plus grand nombre @@ -256,13 +275,24 @@ let tracesSeen = 0; let replays = 0; let skips = 0; let rolls = 0; +// Saisie du coup : demi-coups du coup en cours, pions avancés d'un seul +// toucher ou en deux (pion, puis case), coups validés et annulations. +let halfMoves = 0; +let singleTouch = 0; +let twoTouches = 0; +let confirms = 0; +let undone = 0; +let doubles = 0; +// Vrai dès que le joueur a touché un pion ou « Annuler » pendant son coup : +// le coup de l'ordinateur n'est plus montré. +let touched = false; for (let actions = 0; ; actions++) { - check(actions < 800, "la partie ne finit pas"); + check(actions < 1500, "la partie ne finit pas"); const todo = await waitForPlayer(); step(`partie, action ${actions}`); // Le coup de l'ordinateur est montré jusqu'à l'action suivante du // joueur ; deux fois, on le revoit (la seconde en mouvement réduit). - const traces = await checkTraces(); + const traces = touched ? null : await checkTraces(); if (traces !== null) { tracesSeen++; if (replays < 2) { @@ -292,7 +322,8 @@ for (let actions = 0; ; actions++) { // message, et le tour ne passe qu'en touchant « Continuer ». const info = await page.textContent("#info"); const blocked = await page.locator(".die-blocked").count(); - check(/^Aucun coup possible avec \d-\d\.$/.test(info ?? "") && blocked === 2, + check(/^Aucun coup possible avec \d-\d\.$/.test(info ?? "") && blocked >= 2 && + blocked === (await shownDice()).dice, `aucun coup possible mal montré : « ${info} », ${blocked} dés barrés`); skips++; log(`aucun coup possible : « ${info} », vous continuez`); @@ -304,30 +335,115 @@ for (let actions = 0; ; actions++) { } else if (todo === "roll") { if (!doubled && (await page.isEnabled("#double"))) { doubled = true; - log("vous doublez en touchant le videau"); - await page.click(await touchTarget("double", "Doubler")); + // Toucher le videau demande confirmation ; « Annuler » ne + // propose rien, puis « Doubler » (le bouton) et confirmer. + const dialog = page.locator("#double-dialog"); + const question = "Doubler à 2 ? L'ordinateur pourra prendre ou passer."; + await (await touchTarget("double", "Doubler")).click(); + check(await dialog.isVisible(), "videau touché : pas de confirmation"); + check((await page.textContent("#double-question")) === question, + `confirmation du videau : « ${await page.textContent("#double-question")} »`); + await shot("videau-confirmation"); + await page.click("#double-cancel"); + check(!(await dialog.isVisible()), "Annuler ne ferme pas la confirmation"); + const offered = (await savedJournal()).events.some((e) => e.type === "CubeOffered"); + check(!offered && (await page.isVisible("#roll")), "double proposé malgré Annuler"); + await page.click("#double"); + check(await dialog.isVisible(), "« Doubler » : pas de confirmation"); + await page.click("#double-confirm"); await waitForPlayer(); const answer = (await page.textContent("#info")) || (await page.textContent("#prompt")); - log(`réponse : ${answer}`); + log(`videau touché : confirmation demandée, annulée, puis double confirmé ; réponse : ${answer}`); continue; } - // Une fois sur deux, en touchant les dés en attente plutôt que le - // bouton. + // Une fois sur deux, en touchant les dés plutôt que le bouton, d'un + // côté puis de l'autre du plateau. rolls++; - await page.click(rolls % 2 === 0 ? await touchTarget("roll", "Lancer les dés") : "#roll"); + if (rolls % 2 === 0) { + await (await touchTarget("roll", "Lancer les dés", 2, (rolls / 2) % 2)).click(); + } else { + await page.click("#roll"); + } + } else if (todo === "confirm") { + // Coup complet : rien n'est joué tant qu'il n'est pas validé. + const journal = await savedJournal(); + const last = journal.events.at(-1); + check(last?.type === "DiceRolled" && (await page.isEnabled("#cancel")), + `coup complet déjà joué ou non annulable (dernier événement ${last?.type})`); + check(/Valider/.test((await page.textContent("#prompt")) ?? ""), + `consigne de validation absente : « ${await page.textContent("#prompt")} »`); + const dice = await shownDice(); + check(dice.used === halfMoves && dice.used > 0, + `dés estompés : ${dice.used} pour ${halfMoves} demi-coups`); + if (undone === 0 && halfMoves >= 2) { + // Une fois : « Annuler » défait les demi-coups un par un. + if (confirms === 0) { + await shot("2b-coup-a-valider"); + } + for (let left = halfMoves - 1; left >= 0; left--) { + await page.click("#cancel"); + await waitForPlayer(); + const after = await shownDice(); + check(after.used === left && (await page.isHidden("#confirm")), + `Annuler : ${after.used} dés estompés, ${left} attendus`); + undone++; + } + check(await page.isDisabled("#cancel"), "Annuler reste actif sans demi-coup à défaire"); + check((await savedJournal()).head === journal.head, "Annuler a touché au journal"); + log(`Annuler : ${undone} demi-coups défaits un par un, journal intact`); + halfMoves = 0; + continue; + } + confirms++; + // Une fois sur trois avec le bouton, sinon en touchant les dés + // (d'un côté ou de l'autre). + if (confirms % 3 === 0) { + await page.click("#confirm"); + } else { + await (await touchTarget("confirm", "Valider", 2, confirms % 2)).click(); + } + await waitForPlayer(); + const played = await savedJournal(); + check(countMoves(played, "player") === countMoves(journal, "player") + 1, + "le coup validé n'est pas dans le journal"); + halfMoves = 0; + touched = false; } else { check((await page.locator("[data-action]").count()) === 0, "dés ou videau touchables pendant que vous jouez votre coup"); - await page.click(".hit.can-move"); - await page.waitForSelector(".hit.can-land"); - if (!selectionShot) { - selectionShot = true; - await shot("2-pion-touche"); + if (halfMoves === 0) { + // Quatre dés pour un double, deux sinon. + const [a, b] = lastPlayerRoll(await savedJournal()); + const dice = await shownDice(); + check(dice.dice === (a === b ? 4 : 2) && dice.used === 0, + `jet ${a}-${b} : ${dice.dice} dés montrés, ${dice.used} estompés`); + if (a === b) { + doubles++; + } } - await page.click(".hit.can-land"); + touched = true; + // Un pion à destination unique avance dès qu'on le touche ; sinon, + // ses cases d'arrivée s'allument. + await page.click(".hit.can-move"); + if ((await page.locator(".hit.can-land").count()) > 0) { + twoTouches++; + if (!selectionShot) { + selectionShot = true; + await shot("2-pion-touche"); + } + await page.click(".hit.can-land"); + } else { + singleTouch++; + } + halfMoves++; + await waitForPlayer(); + const dice = await shownDice(); + check(dice.used === halfMoves, `${dice.used} dés estompés après ${halfMoves} demi-coups`); + continue; } - // Une fois, au milieu de la partie : fermer et rouvrir la page. + // Une fois, au milieu de la partie, entre deux coups : fermer et rouvrir + // la page. const journal = await savedJournal(); if (!reloaded && countMoves(journal, "computer") >= 3 && countMoves(journal, "player") >= 3) { reloaded = true; @@ -355,7 +471,14 @@ check(countMoves(journal, "player") >= 3 && countMoves(journal, "computer") >= 3 check(reloaded, "la reprise après rechargement n'a pas été essayée"); check(replays === 2, "le coup de l'ordinateur n'a pas été revu"); check(doubled && rolls >= 2, "dés ou videau jamais touchés sur le plateau"); -log(`dés lancés ${Math.floor(rolls / 2)} fois en les touchant, videau touché pour doubler`); +check(singleTouch > 0 && twoTouches > 0, + `saisie : ${singleTouch} pions avancés d'un toucher, ${twoTouches} en deux touchers`); +check(undone >= 2, "Annuler n'a pas été essayé sur un coup complet"); +check(confirms >= 3, "trop peu de coups validés pour essayer dés et bouton"); +log(`dés lancés ${Math.floor(rolls / 2)} fois en les touchant, des deux côtés du plateau`); +log(`demi-coups : ${singleTouch} d'un seul toucher (destination unique), ${twoTouches} en deux ` + + `touchers ; ${confirms} coups validés (${Math.floor(confirms / 3)} par le bouton, les autres ` + + `en touchant les dés) ; ${doubles} doubles montrés en quatre dés`); log(`coup de l'ordinateur montré ${tracesSeen} fois, et effacé à chaque action suivante`); log(skips > 0 ? `« Continuer » demandé ${skips} fois` : "aucun jet sans coup possible dans cette partie"); if (computerMs.length > 0) { diff --git a/web/app.css b/web/app.css index 372535e..d92fac2 100644 --- a/web/app.css +++ b/web/app.css @@ -499,6 +499,10 @@ body.busy .prompt { stroke-linecap: round; } +/* Dé déjà utilisé par un demi-coup du coup en cours : estompé, sans être + * barré (il n'est pas perdu : « Annuler » le rend). */ +.die-used { opacity: 0.35; } + .cube-face { fill: var(--cube); stroke: var(--checker-player-edge); stroke-width: 2; } .cube-value { @@ -513,6 +517,29 @@ body.busy .prompt { cursor: pointer; } +/* ------------------------------------------------ Confirmation (videau) */ + +/* Une carte au milieu de l'écran, comme le glossaire ; le fond de la page + * s'assombrit derrière. */ +.confirm { + width: min(22rem, calc(100vw - 2 * var(--space-4))); + padding: var(--space-4); + color: var(--text); + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius-md); + box-shadow: var(--shadow-raised); +} + +.confirm::backdrop { + background: rgba(43, 29, 21, 0.5); +} + +.confirm p { + margin: 0 0 var(--space-4); + font-weight: 700; +} + /* --------------------------------------------------------------- Aide */ .help { @@ -582,12 +609,6 @@ body.busy .prompt { color: var(--muted); } -/* Une cible tactile confortable pour le lien. */ -.about a { - display: inline-block; - padding-block: 12px; -} - /* ------------------------------------------------------- Mode paysage */ /* Téléphone tourné : le plateau prend toute la hauteur de l'écran, à diff --git a/web/index.html b/web/index.html index 6b3d0aa..2df0e12 100644 --- a/web/index.html +++ b/web/index.html @@ -43,11 +43,20 @@ + + + +

+
+ + +
+
From 1f324cfef0480a2e2beb14a9da8df102181b0283 Mon Sep 17 00:00:00 2001 From: Pierre MARTIN Date: Tue, 3 Nov 2026 07:12:08 +0100 Subject: [PATCH 05/15] Confirmation du videau : boutons hors de la classe actions (mode paysage) Co-Authored-By: Claude Opus 5.5 --- web/app.css | 10 ++++++++++ web/index.html | 2 +- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/web/app.css b/web/app.css index d92fac2..0277200 100644 --- a/web/app.css +++ b/web/app.css @@ -540,6 +540,16 @@ body.busy .prompt { font-weight: 700; } +.confirm-actions { + display: flex; + flex-wrap: wrap; + gap: var(--space-2); +} + +.confirm-actions button { + flex: 1 1 auto; +} + /* --------------------------------------------------------------- Aide */ .help { diff --git a/web/index.html b/web/index.html index 2df0e12..a5051ea 100644 --- a/web/index.html +++ b/web/index.html @@ -52,7 +52,7 @@

-
+
From 409e99070c68b7fc7fe8fb857336444380fe3fa8 Mon Sep 17 00:00:00 2001 From: Pierre MARTIN Date: Tue, 3 Nov 2026 09:52:28 +0100 Subject: [PATCH 06/15] =?UTF-8?q?Plan=20des=20parties=20enregistr=C3=A9es?= =?UTF-8?q?=20(1b)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- docs/plans/2026-11-03-parties-enregistrees.md | 57 +++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 docs/plans/2026-11-03-parties-enregistrees.md diff --git a/docs/plans/2026-11-03-parties-enregistrees.md b/docs/plans/2026-11-03-parties-enregistrees.md new file mode 100644 index 0000000..8afba18 --- /dev/null +++ b/docs/plans/2026-11-03-parties-enregistrees.md @@ -0,0 +1,57 @@ +# Parties enregistrées, branches et export SGF (1b) + +- **Date** : 2026-11-03 +- **Étape** : 1b (`docs/etape-1.md`) +- **Statut** : prévu (lancé après la fusion de 0e.1) +- **Exécutant** : sous-agent general-purpose, dans un worktree + +## Objectif + +Garder toutes les parties du joueur sur le téléphone, avec leurs branches, et +les exporter au format SGF de GNU Backgammon. + +## Consignes + +ADR 0007 (domaine pur, stockage derrière une interface), ADR 0008 (scénarios +et glossaire). Aucune nouvelle dépendance : l'API IndexedDB du navigateur +directement. + +1. **Stockage IndexedDB** derrière l'interface de `app/src/storage/` : une + liste de parties (identifiant, date de début et de dernière action, + adversaire et niveau, score, état en cours ou finie, résultat) et le + journal d'événements de chacune. Migration transparente de la partie + en cours enregistrée aujourd'hui dans `localStorage`. Si IndexedDB est + indisponible (navigation privée), repli sur le comportement actuel. +2. **Branches** : le journal est déjà un arbre (événements avec parent, + `goBackTo`). Exposer dans le domaine la liste des branches d'une partie + (feuilles de l'arbre, avec le coup d'origine) et le passage de l'une à + l'autre. « Rejouer d'ici » depuis un coup passé crée une branche ; la + branche d'origine reste intacte et rechargeable. Pas d'écran d'analyse ici + (1e) : un accès minimal suffit pour le test (menu « Mes parties » : + reprendre une partie, voir ses branches). +3. **Nouvelle partie annulable** : l'arbitrage UX (plan + `2026-11-03-revision-ux.md`, point 3) prévoit « Reprendre l'ancienne » + pendant 10 s. Fournir l'API de stockage qui le permet (reprendre une partie + par son identifiant) ; l'interface viendra avec la révision UX. +4. **Export SGF** : une partie (et ses branches en variantes) au format SGF + de GNU Backgammon, téléchargeable depuis « Mes parties ». Vérification par + le gnubg de bureau de référence (Docker existant) : le fichier exporté se + charge sans erreur et ses positions successives sont les mêmes que celles + du journal, sur des parties générées par les tests. +5. Scénarios Gherkin : reprendre une partie parmi plusieurs, rejouer d'ici + sans perdre l'original, exporter une partie. Fiches du glossaire pour les + nouveaux termes (branche, SGF…). + +`make lint`, `make test` et `make test-browser` passent (ajouter au test +navigateur : deux parties enregistrées, reprise de la première après +rechargement). Commits fréquents ; pas de push ni de MR. + +## Critères de réussite + +- Après rechargement, la liste des parties et chaque branche sont intactes. +- Un SGF exporté se charge dans gnubg de bureau avec les mêmes positions. +- Les trois commandes de test passent. + +## Résultat + +À remplir à la clôture. From 77c76deacd0a99e05fbc7e3aad1bffc8cca34282 Mon Sep 17 00:00:00 2001 From: Pierre MARTIN Date: Tue, 3 Nov 2026 09:54:09 +0100 Subject: [PATCH 07/15] =?UTF-8?q?Couleur=20des=20fl=C3=A8ches=20selon=20le?= =?UTF-8?q?ur=20num=C3=A9ro=20(impaires=20point-a,=20paires=20point-b)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- app/src/ui/board-model.ts | 8 ++++++++ app/src/ui/board-view.ts | 10 ++++++++-- app/tests/ui.test.ts | 13 +++++++++++++ 3 files changed, 29 insertions(+), 2 deletions(-) diff --git a/app/src/ui/board-model.ts b/app/src/ui/board-model.ts index bbf19f1..a8e1465 100644 --- a/app/src/ui/board-model.ts +++ b/app/src/ui/board-model.ts @@ -139,6 +139,14 @@ export function moveTrace( return { side, arrows, landed, left, hits: hits.filter(Boolean).length }; } +/** La couleur d'une flèche (case), d'après son numéro affiché (1 à 24, + * pour le joueur) : impaire en point-a, paire en point-b (charte). Jamais + * d'après sa colonne : les cases 12 et 13, l'une au-dessus de l'autre, + * n'ont pas la même couleur. */ +export function pointColor(number: number): "point-a" | "point-b" { + return number % 2 === 1 ? "point-a" : "point-b"; +} + /** Où se trouve une case sur le dessin : rangée du haut ou du bas, et * colonne de 0 (à gauche) à 11 (à droite), sans compter la barre. */ export function pointSlot(number: number): { top: boolean; column: number } { diff --git a/app/src/ui/board-view.ts b/app/src/ui/board-view.ts index b96d4ab..b17a1aa 100644 --- a/app/src/ui/board-view.ts +++ b/app/src/ui/board-view.ts @@ -11,7 +11,13 @@ import type { Cube } from "../domain/game/cube.js"; import type { Side } from "../domain/game/side.js"; -import { type BoardModel, type MoveTrace, type Place, pointSlot } from "./board-model.js"; +import { + type BoardModel, + type MoveTrace, + type Place, + pointColor, + pointSlot, +} from "./board-model.js"; import { TEXTS } from "./texts.js"; /** Un dé affiché, de la couleur du camp qui l'a lancé. */ @@ -221,7 +227,7 @@ function drawPoint( const base = top ? FRAME : HEIGHT - FRAME; const tip = top ? FRAME + POINT_HEIGHT : HEIGHT - FRAME - POINT_HEIGHT; add(svg, "polygon", { - class: column % 2 === 0 ? "point-a" : "point-b", + class: pointColor(number), points: `${left},${base} ${left + POINT_WIDTH},${base} ${center},${tip}`, }); add( diff --git a/app/tests/ui.test.ts b/app/tests/ui.test.ts index 4ba786d..ff72201 100644 --- a/app/tests/ui.test.ts +++ b/app/tests/ui.test.ts @@ -18,6 +18,7 @@ import { moveTrace, onlyHalfMove, placeOf, + pointColor, pointSlot, } from "../src/ui/board-model.js"; import { parseFeature, parseGlossaryEntry, parseGlossaryIndex } from "../src/ui/help-text.js"; @@ -54,6 +55,18 @@ test("cases, barre et sortie de chaque camp", () => { assert.equal(placeOf("computer", 0), "off-computer"); }); +test("couleur des flèches : impaires en point-a, paires en point-b, quelle que soit la colonne", () => { + for (const number of [1, 13, 23]) { + assert.equal(pointColor(number), "point-a", `flèche ${number}`); + } + for (const number of [2, 12, 24]) { + assert.equal(pointColor(number), "point-b", `flèche ${number}`); + } + // 12 et 13 partagent la colonne 0, mais pas la couleur. + assert.equal(pointSlot(12).column, pointSlot(13).column); + assert.notEqual(pointColor(12), pointColor(13)); +}); + test("disposition : 1 à 12 en bas de droite à gauche, 13 à 24 en haut de gauche à droite", () => { assert.deepEqual(pointSlot(1), { top: false, column: 11 }); assert.deepEqual(pointSlot(7), { top: false, column: 5 }); From e8372872be057d274ccb20c75c2d7801a2615524 Mon Sep 17 00:00:00 2001 From: Pierre MARTIN Date: Tue, 3 Nov 2026 09:57:21 +0100 Subject: [PATCH 08/15] =?UTF-8?q?Charte=20:=20couleur=20des=20fl=C3=A8ches?= =?UTF-8?q?=20selon=20leur=20num=C3=A9ro=20(impaires,=20paires)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Les flèches impaires sont en point-a (noyer clair), les paires en point-b (chêne), comme sur un vrai plateau ; le dessin de référence alternait en miroir. Signalé par Pierre. Co-Authored-By: Claude Opus 5.5 --- docs/identite/charte/board.js | 2 +- docs/identite/charte/guide-de-marque.md | 2 +- docs/identite/charte/tokens.json | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/identite/charte/board.js b/docs/identite/charte/board.js index cce1f44..c9fb53a 100644 --- a/docs/identite/charte/board.js +++ b/docs/identite/charte/board.js @@ -34,7 +34,7 @@ window.UstaBoard = (function () { o += ''; o += ''; for (var point = 1; point <= 24; point++) { - var pl = place(point), x = colX(pl.col), cls = (pl.col % 2 === 0) === pl.top ? "point-a" : "point-b"; + var pl = place(point), x = colX(pl.col), cls = point % 2 === 1 ? "point-a" : "point-b"; // impairs en noyer, pairs en chêne var base = pl.top ? TB : H - TB, apex = pl.top ? TB + PH : H - TB - PH; o += ''; if ((state.targets || []).indexOf(point) >= 0) diff --git a/docs/identite/charte/guide-de-marque.md b/docs/identite/charte/guide-de-marque.md index f24ad86..e74720e 100644 --- a/docs/identite/charte/guide-de-marque.md +++ b/docs/identite/charte/guide-de-marque.md @@ -22,7 +22,7 @@ La palette, c'est le bois, les pions et le café, avec **un seul accent : le rou - `accent-soft` : fond léger pour signaler « c'est à vous », avec du texte `text` dessus. - Les appréciations de coups ont leurs propres couleurs, distinctes de la brique : `verdict-good` (vert), `verdict-doubtful` (ocre), `verdict-bad` (lie-de-vin). **Toujours avec le mot et l'icône**, jamais la couleur seule. - `brand-laiton` (via `last-move`) marque le dernier coup joué, comme les charnières en laiton d'un plateau de tavla. Rien d'autre. -- Le plateau : cadre `frame` en noyer, fond `bed`, flèches alternées `point-a` (noyer clair) et `point-b` (chêne), barre `bar`, sorties `tray`. Le joueur a les pions ivoire (`checker-player`, l'apprenti), l'ordinateur les pions noyer (`checker-computer`, le maître), comme dans le logo. +- Le plateau : cadre `frame` en noyer, fond `bed`, flèches impaires en `point-a` (noyer clair) et paires en `point-b` (chêne), barre `bar`, sorties `tray`. Le joueur a les pions ivoire (`checker-player`, l'apprenti), l'ordinateur les pions noyer (`checker-computer`, le maître), comme dans le logo. - **À éviter** : le vert feutre de casino, le noir et or, les dégradés, les néons. Les deux thèmes, clair et sombre, sont complets. Tous les textes atteignent 4,5:1 sur leurs fonds dans les deux thèmes ; les contours et marques porteuses de sens, 3:1. diff --git a/docs/identite/charte/tokens.json b/docs/identite/charte/tokens.json index 253cbd6..516f8e9 100644 --- a/docs/identite/charte/tokens.json +++ b/docs/identite/charte/tokens.json @@ -195,7 +195,7 @@ "light": "#6B4430", "dark": "#3A2519" }, - "usage": "Flèches sombres (noyer clair), une sur deux." + "usage": "Flèches impaires (1, 3, 5…), noyer clair." }, { "name": "point-b", @@ -203,7 +203,7 @@ "light": "#C9AE88", "dark": "#7E6142" }, - "usage": "Flèches claires (chêne), une sur deux." + "usage": "Flèches paires (2, 4, 6…), chêne." }, { "name": "point-number", From 31174e98176420a8b2f4c72addda1a57d0b683f7 Mon Sep 17 00:00:00 2001 From: Pierre MARTIN Date: Tue, 3 Nov 2026 09:58:52 +0100 Subject: [PATCH 09/15] =?UTF-8?q?Journal=20:=20coordination=20design=20et?= =?UTF-8?q?=20d=C3=A9veloppement?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- docs/journal.md | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/docs/journal.md b/docs/journal.md index 0b6c5db..7cfcc1d 100644 --- a/docs/journal.md +++ b/docs/journal.md @@ -136,3 +136,30 @@ Une entrée datée par session : ce qui a été fait, décidé, et la suite. **Reste à faire** - Firefox sur le téléphone reste à mesurer. + +## 2026-11-03 (jour) : coordination design et développement + +**Fait** +- Leçons numérotées, conseil du maître après le coup et bilan de fin de partie + retenus par Pierre (ADR 0011) ; mode immersif en paysage (ADR 0012), puis + vue paysage dès la rotation, avec le plateau qui occupe tout l'écran. +- Revue UX du designer de la session de développement (étape 0e.2), arbitrée + par la session design sur délégation de Pierre : portrait à zones fixes, + compte de points en bandeaux (pastilles en paysage), menu en feuille, + « annuler plutôt que confirmer », pas de passage automatique du tour, + vibrations, pictogrammes, clavier sur ordinateur. Règles dans + `docs/identite/charte/interaction.md` (MR !23), second amendement de + l'ADR 0012 (placement des contrôles en paysage). +- Maquettes des variantes sur le canevas (paysage clair, sombre, avec barre + d'adresse, fin de partie ; portrait révisé, conseil, nouvelle partie + annulable ; menu ; ordinateur avec raccourcis). +- Couleur des flèches corrigée dans la charte (impaires en noyer clair, paires + en chêne ; MR !25). La correction dans l'app est faite par la session de + développement, dans la MR de saisie du coup. + +**Reste à faire** +- Implémentation par la session de développement (plan + `docs/plans/2026-11-03-revision-ux.md`), puis revue design sur ses captures + Playwright (412×915, 915×412, 844×320, clair et sombre) avant l'essai de + Pierre sur le téléphone. +- Réglage en main des vibrations sur le Fairphone 4. From 7747fc1e493788c289eda87622f2a53e58f5e0fb Mon Sep 17 00:00:00 2001 From: Pierre MARTIN Date: Tue, 3 Nov 2026 10:01:36 +0100 Subject: [PATCH 10/15] =?UTF-8?q?Annuler=20:=20=C3=A9cran=20occup=C3=A9=20?= =?UTF-8?q?pendant=20le=20recalcul=20des=20coups=20possibles?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- app/src/ui/app.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/app/src/ui/app.ts b/app/src/ui/app.ts index 9a23ffd..445a478 100644 --- a/app/src/ui/app.ts +++ b/app/src/ui/app.ts @@ -364,13 +364,16 @@ export function startApp(options: AppOptions): void { await g.confirmMove(); }); - // Annuler défait le dernier demi-coup saisi, un par un. + // Annuler défait le dernier demi-coup saisi, un par un. L'écran est + // occupé le temps que le moteur recalcule les coups possibles. ui.cancel.onclick = () => { if (!busy && game !== null) { game.undoLastHalfMove(); selected = null; shownMove = null; - void refresh(); + busy = true; + render(); + void settle(false); } }; From 95167da223fa02f23e558bafa3e17cdf1141bb5c Mon Sep 17 00:00:00 2001 From: Pierre MARTIN Date: Tue, 3 Nov 2026 10:04:59 +0100 Subject: [PATCH 11/15] =?UTF-8?q?Charte=20:=20crit=C3=A8res=20de=20recette?= =?UTF-8?q?=20design=20de=20l'=C3=A9cran=20de=20jeu?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- docs/identite/charte/README.md | 1 + docs/identite/charte/recette-ux.md | 45 ++++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+) create mode 100644 docs/identite/charte/recette-ux.md diff --git a/docs/identite/charte/README.md b/docs/identite/charte/README.md index d06d273..8a5be9a 100644 --- a/docs/identite/charte/README.md +++ b/docs/identite/charte/README.md @@ -14,6 +14,7 @@ Décisions : ADR 0010. Fichiers du logo et des icônes : `../logo/`. | Fichier | Contenu | | --- | --- | | `guide-de-marque.md` | Règles d'usage : ton, couleurs, typographie, formes, mouvement, icônes, composants | +| `recette-ux.md` | Critères de recette design de l'écran de jeu, à cocher sur les captures et sur le téléphone | | `interaction.md` | Comportement : réponse immédiate, annuler plutôt que confirmer, mouvement, vibrations, clavier, mises en page de l'écran de jeu (portrait, paysage, ordinateur), pictogrammes | | `tokens.json` | Toutes les valeurs, thèmes clair et sombre, avec une note d'usage par couleur | | `bundle.css` | Styles des composants ; mêmes noms de classes que `web/app.css` | diff --git a/docs/identite/charte/recette-ux.md b/docs/identite/charte/recette-ux.md new file mode 100644 index 0000000..25718de --- /dev/null +++ b/docs/identite/charte/recette-ux.md @@ -0,0 +1,45 @@ +# Recette design de l'écran de jeu + +Critères vérifiables sur les captures de `make test-browser` (412×915, +915×412, 844×320, thèmes clair et sombre) et à la main sur le Fairphone 4. +Chaque ligne se coche ou se refuse ; une ligne refusée dit pourquoi. +Référence : `interaction.md`, maquettes « Révision UX » du canevas. + +## Toutes tailles + +- [ ] Flèches impaires en `point-a`, paires en `point-b` ; 12 et 13 de couleurs différentes. +- [ ] Une seule action principale (brique) visible à la fois. +- [ ] Aucun `window.confirm` ni `alert` dans le code de l'app (`grep -rn "confirm(\|alert(" app/src` vide). +- [ ] Aucune cible tactile sous 44 × 44 px (boutons, pictogrammes, dés, videau). +- [ ] Textes à lire : 13 px au moins ; numéros des flèches : 12 px au moins. +- [ ] Thème sombre : aucune couleur codée en dur hors `tokens` ; le cadre se distingue du fond. +- [ ] Anneau de focus `focus` visible au clavier sur chaque élément interactif. +- [ ] `overscroll-behavior: none` : tirer vers le bas ne recharge pas la page. + +## Portrait (412 × 915) + +- [ ] Ordre de haut en bas : barre haute, bandeau ordinateur, plateau, bandeau joueur, ligne du maître ou du dernier coup, consigne, barre d'actions collée en bas. +- [ ] Stabilité : sur une partie complète, la position verticale du plateau et de la barre d'actions ne varie pas d'un pixel (comparer les boîtes entre deux captures successives). +- [ ] Barre haute : pictogrammes avec libellé de 13 px (Aide, Plein écran, Menu) ; pas de bouton « Nouvelle partie ». +- [ ] Comptes de points en 20 px gras, chiffres alignés ; le bandeau du joueur qui a la main est teinté `accent-soft` ; l'écart s'écrit « mène de N ». +- [ ] Plus de durée de calcul « (0,5 s) » dans les messages. +- [ ] Aucun coup possible : dés barrés et message ; le tour ne passe qu'au toucher des dés. +- [ ] Conseil du maître : une ligne, après le coup du joueur seulement ; toucher l'ouvre. + +## Paysage (915 × 412 et 844 × 320) + +- [ ] Le cadre noyer touche les quatre bords ; aucune marge de page, aucune colonne de texte. +- [ ] Flèches d'au moins 44 px de large ; pions d'au moins 22 px de diamètre à 844 × 320. +- [ ] Colonne de commandes à gauche : menu en haut, videau au milieu, annuler (ou revoir) en bas ; colonne des sorties libre. +- [ ] Comptes en pastilles aux deux bouts de la barre, 13 px, couleur du camp ; ils ne chevauchent pas les pions sur la barre. +- [ ] Consigne et dés dans la bande centrale, sans chevaucher un pion. +- [ ] Rotation portrait ↔ paysage en cours de partie : la position, la sélection et les dés sont conservés. +- [ ] Fin de partie : panneau centré sur un voile, « Revoir la partie » et « Nouvelle partie ». + +## Comportement + +- [ ] Un toucher sur un pion produit un effet visible en moins de 100 ms (trace de performance de Playwright). +- [ ] Déplacements à 160 ms en `easing-standard`, sans rebond ; coups de l'ordinateur pion par pion ; tout est instantané avec `prefers-reduced-motion`. +- [ ] « Nouvelle partie » depuis le menu : effet immédiat, bandeau « Reprendre l'ancienne » 10 s, et la reprise restaure la partie exacte. +- [ ] Vibrations aux quatre moments, coupées par l'interrupteur du menu (test unitaire sur `haptic()` ; ressenti à la main). +- [ ] Écran maintenu allumé pendant une partie, interrupteur dans le menu. From 15fb77c64b211b0a4b81547bbf42942e74513b2d Mon Sep 17 00:00:00 2001 From: Pierre MARTIN Date: Tue, 3 Nov 2026 10:11:29 +0100 Subject: [PATCH 12/15] Saisie du coup : plan clos Co-Authored-By: Claude Opus 5.5 --- docs/feuille-de-route.md | 1 - docs/plans/2026-11-03-revision-ux.md | 2 +- docs/plans/2026-11-03-saisie-du-coup.md | 65 ------------------------- 3 files changed, 1 insertion(+), 67 deletions(-) delete mode 100644 docs/plans/2026-11-03-saisie-du-coup.md diff --git a/docs/feuille-de-route.md b/docs/feuille-de-route.md index b0c848c..502af9f 100644 --- a/docs/feuille-de-route.md +++ b/docs/feuille-de-route.md @@ -23,7 +23,6 @@ Demandes de Pierre du 2026-11-03, traitées avant d'ouvrir l'étape 1. | Sous-étape | Contenu | État | Plan | | --- | --- | --- | --- | -| 0e.1 | Saisie du coup : pion à destination unique joué en un toucher, coup à valider et annulable demi-coup par demi-coup, dés joués marqués, dés des deux côtés touchables, videau confirmé ; pages de test du moteur retirées | 🔄 | `plans/2026-11-03-saisie-du-coup.md` | | 0e.3 | Révision UX arbitrée (session « identité », sur délégation de Pierre) : portrait stable, bandeaux de compte de points, menu en feuille et nouvelle partie annulable, vibrations, écran allumé, paysage « plateau = écran » dès la rotation (ADR 0012 amendé) | ⏳ | `plans/2026-11-03-revision-ux.md` | | 0e.4 | Position dans l'adresse : chaque position du plateau a un lien direct (nouvel ADR pour le format) | ⏳ | | | 0e.5 | Page d'accueil, installation en un toucher (« Ajouter à l'écran d'accueil », proposée par l'app), tutoriel des règles très didactique avec plateaux illustrés, aide réécrite sur un ton plus humain ; textes à valider par Pierre | ⏳ | | diff --git a/docs/plans/2026-11-03-revision-ux.md b/docs/plans/2026-11-03-revision-ux.md index 706d9ab..2baeedd 100644 --- a/docs/plans/2026-11-03-revision-ux.md +++ b/docs/plans/2026-11-03-revision-ux.md @@ -2,7 +2,7 @@ - **Date** : 2026-11-03 - **Étape** : 0e.3 (feuille de route) -- **Statut** : prévu (après la fusion de 0e.1, qui touche les mêmes fichiers) +- **Statut** : prévu - **Exécutant** : sous-agent general-purpose, dans un worktree ## Objectif diff --git a/docs/plans/2026-11-03-saisie-du-coup.md b/docs/plans/2026-11-03-saisie-du-coup.md deleted file mode 100644 index 219bb4f..0000000 --- a/docs/plans/2026-11-03-saisie-du-coup.md +++ /dev/null @@ -1,65 +0,0 @@ -# Saisie du coup, videau confirmé, pages de test du moteur retirées - -- **Date** : 2026-11-03 -- **Étape** : 0e (retours du premier test de Pierre sur le téléphone) -- **Statut** : en cours -- **Exécutant** : sous-agent general-purpose, dans un worktree - -## Objectif - -Rendre la saisie du coup aussi confortable que dans CBG, d'après les retours de -Pierre du 2026-11-03 (points 1, 6, 7, 8, 9 et 11 de son message), et retirer du -site les pages de test du moteur. - -## Consignes - -Règles de code : ADR 0007 (domaine pur, aucune règle de backgammon -réimplémentée : tout vient du moteur via le domaine). Glossaire et scénarios : -ADR 0008 (chaque comportement visible a son scénario Gherkin dans -`docs/scenarios/`, chaque nouveau terme sa fiche). Aucune nouvelle dépendance. - -1. **Pages de test du moteur retirées** (« supprimer la partie test du moteur - totalement ») : plus de `/moteur/` sur le site ni dans `make deploy`. - `make bench` (mesure sous Node) reste : déplacer ce dont il a besoin hors de - `web/`. Mettre à jour `tests/test_site.sh`, le Makefile, `deploy/`, le - service worker, `docs/guide-test-etape-0.md` et ajouter un amendement daté à - l'ADR 0009. -2. **Un seul coup possible, le pion avance** : quand le joueur touche un pion - (ou une case) et qu'une seule case d'arrivée est possible pour ce pion, le - demi-coup est joué aussitôt, sans second toucher. S'il y en a plusieurs, la - sélection actuelle reste. -3. **Coup à valider, annulable** : quand tous les demi-coups sont joués (ou - qu'aucun autre n'est possible), le coup n'est pas envoyé tout de suite. Le - joueur le valide en touchant ses dés (ou un bouton « Valider »). Tant qu'il - ne l'a pas validé, « Annuler » défait le dernier demi-coup, un par un. Le - journal d'événements ne reçoit `MovePlayed` qu'à la validation. -4. **Dés joués marqués** : chaque dé déjà utilisé par un demi-coup est - visiblement estompé. Pour un double, quatre dés, marqués un par un. -5. **Dés de l'adversaire touchables** : toucher la zone des dés de l'un ou - l'autre côté du plateau lance les dés quand le joueur peut lancer (et valide - le coup en attente de validation). -6. **Confirmation du videau** : toucher le videau ou « Doubler » ouvre une - confirmation courte (« Doubler à 2 ? L'ordinateur pourra prendre ou - passer. » : Doubler / Annuler) avant d'envoyer le double. - -Tests : scénarios Gherkin mis à jour ou ajoutés (validation du coup, annulation -demi-coup par demi-coup, confirmation du videau), tests unitaires, et -`tests/browser/play.mjs` adapté (Playwright est validé par Pierre). Lancer -`make lint`, `make test` et `make test-browser`. - -Rapporter : ce qui a été fait, les écarts, les résultats des trois commandes, -et un brouillon de description de MR (7 sections, voir `docs/organisation.md`). -Commits fréquents sur la branche du worktree ; ne pas pousser, ne pas ouvrir de -MR : la session principale s'en charge. - -## Critères de réussite - -- `make lint`, `make test` et `make test-browser` passent. -- Dans le navigateur : un pion à destination unique avance en un toucher ; un - coup complet attend la validation ; « Annuler » défait un demi-coup à la - fois ; les dés joués sont estompés ; le videau demande confirmation. -- `https://…/moteur/` n'existe plus dans le site construit (`make site`). - -## Résultat - -À remplir à la clôture. From b28a37d411847991bee50fdd0b6df2d5c031b27d Mon Sep 17 00:00:00 2001 From: Pierre MARTIN Date: Tue, 3 Nov 2026 10:25:50 +0100 Subject: [PATCH 13/15] ADR 0013 : position dans l'adresse ; plan 0e.4 Co-Authored-By: Claude Opus 5.5 --- docs/adr/0013-position-dans-l-adresse.md | 49 +++++++++++++++++++ docs/adr/README.md | 1 + docs/feuille-de-route.md | 2 +- .../2026-11-03-position-dans-l-adresse.md | 39 +++++++++++++++ 4 files changed, 90 insertions(+), 1 deletion(-) create mode 100644 docs/adr/0013-position-dans-l-adresse.md create mode 100644 docs/plans/2026-11-03-position-dans-l-adresse.md diff --git a/docs/adr/0013-position-dans-l-adresse.md b/docs/adr/0013-position-dans-l-adresse.md new file mode 100644 index 0000000..c06ddc1 --- /dev/null +++ b/docs/adr/0013-position-dans-l-adresse.md @@ -0,0 +1,49 @@ +# 0013. Une position du plateau dans l'adresse de la page + +- **Statut** : accepté +- **Date** : 2026-11-03 +- **Décideurs** : Pierre (besoin), Claude Code (format) + +## Contexte + +Pierre a demandé, au premier test sur le téléphone (2026-11-03), qu'avec la +seule adresse on arrive à une position du plateau : chaque position devient un +lien direct, à partager, à mettre dans une leçon ou dans un prompt pour une IA. +GNU Backgammon décrit déjà toute position par deux identifiants publics et +documentés : le Position ID (14 caractères, les pions) et le Match ID +(12 caractères : videau, score, longueur du match, joueur au trait, dés). Le +moteur sait les lire et les écrire (`engine.h`). + +## Décision + +- L'adresse porte la position dans le fragment (après `#`) : + `https://ustagammon.sans.pub/#position=&match=`, + chaque identifiant encodé avec `encodeURIComponent` (le base64 de gnubg + contient `+` et `/`). `match` est facultatif : sans lui, partie libre, videau + au centre, joueur du bas au trait, sans dés. +- Ouvrir un tel lien affiche la position, non jouable, avec deux actions : + « Jouer à partir d'ici » (nouvelle partie contre l'ordinateur depuis cette + position, sans toucher à la partie en cours) et « Retour à ma partie ». +- Toute position affichée (partie, coup de l'ordinateur, analyse, leçon) peut + être copiée en lien (« Copier le lien de la position »). +- Un identifiant illisible pour le moteur donne un message clair et le retour + à la partie, jamais une page cassée. + +## Alternatives écartées + +- **Paramètres de requête (`?position=`)** : ils changent l'adresse demandée + au serveur et au service worker ; hors ligne, la page en cache ne + correspondrait plus. Le fragment ne quitte jamais le navigateur. +- **XGID (format d'eXtreme Gammon)** : répandu, mais un seul identifiant de + plus à décoder ; gnubg l'importe, son import pourra s'ajouter plus tard + (outil « import d'un identifiant GNU ou XGID » du cadrage). +- **Format maison plus lisible** : il faudrait le décoder sans le moteur, donc + réimplémenter une règle de représentation (ADR 0007). + +## Conséquences + +- Le fragment est lu au chargement et à chaque changement (`hashchange`) ; le + domaine reçoit une position et un Match ID, le moteur les valide. +- Les liens restent valables tant que GNU Backgammon garde ces formats, stables + depuis des années. +- Les liens contiennent la position, rien d'autre : pas de donnée personnelle. diff --git a/docs/adr/README.md b/docs/adr/README.md index ef00008..e79216f 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -19,3 +19,4 @@ l'ancien passe au statut « remplacé par NNNN ». Voir l'ADR 0001. | [0010](0010-identite-visuelle.md) | Identité visuelle : symbole, logotype, palette | accepté | | [0011](0011-lecons-conseil-et-bilan.md) | Leçons numérotées, conseil du maître après le coup, bilan de fin de partie | accepté | | [0012](0012-mode-immersif.md) | Mode immersif : le plateau en paysage, sur demande | accepté | +| [0013](0013-position-dans-l-adresse.md) | Une position du plateau dans l'adresse de la page | accepté | diff --git a/docs/feuille-de-route.md b/docs/feuille-de-route.md index 502af9f..88a9530 100644 --- a/docs/feuille-de-route.md +++ b/docs/feuille-de-route.md @@ -24,7 +24,7 @@ Demandes de Pierre du 2026-11-03, traitées avant d'ouvrir l'étape 1. | Sous-étape | Contenu | État | Plan | | --- | --- | --- | --- | | 0e.3 | Révision UX arbitrée (session « identité », sur délégation de Pierre) : portrait stable, bandeaux de compte de points, menu en feuille et nouvelle partie annulable, vibrations, écran allumé, paysage « plateau = écran » dès la rotation (ADR 0012 amendé) | ⏳ | `plans/2026-11-03-revision-ux.md` | -| 0e.4 | Position dans l'adresse : chaque position du plateau a un lien direct (nouvel ADR pour le format) | ⏳ | | +| 0e.4 | Position dans l'adresse : chaque position du plateau a un lien direct (ADR 0013) | ⏳ | `plans/2026-11-03-position-dans-l-adresse.md` | | 0e.5 | Page d'accueil, installation en un toucher (« Ajouter à l'écran d'accueil », proposée par l'app), tutoriel des règles très didactique avec plateaux illustrés, aide réécrite sur un ton plus humain ; textes à valider par Pierre | ⏳ | | ## Étapes suivantes diff --git a/docs/plans/2026-11-03-position-dans-l-adresse.md b/docs/plans/2026-11-03-position-dans-l-adresse.md new file mode 100644 index 0000000..a1f3b5d --- /dev/null +++ b/docs/plans/2026-11-03-position-dans-l-adresse.md @@ -0,0 +1,39 @@ +# Position dans l'adresse (0e.4) + +- **Date** : 2026-11-03 +- **Étape** : 0e.4 (feuille de route) +- **Statut** : prévu (après la fusion de la révision UX, qui touche les mêmes fichiers) +- **Exécutant** : sous-agent general-purpose, dans un worktree + +## Objectif + +Appliquer l'ADR 0013 : chaque position du plateau a un lien direct. + +## Consignes + +ADR 0007, 0008 et 0013. Aucune nouvelle dépendance. + +1. Lecture du fragment `#position=…&match=…` au chargement et sur + `hashchange` ; validation par le moteur ; message clair si illisible. +2. Vue de la position, non jouable, avec « Jouer à partir d'ici » (nouvelle + partie depuis la position, la partie en cours restant reprenable) et + « Retour à ma partie ». +3. Domaine : démarrer une partie depuis une position et un Match ID donnés + (événement GameStarted étendu de façon rétrocompatible, ou équivalent + justifié), sans règle réimplémentée. +4. « Copier le lien de la position » depuis le menu de la partie et depuis la + vue du coup de l'ordinateur ; le lien copié rouvre exactement la même + position (test aller-retour). +5. Scénarios Gherkin (« Ouvrir un lien vers une position », « Partager la + position »), fiches du glossaire (Position ID, Match ID si absentes). +6. `make lint`, `make test`, `make test-browser` (ouvrir un lien, jouer à + partir d'ici, revenir, lien invalide). + +## Critères de réussite + +Un lien copié depuis l'app, ouvert dans un nouvel onglet, montre la même +position ; les trois commandes passent. + +## Résultat + +À remplir à la clôture. From f24315fb98914dd27a44778620723452999a993b Mon Sep 17 00:00:00 2001 From: Pierre MARTIN Date: Tue, 3 Nov 2026 14:58:25 +0100 Subject: [PATCH 14/15] =?UTF-8?q?Charte=20:=20accueil,=20installation,=20t?= =?UTF-8?q?utoriel=20des=20r=C3=A8gles,=20aide=20en=20questions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- docs/identite/charte/README.md | 1 + docs/identite/charte/accueil-tutoriel-aide.md | 153 ++++++++++++++++++ 2 files changed, 154 insertions(+) create mode 100644 docs/identite/charte/accueil-tutoriel-aide.md diff --git a/docs/identite/charte/README.md b/docs/identite/charte/README.md index 8a5be9a..a4048ce 100644 --- a/docs/identite/charte/README.md +++ b/docs/identite/charte/README.md @@ -14,6 +14,7 @@ Décisions : ADR 0010. Fichiers du logo et des icônes : `../logo/`. | Fichier | Contenu | | --- | --- | | `guide-de-marque.md` | Règles d'usage : ton, couleurs, typographie, formes, mouvement, icônes, composants | +| `accueil-tutoriel-aide.md` | Accueil, installation sans intrusion, tutoriel des règles (forme et première version du texte), aide en questions | | `recette-ux.md` | Critères de recette design de l'écran de jeu, à cocher sur les captures et sur le téléphone | | `interaction.md` | Comportement : réponse immédiate, annuler plutôt que confirmer, mouvement, vibrations, clavier, mises en page de l'écran de jeu (portrait, paysage, ordinateur), pictogrammes | | `tokens.json` | Toutes les valeurs, thèmes clair et sombre, avec une note d'usage par couleur | diff --git a/docs/identite/charte/accueil-tutoriel-aide.md b/docs/identite/charte/accueil-tutoriel-aide.md new file mode 100644 index 0000000..2d7f99c --- /dev/null +++ b/docs/identite/charte/accueil-tutoriel-aide.md @@ -0,0 +1,153 @@ +# Accueil, installation, tutoriel et aide + +Règles de l'étape 0e.5 (et 1g pour le tutoriel). Maquettes : rangée « Étape +0e.5 » du canevas https://claude.ai/artifact/CQw6vbDpi6XQqVWQa4sANe et +l'artboard « Accueil ». Les textes sont une **première version, à valider par +Pierre**. Comportement général : `interaction.md`. + +## Accueil + +**Premier lancement** (aucune partie ni leçon enregistrée) : le logo, le +plateau en position de départ, « Bienvenue à la table », une phrase, puis deux +grands choix : + +- **« Je découvre »** (action principale) → le tutoriel, étape 1 ; +- **« Je connais les règles »** → une partie tout de suite. + +Le choix ne revient plus. Le tutoriel reste dans le menu. + +**Ensuite**, de haut en bas : + +1. **La partie en cours** : miniature du plateau, comptes de points, + « Reprendre la partie » (seule action principale de l'écran) et + « Nouvelle partie ». Sans partie en cours : « Nouvelle partie » devient + l'action principale. +2. **L'invitation à installer**, si elle doit apparaître (voir plus bas). +3. **La prochaine leçon** : une seule carte (titre, état, progression, + « Continuer » en secondaire), avec le lien « Toutes les leçons ». +4. **La progression résumée**, en une ligne `caption` : « 1 leçon terminée + sur 8 · 12 parties jouées · 4 gagnées ». +5. En pied de page, la phrase de l'Atelier du Huit. + +Pas de chiffres d'erreur ou d'équité sur l'accueil : ils viennent avec +l'analyse. + +## Installation + +Le but : proposer l'installation au bon moment, une fois, sans jamais +interrompre. + +- **Jamais** au premier lancement, jamais pendant une partie, jamais en + fenêtre qui s'impose. +- **Quand** : à partir de la fin de la **première partie terminée**, quand le + joueur a vu ce que vaut l'app. +- **Où** : une carte compacte sur l'accueil (symbole, « Installer + Ustagammon », « Sur l'écran d'accueil, en plein écran, sans réseau. », + bouton « Installer » et croix « Plus tard »), et une ligne « Installer + l'application » dans le menu, toujours présente tant que l'app n'est pas + installée. +- **« Plus tard »** masque la carte ; elle revient une seule fois, après + trois parties de plus. Ensuite, seul le menu la propose. +- **Chrome Android** : le bouton déclenche l'invite du système, gardée depuis + l'événement `beforeinstallprompt`. Sans cet événement (navigateur qui ne + propose pas l'installation), le même bouton ouvre la marche à suivre. +- **Firefox Android** : une feuille en trois étapes numérotées (menu ⋮, + « Installer » ou « Ajouter à l'écran d'accueil », confirmer), qui se ferme + par « J'ai compris ». +- **iPhone (Safari)** : même feuille, avec ces étapes : bouton de partage, + « Sur l'écran d'accueil », « Ajouter ». +- **Une fois installée** (`display-mode: standalone`, ou événement + `appinstalled`) : plus aucune proposition, la ligne du menu disparaît. + +## Tutoriel des règles + +### Forme + +- Huit étapes, chacune en **écrans courts** : un titre, un plateau illustré, + deux ou trois phrases. Une étape sur deux finit par un écran **« à vous »** + où le joueur fait le geste sur le vrai plateau, contrôlé par le moteur. +- En haut : une barre de progression en huit segments (`accent` pour les + étapes faites) et « Passer » à droite. En bas : « Retour » et + « Continuer ». +- Les positions sont données par leurs identifiants gnubg (ADR 0013) ; le + plateau est le composant de l'app, non jouable sauf sur les écrans + « à vous ». Aucune règle n'est codée hors du moteur : un écran « à vous » + demande au moteur si le coup du joueur fait partie des coups permis attendus. +- **Les pions bougent** : sur un écran d'explication, le coup illustré se joue + tout seul après une seconde, pion par pion (`duration-fast`), puis reste + affiché ; « Revoir » le rejoue. Sur les écrans à flèches (sens du jeu), un + tracé en pointillés `text` sur un liseré ivoire, jamais en brique (la brique + veut dire « à faire »). +- Un geste juste : coche `verdict-good`, vibration « pion posé », phrase + courte, « Continuer » devient l'action principale. Un geste faux : le pion + revient, et une phrase dit pourquoi, sans le mot « erreur ». +- Chaque terme nouveau est un lien vers sa fiche du glossaire, à sa première + apparition. + +### Texte, première version + +1. **Le but du jeu.** « Vous avez quinze pions ivoire, l'ordinateur quinze + pions noyer. Chacun fait faire le tour du plateau à ses pions, puis les + sort. Le premier qui a sorti tous les siens gagne la partie. » +2. **Dans quel sens avancer.** « Vos pions ivoire font le tour du plateau dans + le sens des pointillés : ils partent d'en haut à droite, passent à gauche, + puis reviennent en bas jusqu'aux six dernières flèches, en bas à droite. + C'est là que vous les rassemblerez avant de les sortir. Les pions noyer de + l'ordinateur font le même chemin, en sens inverse. » +3. **Les dés et les doubles.** « Chaque dé déplace un pion d'autant de + flèches. Avec 5 et 2, vous jouez un pion de 5 et un pion de 2, ou le même + pion de 5 puis de 2. Un double se joue quatre fois : avec 4-4, vous avancez + quatre fois de 4. » *À vous* : jouer 3-1 en faisant le point 5 (8/5 6/5). +4. **Frapper un pion seul.** « Un pion seul sur une flèche s'appelle un blot. + Si vous vous posez dessus, vous le frappez : il part sur la barre et devra + refaire tout le chemin. Une flèche où l'adversaire a deux pions ou plus est + fermée : vous ne pouvez pas vous y poser. » *À vous* : frapper le blot du + point 7 avec 6-1. +5. **Rentrer depuis la barre.** « Votre pion frappé attend sur la barre, au + milieu. Avant de jouer quoi que ce soit d'autre, il doit rentrer dans le + camp adverse, en haut à droite. Chaque dé indique une flèche d'entrée : + avec 3 et 4, la 22 ou la 21. Si les deux flèches sont fermées, votre tour + passe. » +6. **Sortir ses pions.** « Quand vos quinze pions sont dans les six dernières + flèches, vous pouvez commencer à les sortir : un 6 sort un pion de la + flèche 6, un 1 un pion de la flèche 1. Touchez le pion, puis la sortie à + droite. » *À vous* : sortir deux pions avec 6-3. +7. **Gammon et backgammon.** « Une partie gagnée vaut 1 point. Si + l'adversaire n'a sorti aucun pion quand vous finissez, c'est un gammon : + 2 points. S'il lui reste en plus un pion sur la barre ou dans votre camp, + c'est un backgammon : 3 points. » +8. **Le videau.** « Le cube aux faces 2, 4, 8, 16, 32 et 64 sert à miser. + Avant de lancer vos dés, vous pouvez proposer de doubler la valeur de la + partie. L'ordinateur prend, et la partie continue en valant le double, ou il + passe, et vous gagnez tout de suite. Celui qui a pris garde le cube : lui + seul pourra redoubler. » *À vous* : décider de prendre ou passer, avec une + phrase du maître ensuite. + +Fin du tutoriel : « Vous connaissez les règles. Le reste s'apprend en jouant, +et le maître vous accompagne. » → « Jouer ma première partie ». + +## Aide + +L'aide n'est plus une copie brute des fichiers : c'est une page organisée en +questions, avec un ton. **La source reste unique** (ADR 0008) : les mêmes +fichiers servent d'aide et de tests. + +- **Trois onglets** : « Jouer » (les scénarios), « Les mots du jeu » (le + glossaire), « À propos ». Le tutoriel est proposé en tête de page. +- **« Jouer »** : un scénario = une question repliable (`details`), fermée par + défaut sauf celle qui correspond à l'écran d'où l'on vient. + - Le **titre du scénario** est la question, formulée comme le joueur se la + pose : « Comment jouer un coup ? », « Je me suis trompé de pion », « Je ne + peux rien jouer avec mes dés ». C'est un changement de titre dans les + `.feature` : les tests n'en dépendent pas. + - La **description du scénario** (le paragraphe sous le titre) est la + réponse affichée. Elle se lit seule, au présent, au « vous ». + - Les **étapes Gherkin** ne s'affichent pas dans l'aide : elles restent la + preuve que l'app fait ce que dit la réponse. « Voir sur le plateau » ouvre + la position de l'exemple quand le scénario en donne une (lien ADR 0013). + - La **description de la fonctionnalité** sert d'introduction à l'onglet. +- **« Les mots du jeu »** : une fiche du glossaire = une carte (le mot, son nom + anglais en `caption` italique, « En bref », les renvois), triées par ordre + alphabétique, avec un champ de recherche en tête. +- **Ton** : celui du maître de café, patient et concret. Pas de « Étant + donné que », pas de vocabulaire technique (moteur, scénario, événement). From c061b6d41bda13e053624ef65daa1672020c26c0 Mon Sep 17 00:00:00 2001 From: Pierre MARTIN Date: Tue, 3 Nov 2026 15:02:01 +0100 Subject: [PATCH 15/15] Plan 0e.5 : accueil, installation, tutoriel, aide Co-Authored-By: Claude Opus 5.5 --- docs/feuille-de-route.md | 2 +- .../plans/2026-11-03-accueil-tutoriel-aide.md | 59 +++++++++++++++++++ 2 files changed, 60 insertions(+), 1 deletion(-) create mode 100644 docs/plans/2026-11-03-accueil-tutoriel-aide.md diff --git a/docs/feuille-de-route.md b/docs/feuille-de-route.md index 88a9530..d19aa37 100644 --- a/docs/feuille-de-route.md +++ b/docs/feuille-de-route.md @@ -25,7 +25,7 @@ Demandes de Pierre du 2026-11-03, traitées avant d'ouvrir l'étape 1. | --- | --- | --- | --- | | 0e.3 | Révision UX arbitrée (session « identité », sur délégation de Pierre) : portrait stable, bandeaux de compte de points, menu en feuille et nouvelle partie annulable, vibrations, écran allumé, paysage « plateau = écran » dès la rotation (ADR 0012 amendé) | ⏳ | `plans/2026-11-03-revision-ux.md` | | 0e.4 | Position dans l'adresse : chaque position du plateau a un lien direct (ADR 0013) | ⏳ | `plans/2026-11-03-position-dans-l-adresse.md` | -| 0e.5 | Page d'accueil, installation en un toucher (« Ajouter à l'écran d'accueil », proposée par l'app), tutoriel des règles très didactique avec plateaux illustrés, aide réécrite sur un ton plus humain ; textes à valider par Pierre | ⏳ | | +| 0e.5 | Page d'accueil, installation en un toucher (« Ajouter à l'écran d'accueil », proposée par l'app), tutoriel des règles très didactique avec plateaux illustrés, aide réécrite sur un ton plus humain ; textes à valider par Pierre | ⏳ | `plans/2026-11-03-accueil-tutoriel-aide.md` | ## Étapes suivantes diff --git a/docs/plans/2026-11-03-accueil-tutoriel-aide.md b/docs/plans/2026-11-03-accueil-tutoriel-aide.md new file mode 100644 index 0000000..addb98b --- /dev/null +++ b/docs/plans/2026-11-03-accueil-tutoriel-aide.md @@ -0,0 +1,59 @@ +# Accueil, installation, tutoriel des règles, aide (0e.5) + +- **Date** : 2026-11-03 +- **Étape** : 0e.5 (feuille de route), qui couvre aussi le tutoriel de 1g +- **Statut** : prévu (après la fusion de 0e.4) +- **Exécutant** : sous-agent general-purpose, dans un worktree + +## Objectif + +Traiter les points 2 et 3 du message de Pierre du 2026-11-03 et sa demande d'installation en un toucher : +- une page d'accueil ; +- l'installation proposée par l'app ; +- un tutoriel des règles très didactique, illustré par le plateau ; +- une aide plus humaine. + +## Références + +- Règles : `docs/identite/charte/accueil-tutoriel-aide.md`. Le texte du tutoriel y figure en première version, à faire valider par Pierre. +- Maquettes : https://claude.ai/artifact/CQw6vbDpi6XQqVWQa4sANe, rangée « Étape 0e.5 » et artboard « Accueil ». +- ADR 0008 (source unique), ADR 0013 (positions par identifiants gnubg). + +## Consignes + +1. **Accueil**, selon la charte : + - au premier lancement, et une seule fois, le choix entre « Je découvre » et « Je connais les règles » ; + - ensuite : la partie en cours, la carte d'installation, la prochaine leçon et la progression résumée. La leçon et la progression restent en attente tant que 1g et 1h n'existent pas : n'afficher que ce qui existe. +2. **Installation** : + - sous Chrome, garder l'invite reçue avec `beforeinstallprompt` ; + - sous Firefox et sur iPhone, une feuille en 3 étapes ; + - la carte n'apparaît qu'à partir de la première partie terminée. « Plus tard » la masque, et elle ne revient qu'une fois, après 3 parties. Le menu garde une ligne tant que l'app n'est pas installée ; + - plus rien en `display-mode: standalone` ni après l'événement `appinstalled`. +3. **Tutoriel**, en 8 étapes : + - le texte vit dans un fichier source unique sous `docs/` (par exemple `docs/tutoriel/`), copié dans le site par `make site` comme le glossaire, sous licence CC BY-SA 4.0 ; + - positions données par leurs identifiants gnubg ; + - sur les écrans « à vous », le moteur vérifie le coup joué en le comparant aux coups attendus de l'étape ; + - le coup illustré se joue seul après 1 s, avec « Revoir » ; + - un test vérifie que chaque position du tutoriel est valide pour le moteur et que chaque coup attendu est légal ; + - ajouter la fiche du glossaire « camp ». +4. **Aide**, en trois onglets : + - « Jouer » : les scénarios deviennent des questions repliables. Le titre est la question et la description la réponse ; les étapes Gherkin sont cachées. Reformuler les titres des `.feature` sans casser les tests ; + - « Les mots du jeu » : les cartes du glossaire, avec une recherche ; + - « À propos » : licence, crédits, version ; + - le tutoriel est proposé en tête de l'aide. +5. **Scénarios Gherkin** : premier lancement, installation, suivre le tutoriel. +6. **`make test-browser`** : + - premier lancement, tutoriel complet avec ses écrans « à vous », aide ; + - `beforeinstallprompt` simulé dans Chromium ; + - captures dans `build/browser-shots/accueil-tutoriel/` pour la recette design. + +`make lint`, `make test` et `make test-browser` doivent passer. Aucune nouvelle dépendance. + +## Critères de réussite + +- Un débutant suit le tutoriel jusqu'au bout sur le téléphone, et chaque écran « à vous » accepte le bon coup et refuse les autres. +- Les trois commandes passent, et les captures sont livrées pour la recette. + +## Résultat + +À remplir à la clôture.