Conseil du maître : test dans le navigateur, ADR 0007 et 0013 amendés

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Pierre MARTIN
2026-11-10 04:49:03 +01:00
co-authored by Claude Opus 5.5
parent 5d6cbec89b
commit cd5f70e548
4 changed files with 350 additions and 3 deletions
@@ -128,3 +128,25 @@ ne change pas (version 1) : le résumé d'une partie gagne un champ facultatif
`match`, recalculé à chaque enregistrement, et les réglages
(`ustagammon.settings`) quatre champs (`gameKind`, `matchLength`, `level`,
`masterAdvice`) qui ont une valeur par défaut quand ils manquent.
### 2026-11-10 — Le conseil du maître rattaché au journal
Avec le conseil du maître (étape 1d), le journal d'une partie
(`GameJournal`, `app/src/domain/game/events.ts`) gagne un champ facultatif
`advice` : pour chaque décision du joueur analysée (coup, jet sans doubler,
double, prise ou refus d'un double), l'analyse du moteur à 2 plis, rangée
sous l'identifiant de l'événement de la décision
(`app/src/domain/game/advice.ts`). Ce n'est pas un événement : l'analyse
arrive après coup, d'un second moteur, et ne change pas la partie ; les
événements gardent leur version (2). Le bilan de fin de partie (1e) la
relira sans recalcul.
- Chaque conseil porte sa version (`ADVICE_VERSION`, 1). À la reprise
(`migrateJournal`), un conseil d'une autre version, ou rattaché à un
événement absent, est oublié : ce n'est qu'une analyse, qui peut être
refaite. Un journal enregistré avant l'étape 1d n'a pas de champ `advice`
et se reprend tel quel. Testé dans `app/tests/advice.test.ts`.
- Ajouter un événement, revenir en arrière ou changer de branche garde les
conseils ; un conseil reste attaché à son événement, même hors de la
branche courante.
- Le schéma IndexedDB ne change pas : le journal est enregistré tel quel.
+18
View File
@@ -112,3 +112,21 @@ Le menu, l'accueil et « Nouvelle partie » en fin de partie y mènent. « Comme
remplace cette adresse par celle de la partie (`#/`) : le retour du téléphone
ne rouvre pas l'écran. Le lien d'une position d'une partie de match porte le
Match ID du match (longueur, score, partie Crawford), écrit par le moteur.
### 2026-11-10 — Adresse de la feuille d'explication du maître
Ajoutée avec le conseil du maître (étape 1d), dans le même schéma :
| Écran | Adresse |
| --- | --- |
| Feuille d'explication du maître sur une décision de la partie en cours | `#/explication/<événement>` |
`<événement>` est l'identifiant, dans le journal de la partie en cours, de
l'événement de la décision commentée (le coup, le double, la prise…). La
ligne du maître est un lien vers cette adresse ; la feuille monte au-dessus
de la partie, comme le menu, et « Fermer » (ou le retour du téléphone)
revient à la partie. Rechargée, l'adresse rouvre la même explication : le
conseil est enregistré dans le journal (ADR 0007, amendement du
2026-11-10). Un événement sans conseil donne un message, jamais une page
cassée. L'adresse n'a de sens que sur l'appareil qui a joué la partie : ce
n'est pas un lien à partager (le lien de la position, lui, se partage).
+13 -3
View File
@@ -8,7 +8,10 @@
# partie » et un match en 3 points joué jusqu'au bout (tests/browser/match.mjs),
# captures dans build/browser-shots/match/.
#
# BROWSERS=chromium et SCRIPTS=accueil (ou play, ou match) restreignent le
# Le conseil du maître (tests/browser/maitre.mjs), captures dans
# build/browser-shots/maitre/.
#
# BROWSERS=chromium et SCRIPTS=accueil (ou play, match, maitre) restreignent le
# test.
#
# Outils : l'image de Playwright (navigateurs) et le paquet playwright,
@@ -22,7 +25,7 @@ cd "$(dirname "$0")/.."
image="${PLAYWRIGHT_IMAGE:?lancer via make test-browser}"
browsers="${BROWSERS:-chromium firefox}"
# Les scripts à lancer (tests/browser/<nom>.mjs), tous par défaut.
scripts="${SCRIPTS:-play accueil match}"
scripts="${SCRIPTS:-play accueil match maitre}"
unset MAKEFLAGS MFLAGS MAKELEVEL
make -s package >/dev/null
@@ -59,7 +62,7 @@ update_port="$(docker port "$update_container" 80/tcp | head -n1 | sed 's/.*://'
rm -rf build/browser-shots
mkdir -p build/browser-shots
cp tests/browser/play.mjs tests/browser/accueil.mjs tests/browser/match.mjs "$tools/"
cp tests/browser/play.mjs tests/browser/accueil.mjs tests/browser/match.mjs tests/browser/maitre.mjs "$tools/"
status=0
for browser in $browsers; do
# Réseau de l'hôte : le site est sur 127.0.0.1. Le service worker
@@ -85,5 +88,12 @@ for browser in $browsers; do
-v "$PWD/$tools:/tools" -v "$PWD/build/browser-shots:/shots" -w /tools "$image" \
node match.mjs "http://localhost:$port/" "$browser" /shots || status=1
fi
if [[ " $scripts " == *" maitre "* ]]; then
# Conseil du maître : ligne, feuille d'explication, videau, réglage ;
# captures de la recette dans build/browser-shots/maitre/.
docker run --rm --init --ipc=host --network host --user "$(id -u):$(id -g)" -e HOME=/tmp \
-v "$PWD/$tools:/tools" -v "$PWD/build/browser-shots:/shots" -w /tools "$image" \
node maitre.mjs "http://localhost:$port/" "$browser" /shots || status=1
fi
done
exit "$status"
+297
View File
@@ -0,0 +1,297 @@
// maitre.mjs -- le conseil du maître (étape 1d) joué par l'interface, dans
// un vrai navigateur sans écran (Playwright), sur le site servi par l'image
// de production (docs/plans/2026-11-03-conseil-du-maitre.md).
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
//
// Usage : node maitre.mjs <adresse du site> <chromium|firefox> <dossier des captures>
// Lancé par tests/browser.sh. Chaque cas part d'une position connue
// (#/position/..., « Jouer à partir d'ici ») avec des dés imposés : les dés
// de l'application viennent de Math.random, remplacé ici par une file de
// valeurs choisies (window.__dice), puis par un générateur à graine.
// Vérifie la ligne du maître (bon coup, imprécision, erreur, grosse
// erreur), la feuille d'explication et son adresse, une décision de videau
// commentée, le conseil coupé dans le menu, l'absence d'erreur et de
// violation de la politique de sécurité. Captures de la recette design
// (Chromium) dans <dossier des captures>/maitre/, en 412×915 et 915×412.
import { mkdirSync } from "node:fs";
import { chromium, firefox } from "playwright";
const [base, browserName, shots] = process.argv.slice(2);
const browserType = { chromium, firefox }[browserName];
if (!base || !browserType || !shots) {
console.error("usage : node maitre.mjs <adresse> <chromium|firefox> <dossier des captures>");
process.exit(2);
}
const full = browserName === "chromium";
const dir = `${shots}/maitre`;
mkdirSync(dir, { recursive: true });
const PORTRAIT = { width: 412, height: 915 };
const LANDSCAPE = { width: 915, height: 412 };
const START = "4HPwATDgc/ABMA";
// Une position de double et prise (videau au centre) : doubler y est juste.
const DOUBLE_TAKE = "6O5uAAC4uzsAAA";
const problems = [];
const log = (text) => console.log(`[${browserName}] ${text}`);
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
let captures = 0;
function check(condition, message) {
if (!condition) {
throw new Error(message);
}
}
const browser = await browserType.launch();
const context = await browser.newContext({
viewport: PORTRAIT,
deviceScaleFactor: 1,
locale: "fr-FR",
// Sans animation : les captures montrent les coups posés, avec leurs
// traces.
reducedMotion: "reduce",
});
await context.addInitScript(() => {
// Les dés imposés d'abord (un dé par valeur), puis une graine fixe.
window.__dice = [];
let state = 11;
Math.random = () => {
const die = window.__dice.shift();
if (die !== undefined) {
return (die - 0.5) / 6;
}
state = (state * 1103515245 + 12345) % 2147483648;
return state / 2147483648;
};
window.__csp = [];
document.addEventListener("securitypolicyviolation", (e) =>
window.__csp.push(`${e.effectiveDirective} ${e.blockedURI}`),
);
});
const page = await context.newPage();
page.on("pageerror", (error) => problems.push(`erreur de la page : ${error.message}`));
page.on("console", (message) => {
if (message.type() === "error") {
problems.push(`console : ${message.text()}`);
}
});
/** Attend une condition dans la page (sans page.waitForFunction, qui passe
* par eval, interdit par la politique de sécurité du site). */
async function until(what, fn, arg, ms = 90_000) {
for (let waited = 0; waited < ms; waited += 100) {
if (await page.evaluate(fn, arg)) {
return;
}
await sleep(100);
}
await page.screenshot({ path: `${dir}/${browserName}-bloque.png` }).catch(() => {});
throw new Error(`attente trop longue : ${what}`);
}
const idle = () => until("écran libre", () => !document.body.classList.contains("busy"));
/** Une capture de la recette, dans les deux tailles (Chromium seulement). */
async function capture(name) {
if (!full) {
return;
}
// Le bandeau « Reprendre l'ancienne » (10 s) ne gêne pas la lecture.
await until("bandeau parti", () => document.getElementById("banner").hidden, null, 15_000);
for (const size of [PORTRAIT, LANDSCAPE]) {
await page.setViewportSize(size);
await sleep(300);
await page.screenshot({ path: `${dir}/${name}-${size.width}x${size.height}.png` });
captures++;
}
await page.setViewportSize(PORTRAIT);
await sleep(300);
}
/** Une nouvelle partie à partir d'une position, joueur au trait. */
async function startFrom(position) {
await page.goto(`${base}#/position/${encodeURIComponent(position)}`);
await until("position lue", () => {
const play = document.getElementById("position-play");
return !play.hidden && !play.disabled;
});
await page.click("#position-play");
await until("partie commencée", () =>
location.hash === "#/" && !document.body.classList.contains("busy") &&
!document.getElementById("roll").hidden);
}
/** Lance les dés imposés et joue un coup, demi-coup par demi-coup : le pion
* de départ, puis sa case d'arrivée si le pion ne s'y est pas déjà posé. */
async function play(dice, halfMoves) {
await page.evaluate((d) => {
window.__dice.push(...d);
}, dice);
await page.click("#roll");
await idle();
for (const [from, to] of halfMoves) {
await page.click(`#board .hit[data-place="p${from}"]`);
await idle();
const land = page.locator(`#board .hit.can-land[data-place="p${to}"]`);
if ((await land.count()) > 0) {
await land.click();
await idle();
}
}
check(!(await page.locator("#confirm").isHidden()), `coup incomplet : ${JSON.stringify(halfMoves)}`);
await page.click("#confirm");
}
/** La ligne du maître telle qu'elle s'affiche. */
async function adviceLine() {
return page.evaluate(() => {
const line = document.getElementById("advice-line");
return {
shown: !line.hidden,
word: line.querySelector(".advice-word")?.textContent ?? "",
phrase: line.querySelector(".advice-phrase")?.textContent ?? "",
href: line.getAttribute("href"),
infoShown: !document.getElementById("info").hidden,
height: line.getBoundingClientRect().height,
chip: document.getElementById("advice-chip") !== null,
};
});
}
async function waitAdvice() {
await until("ligne du maître", () =>
!document.getElementById("advice-line").hidden &&
!document.body.classList.contains("busy"));
return adviceLine();
}
// ------------------------------------------------- Bon coup, et ses voisins
const cases = [
{ name: "1-bon-coup", dice: [3, 1], moves: [[8, 5], [6, 5]], word: "Bon coup." },
{ name: "2-imprecision", dice: [2, 1], moves: [[13, 11], [11, 10]], word: "Imprécision." },
{ name: "3-erreur", dice: [2, 1], moves: [[24, 22], [6, 5]], word: "Erreur." },
{ name: "4-grosse-erreur", dice: [3, 1], moves: [[24, 23], [13, 10]], word: "Grosse erreur." },
];
for (const c of cases) {
await startFrom(START);
// Rien pendant que le joueur réfléchit.
check(!(await adviceLine()).shown, `${c.name} : ligne avant le coup`);
await play(c.dice, c.moves);
const line = await waitAdvice();
check(line.word === c.word, `${c.name} : mot « ${line.word} », attendu « ${c.word} »`);
check(line.phrase.length > 0 && !line.phrase.includes("!"), `${c.name} : phrase « ${line.phrase} »`);
check(!line.infoShown, `${c.name} : la ligne du dernier coup reste visible`);
check(line.height <= 44, `${c.name} : ligne de ${line.height} px`);
check(/^#\/explication\/\d+$/.test(line.href), `${c.name} : lien ${line.href}`);
if (c.word !== "Bon coup.") {
check(line.phrase.startsWith("J'aurais joué") || line.phrase.startsWith("Tout près"),
`${c.name} : le coup du maître n'est pas nommé : « ${line.phrase} »`);
}
await capture(c.name);
if (full) {
// En paysage, la pastille du mot mène à la même feuille.
await page.setViewportSize(LANDSCAPE);
await sleep(200);
check((await adviceLine()).chip, `${c.name} : pas de pastille en paysage`);
await page.setViewportSize(PORTRAIT);
await sleep(200);
}
log(`${c.name} : « ${line.word} ${line.phrase} »`);
}
// La ligne s'efface à l'action suivante du joueur.
await page.click("#roll");
await idle();
check(!(await adviceLine()).shown, "la ligne reste après le lancer suivant");
// ------------------------------------------------- Feuille d'explication
await startFrom(START);
await play([3, 1], [[24, 23], [13, 10]]);
await waitAdvice();
await page.click("#advice-line");
await until("feuille ouverte", () =>
/^#\/explication\/\d+$/.test(location.hash) &&
!document.getElementById("explain-veil").hidden &&
document.getElementById("explain-board") !== null &&
document.querySelector("#explain-board .board-frame") !== null);
const sheet = await page.evaluate(() => ({
verdict: document.getElementById("explain-verdict").textContent,
title: document.getElementById("explain-title").textContent,
mine: document.getElementById("explain-mine")?.getAttribute("aria-checked"),
best: document.getElementById("explain-best")?.getAttribute("aria-checked"),
numbersOpen: document.getElementById("explain-numbers").open,
}));
check(sheet.verdict === "Grosse erreur · votre 3-1 d'ouverture", `feuille : ${JSON.stringify(sheet)}`);
check(sheet.title === "J'aurais joué 8/5 6/5", `titre : ${sheet.title}`);
check(sheet.best === "true" && sheet.mine === "false", "le coup du maître n'est pas montré d'abord");
check(!sheet.numbersOpen, "les chiffres du moteur ne sont pas repliés");
await capture("5-feuille-explication");
await page.click("#explain-mine");
await sleep(300);
await page.click("#explain-numbers summary");
const rows = await page.evaluate(() => document.querySelector(".explain-table .strong")?.textContent);
check(rows !== undefined, "votre coup n'est pas en gras dans les chiffres");
await capture("5b-feuille-votre-coup-chiffres");
// L'adresse se recharge : la feuille revient, le conseil vient du journal.
await page.reload();
await until("feuille rouverte après rechargement", () =>
!document.getElementById("explain-veil").hidden &&
document.getElementById("explain-title")?.textContent === "J'aurais joué 8/5 6/5");
await page.click("#explain-done");
await until("feuille fermée", () =>
location.hash === "#/" && document.getElementById("explain-veil").hidden);
log("feuille d'explication : mot, coup du maître, votre coup, chiffres repliés, adresse rechargée");
// ------------------------------------------------- Décision de videau
await startFrom(DOUBLE_TAKE);
check(!(await page.locator("#double").isHidden()), "doubler n'est pas proposé");
await page.click("#double");
await page.click("#double-confirm");
const cube = await waitAdvice();
check(cube.word === "Bonne décision." && cube.phrase === "Doubler était le bon choix.",
`videau : « ${cube.word} ${cube.phrase} »`);
await capture("6-decision-de-videau");
log(`décision de videau : « ${cube.word} ${cube.phrase} »`);
// ------------------------------------------------- Conseil coupé
await page.click("#menu-button");
await page.waitForSelector("#menu", { state: "visible" });
check((await page.getAttribute("#menu-advice", "aria-checked")) === "true", "conseil coupé par défaut");
await page.click("#menu-advice");
check((await page.getAttribute("#menu-advice", "aria-checked")) === "false", "interrupteur sans effet");
await page.click("#menu-close");
await startFrom(START);
await play([3, 1], [[24, 23], [13, 10]]);
await idle();
// Laisse le temps à l'analyse : la ligne ne doit pas venir.
await sleep(3000);
const off = await adviceLine();
check(!off.shown && off.infoShown, `conseil coupé : ${JSON.stringify(off)}`);
await capture("7-conseil-desactive");
// On le rallume : le conseil déjà prêt réapparaît.
await page.click("#menu-button");
await page.waitForSelector("#menu", { state: "visible" });
await page.click("#menu-advice");
await page.click("#menu-close");
check((await waitAdvice()).word === "Grosse erreur.", "conseil rallumé : la ligne ne revient pas");
log("conseil coupé dans le menu : la ligne reste vide ; rallumé, elle revient");
const csp = await page.evaluate(() => window.__csp);
for (const violation of csp) {
problems.push(`politique de sécurité : ${violation}`);
}
await browser.close();
if (problems.length > 0) {
for (const problem of problems) {
console.error(`[${browserName}] ${problem}`);
}
process.exit(1);
}
log(`conseil du maître : tout est conforme (${captures} captures)`);