// match.mjs -- l'écran « Nouvelle partie » et un match en 3 points joué // jusqu'au bout par l'interface, dans un vrai navigateur sans écran // (Playwright), sur le site servi par l'image de production // (docs/plans/2026-11-03-match-et-reglages.md, point 6). // // Copyright (C) 2026 les auteurs d'Ustagammon. // Licence : GPLv3 ou ultérieure (voir LICENSE). // // Usage : node match.mjs // Lancé par tests/browser.sh. Les dés de l'application viennent de // Math.random : il est remplacé ici par un générateur à graine, pour que le // match soit le même à chaque fois (il passe par une partie Crawford). Le // joueur joue toujours le premier pion jouable vers sa première case // d'arrivée, prend les doubles et ne double jamais. Captures de la recette // design (Chromium) dans /match/ : écran « Nouvelle // partie », score du match et partie Crawford, 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 match.mjs "); process.exit(2); } const full = browserName === "chromium"; const dir = `${shots}/match`; mkdirSync(dir, { recursive: true }); const PORTRAIT = { width: 412, height: 915 }; const LANDSCAPE = { width: 915, height: 412 }; // La graine des dés : un match qui passe par une partie Crawford. const SEED = 7; 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 : le match va plus vite, rien d'autre ne change. reducedMotion: "reduce", }); await context.addInitScript((seed) => { let state = seed; Math.random = () => { state = (state * 1103515245 + 12345) % 2147483648; return state / 2147483648; }; window.__csp = []; document.addEventListener("securitypolicyviolation", (e) => window.__csp.push(`${e.effectiveDirective} ${e.blockedURI}`), ); }, SEED); 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)) { return; } await sleep(100); } await page.screenshot({ path: `${dir}/${browserName}-bloque.png` }).catch(() => {}); throw new Error(`attente trop longue : ${what}`); } /** Une capture de la recette, dans les deux tailles (Chromium seulement). */ async function capture(name) { if (!full) { return; } for (const size of [PORTRAIT, LANDSCAPE]) { await page.setViewportSize(size); await sleep(200); await page.screenshot({ path: `${dir}/${name}-${size.width}x${size.height}.png` }); captures++; } await page.setViewportSize(PORTRAIT); await sleep(200); } /** Ce que le joueur a à faire (comme tests/browser/play.mjs). */ async function todo() { for (let i = 0; i < 600; i++) { const what = await page.evaluate(() => { if (document.body.classList.contains("busy")) { return null; } const panel = document.getElementById("panel-veil"); if (!panel.hidden) { return panel.dataset.kind; } const usable = (id) => { const b = document.getElementById(id); return !b.hidden && !b.disabled; }; for (const id of ["take", "skip", "confirm", "roll"]) { if (usable(id)) { return id; } } return document.querySelector(".hit.can-move") ? "move" : null; }); if (what !== null) { return what; } await sleep(100); } await page.screenshot({ path: `${dir}/${browserName}-bloque.png` }).catch(() => {}); throw new Error(`rien à faire depuis 60 s : « ${await page.textContent("#info")} »`); } /** Le match tel que l'écran le montre (portrait). */ async function shownMatch() { return page.evaluate(() => ({ line: document.getElementById("match-line").textContent, lineShown: !document.getElementById("match-line").hidden, spoken: document.getElementById("match-line").getAttribute("aria-label"), computer: document.querySelector("#strip-computer .match-score").textContent, player: document.querySelector("#strip-player .match-score").textContent, cube: document.querySelectorAll("#board .cube").length, double: !document.getElementById("double").hidden, })); } // ---------------------------------------------------- Écran « Nouvelle partie » await page.goto(`${base}#/nouvelle-partie`); await until("moteur chargé", () => !document.getElementById("new-game-start").disabled); const screen = () => page.evaluate(() => ({ free: document.getElementById("new-game-free").getAttribute("aria-checked"), match: document.getElementById("new-game-match").getAttribute("aria-checked"), lengthsShown: !document.getElementById("new-game-match-options").hidden, jacobyShown: !document.getElementById("new-game-free-options").hidden, level: document.querySelector(".level-bars [aria-checked=true]")?.dataset.level, reached: document.querySelectorAll(".level-bars .reached").length, name: document.getElementById("new-game-level-name").textContent, advice: document.getElementById("new-game-advice").getAttribute("aria-checked"), start: document.getElementById("new-game-start").textContent, topbar: document.querySelector(".topbar").getClientRects().length > 0, game: !document.getElementById("game").hidden, })); let s = await screen(); check(s.free === "true" && s.match === "false" && !s.lengthsShown && s.jacobyShown, `par défaut, partie libre : ${JSON.stringify(s)}`); check(s.level === "intermediate" && s.reached === 3 && s.name === "Intermédiaire", `par défaut, Intermédiaire : ${JSON.stringify(s)}`); check(s.advice === "true" && s.start === "Commencer la partie", `conseil, bouton : ${JSON.stringify(s)}`); check(!s.topbar && !s.game, "l'écran « Nouvelle partie » laisse voir la barre haute ou la partie"); check((await page.getAttribute("#new-game-jacoby a", "href")) === "#/glossaire/regle-de-jacoby", "lien vers la règle de Jacoby"); // Les cibles tactiles : 44 px au moins. const small = await page.evaluate(() => [...document.querySelectorAll("#new-game button, #new-game a")] .filter((el) => el.getClientRects().length > 0 && el.closest(".new-game-rule") === null) .map((el) => el.getBoundingClientRect()) .filter((r) => r.width < 44 || r.height < 44).length, ); check(small === 0, `${small} cibles tactiles de moins de 44 px sur l'écran « Nouvelle partie »`); await page.click("#new-game-match"); await page.click("#new-game-length-3"); await page.click("#new-game-level-beginner"); s = await screen(); check(s.match === "true" && s.lengthsShown && !s.jacobyShown && s.start === "Commencer le match en 3 points", `match en 3 points : ${JSON.stringify(s)}`); check(s.level === "beginner" && s.reached === 1 && s.name === "Débutant", `Débutant : ${JSON.stringify(s)}`); check((await page.getAttribute("#new-game-crawford a", "href")) === "#/glossaire/regle-de-crawford", "lien vers la règle de Crawford"); check((await page.textContent("#new-game-level-text")).startsWith("Joue simplement"), "description du niveau Débutant"); await capture("1-nouvelle-partie"); // Fermer ne change rien : on revient à la partie, et les choix sont gardés. const before = await page.evaluate(() => localStorage.getItem("ustagammon.settings")); await page.click("#new-game-close"); await until("retour à la partie", () => location.hash === "#/" && !document.getElementById("game").hidden); const settings = JSON.parse(before); check(settings.gameKind === "match" && settings.matchLength === 3 && settings.level === "beginner", `choix gardés : ${before}`); // Le menu y ramène, avec les choix de la dernière fois. await page.click("#menu-button"); await page.waitForSelector("#menu", { state: "visible" }); await page.click("#menu-new-game"); await until("écran « Nouvelle partie »", () => location.hash === "#/nouvelle-partie" && !document.getElementById("new-game").hidden); s = await screen(); check(s.match === "true" && s.level === "beginner" && s.start === "Commencer le match en 3 points", `choix retrouvés : ${JSON.stringify(s)}`); await page.click("#new-game-start"); await until("match commencé", () => location.hash === "#/" && !document.body.classList.contains("busy")); log("écran « Nouvelle partie » : réglages par défaut, match en 3 points, Débutant, choix gardés"); // ------------------------------------------------------------- Le match let m = await shownMatch(); check(m.lineShown && m.line === "Match en 3 points" && m.computer === "0" && m.player === "0", `début du match : ${JSON.stringify(m)}`); check(m.spoken === "Match en 3 points : l'ordinateur 0, vous 0", `lecteur d'écran : « ${m.spoken} »`); // Un match ne passe pas toujours par une partie Crawford (un gammon peut // sauter le point d'avant la victoire) : on enchaîne les matchs, avec les // dés de la même graine, jusqu'à en avoir joué une (cinq matchs au plus). let games = 1; let crawfordGames = 0; let scoreShot = false; let crawfordShot = false; let reloaded = false; let actions = 0; let matches = 1; for (;;) { check(actions++ < 20000, "le match ne finit pas"); const what = await todo(); const now = await shownMatch(); const crawford = now.line.endsWith("· partie Crawford"); if (crawford && what !== "over") { check(now.cube === 0 && !now.double, `partie Crawford : videau visible ${JSON.stringify(now)}`); } if (what === "over") { const title = await page.textContent("#panel-text"); const detail = await page.textContent("#panel-detail"); const primary = await page.textContent("#panel-primary"); log(`partie ${games}${crawford ? " (Crawford)" : ""} : ${title} ; ${detail}`); if (primary === "Nouvelle partie") { check(/gagne(z)? le match$/.test(title) && detail.includes("Score final"), `fin du match : ${title} ; ${detail}`); const [, player, computer] = /vous (\d+), l'ordinateur (\d+)/.exec(detail) ?? []; check(Math.max(Number(player), Number(computer)) >= 3, `score final : ${detail}`); // Paysage : le score dans la colonne de commandes. await page.setViewportSize(LANDSCAPE); await sleep(200); const box = await page.evaluate(() => document.querySelector("#board .match-box")?.getAttribute("aria-label")); check(/^Match en 3 points : l'ordinateur \d+, vous \d+$/.test(box ?? ""), `score en paysage : « ${box} »`); await page.setViewportSize(PORTRAIT); await sleep(200); // Après le match, « Nouvelle partie » mène à l'écran des choix. await page.click("#panel-primary"); await until("écran « Nouvelle partie » après le match", () => location.hash === "#/nouvelle-partie"); log(`match ${matches} fini : ${games} parties, ${crawfordGames} Crawford au total`); if (crawfordGames > 0 && scoreShot) { break; } check(matches < 5, `aucune partie Crawford en ${matches} matchs (graine ${SEED})`); matches++; games = 1; await page.click("#new-game-start"); await until("match suivant", () => location.hash === "#/" && !document.body.classList.contains("busy")); continue; } check(primary === "Partie suivante" && /Match en 3 points : vous \d+, l'ordinateur \d+/.test(detail), `fin de partie dans un match : ${primary} ; ${detail}`); await page.click("#panel-primary"); games++; await until("partie suivante", () => !document.body.classList.contains("busy")); const next = await shownMatch(); if (next.line.endsWith("· partie Crawford")) { crawfordGames++; check(next.cube === 0, "partie Crawford : le videau est dessiné"); if (!crawfordShot) { crawfordShot = true; await capture("3-partie-crawford"); } } else if (!scoreShot) { scoreShot = true; await capture("2-score-du-match"); } if (!reloaded) { // Le match continue après un rechargement de la page. reloaded = true; await page.reload(); await until("partie reprise", () => !document.body.classList.contains("busy")); const again = await shownMatch(); check(again.line === next.line && again.player === next.player && again.computer === next.computer, `après rechargement : ${JSON.stringify(again)} au lieu de ${JSON.stringify(next)}`); } continue; } // Les doubles de l'ordinateur sont passés : le match avance d'un point // à la fois, ce qui rend la partie Crawford probable. if (what === "doubled") { await page.click("#panel-secondary"); } else if (what === "take") { await page.click("#drop"); } else if (what === "skip" || what === "confirm" || what === "roll") { await page.click(`#${what}`); } else if (what === "move") { await page.click(".hit.can-move"); if ((await page.locator(".hit.can-land").count()) > 0) { await page.click(".hit.can-land"); } } } log(`${matches} match(s) en 3 points joués jusqu'au bout : ${crawfordGames} partie(s) Crawford, ` + `${actions} actions`); const csp = await page.evaluate(() => window.__csp); check(csp.length === 0, `violations de la politique de sécurité : ${csp.join(", ")}`); await browser.close(); if (problems.length > 0) { console.error(problems.join("\n")); process.exit(1); } log(`aucune erreur dans la page${full ? ` ; ${captures} captures dans match/` : ""}`);