// plateau.mjs -- les gestes du plateau soigné (étape 1i ; charte, // ecrans-etape-1.md, 1i ; interaction.md), joués dans un vrai navigateur sans // écran (Playwright), sur le site servi par l'image de production : // // - un coup joué entièrement au glisser-déposer (pion fantôme au départ, pion // glissé agrandi, cases d'arrivée du moteur) ; // - un pion lâché hors d'une case permise revient à sa place, sans rien // jouer ; // - le double toucher : rien quand plusieurs coups commencent par le pion, // le coup évident sinon ; // - un coup joué au clavier (flèches, Entrée, Espace), raccourcis en kbd // affichés une fois le clavier détecté ; // - le filet intérieur des pions ; // - aucune erreur, aucune violation de la politique de sécurité. // // Copyright (C) 2026 les auteurs d'Ustagammon. // Licence : GPLv3 ou ultérieure (voir LICENSE). // // Usage : node plateau.mjs // Lancé par tests/browser.sh. Les dés viennent de Math.random, remplacé ici // par une file de valeurs choisies (window.__dice), puis par un générateur à // graine. Captures de la recette design (Chromium) dans // /plateau/, 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 plateau.mjs "); process.exit(2); } const full = browserName === "chromium"; const dir = `${shots}/plateau`; mkdirSync(dir, { recursive: true }); const PORTRAIT = { width: 412, height: 915 }; const LANDSCAPE = { width: 915, height: 412 }; // Positions de gnubg, vues du joueur : le départ ; un pion sur la 6 et un // sur la 1 (avec 6-1, seul le pion de la 1 a un coup évident). const START = "4HPwATDgc/ABMA"; const SIX_AND_ONE = "BQAACAIAAAAAAA"; 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", }); await context.addInitScript(() => { window.__dice = []; let state = 7; 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}`), ); if (localStorage.getItem("ustagammon.settings") === null) { localStorage.setItem("ustagammon.settings", JSON.stringify({ tutorialSeen: 1 })); } }); 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 = 60_000) { for (let waited = 0; waited < ms; waited += 100) { if (await page.evaluate(fn, arg).catch(() => false)) { 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 nouvelle partie à partir d'une position, joueur au trait, puis les * dés imposés lancés. */ async function startFrom(position, dice) { 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, ); await page.evaluate((d) => window.__dice.push(...d), dice); await page.click("#roll"); await idle(); } /** L'état du plateau : pions par endroit (pion du dessus marqué), pion * choisi, cases d'arrivée, glissement en cours, dés joués. */ function boardState() { return page.evaluate(() => { const board = document.getElementById("board"); const at = (place) => board.querySelector(`[data-checker="${place}"]`) !== null; return { at: Object.fromEntries( ["p1", "p5", "p6", "p7", "p8", "p13", "p24", "off-player"].map((p) => [p, at(p)]), ), selected: board.querySelector(".checker.selected")?.closest("[data-checker]") ?.getAttribute("data-checker") ?? null, landing: [...board.querySelectorAll(".hit.can-land")].map((e) => e.getAttribute("data-place"), ), dragging: board.querySelector("#drag-piece") !== null, ghost: board.querySelector(".drag-origin")?.getAttribute("data-checker") ?? null, used: board.querySelectorAll(".die-used").length, focus: board.querySelector(".key-focus") !== null, rings: board.querySelectorAll(".checker-ring").length, checkers: board.querySelectorAll("circle.checker").length, confirm: !document.getElementById("confirm").hidden, }; }); } /** Le centre, à l'écran, de la zone à toucher d'un endroit. */ async function center(place) { const box = await page.locator(`#board .hit[data-place="${place}"]`).boundingBox(); check(box !== null, `zone ${place} introuvable`); return { x: box.x + box.width / 2, y: box.y + box.height / 2 }; } /** Le centre, à l'écran, du pion du dessus d'un endroit. */ async function checkerCenter(place) { const box = await page.locator(`#board [data-checker="${place}"] circle.checker`).boundingBox(); check(box !== null, `pion ${place} introuvable`); return { x: box.x + box.width / 2, y: box.y + box.height / 2 }; } /** Prend le pion de from et le fait glisser au-dessus de la zone de to, * sans le lâcher ; renvoie l'état du plateau pendant le glissement. */ async function dragOver(from, to) { const start = await checkerCenter(from); const end = await center(to); await page.mouse.move(start.x, start.y); await page.mouse.down(); await page.mouse.move(start.x + 4, start.y + 12, { steps: 3 }); await page.mouse.move(end.x, end.y, { steps: 8 }); await sleep(100); return boardState(); } /** Lâche le pion glissé, puis attend que le plateau soit de nouveau libre * et sans glissement (le pion rentré ou posé). */ async function release() { await page.mouse.up(); await until("glissement fini", () => document.getElementById("drag-piece") === null); await idle(); } /** Une capture de la recette, à la taille du moment (Chromium seulement). */ async function capture(name) { if (!full) { return; } const size = page.viewportSize(); await page.screenshot({ path: `${dir}/${name}-${size.width}x${size.height}.png` }); captures++; } // ------------------------------------------------- Le filet des pions await startFrom(START, [3, 1]); let state = await boardState(); check(state.checkers >= 28 && state.rings === state.checkers, `filet : ${state.rings} filets pour ${state.checkers} pions`); 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 capture("plateau-filet"); } await page.setViewportSize(PORTRAIT); await sleep(300); log(`filet intérieur sur chaque pion (${state.rings})`); // ------------------------------------- Un coup entier au glisser-déposer state = await dragOver("p8", "p5"); check(state.dragging && state.ghost === "p8" && state.selected === "p8", `glissement de 8 : ${JSON.stringify(state)}`); check(state.landing.includes("p5") && state.landing.includes("p7"), `glissement de 8 : cases d'arrivée ${state.landing}`); await capture("glissement"); await release(); state = await boardState(); check(state.used === 1 && state.at.p5 && !state.dragging && state.ghost === null, `8/5 lâché sur la 5 : ${JSON.stringify(state)}`); await dragOver("p6", "p5"); await release(); state = await boardState(); check(state.used === 2 && state.confirm, `6/5 lâché sur la 5 : ${JSON.stringify(state)}`); await page.click("#confirm"); await idle(); log("un coup entier joué au glisser-déposer (8/5 6/5)"); // Le glissement en paysage, pour la recette. if (full) { await startFrom(START, [3, 1]); await until("bandeau parti", () => document.getElementById("banner").hidden, null, 15_000); await page.setViewportSize(LANDSCAPE); await sleep(300); state = await dragOver("p13", "p10"); check(state.dragging && state.ghost === "p13", `paysage : ${JSON.stringify(state)}`); await capture("glissement"); await release(); await page.setViewportSize(PORTRAIT); await sleep(300); } // ------------------------------------------- Lâché hors d'une case permise await startFrom(START, [3, 1]); const before = await boardState(); // La 12 est tenue par l'ordinateur : 13/12 n'est pas permis. state = await dragOver("p13", "p12"); check(state.dragging && !state.landing.includes("p12"), `13 vers 12 : ${JSON.stringify(state)}`); await release(); state = await boardState(); check(state.used === 0 && state.at.p13 && state.selected === null && !state.dragging && state.checkers === before.checkers, `lâché sur la 12 : le pion n'est pas revenu ${JSON.stringify(state)}`); log("lâché hors d'une case permise : le pion revient à sa place, rien n'est joué"); // ----------------------------------------------------------- Double toucher await startFrom(SIX_AND_ONE, [6, 1]); // Depuis la 6 : 6/off 1/off ou 6/5/off, deux coups : rien. await page.dblclick('#board .hit[data-place="p6"]'); await sleep(500); await idle(); state = await boardState(); check(state.used === 0 && state.at.p6 && state.at.p1, `double toucher sur la 6 : ${JSON.stringify(state)}`); // Depuis la 1 : 1/off puis 6/off, le seul coup. await page.dblclick('#board .hit[data-place="p1"]'); await until("coup évident joué", () => !document.getElementById("confirm").hidden); await idle(); state = await boardState(); check(state.used === 2 && !state.at.p6 && !state.at.p1 && state.at["off-player"], `double toucher sur la 1 : ${JSON.stringify(state)}`); log("double toucher : rien sur la 6 (deux coups), le coup évident sur la 1"); // ------------------------------------------------------------------ Clavier await startFrom(START, [3, 1]); await page.locator("body").focus().catch(() => {}); // Les pions jouables dans le sens de la course : 24, 13, 8, 6. for (let i = 0; i < 3; i++) { await page.keyboard.press("ArrowRight"); } state = await boardState(); check(state.selected === "p8" && state.focus, `flèches : pion ${state.selected}`); // Les cases d'arrivée de la 8 : la 7, puis la 5. await page.keyboard.press("ArrowDown"); await page.keyboard.press("Enter"); await idle(); state = await boardState(); check(state.used === 1 && state.at.p5, `Entrée : 8/5 non joué ${JSON.stringify(state)}`); // En arrière depuis la fin : 5, puis 6 ; sa seule case, la 5. await page.keyboard.press("ArrowLeft"); await page.keyboard.press("ArrowLeft"); state = await boardState(); check(state.selected === "p6" && state.focus, `flèche gauche : pion ${state.selected}`); await page.keyboard.press("Enter"); await idle(); state = await boardState(); check(state.used === 2 && state.confirm, `Entrée : 6/5 non joué ${JSON.stringify(state)}`); const keys = await page.evaluate(() => { const kbd = document.querySelector("#confirm kbd.shortcut"); return { keyboard: document.documentElement.classList.contains("keyboard"), shown: kbd !== null && kbd.getBoundingClientRect().width > 0, text: kbd?.textContent ?? null, }; }); check(keys.keyboard && keys.shown && keys.text === "Espace", `raccourci : ${JSON.stringify(keys)}`); await capture("clavier"); await page.keyboard.press(" "); await until("coup validé", () => document.getElementById("confirm").hidden); await idle(); log("un coup joué au clavier (flèches, Entrée, Espace), raccourcis affichés"); 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(`plateau soigné : tout est conforme (${captures} captures)`);