// 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 // 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 /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 "); 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)`);