// 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 // après rechargement, aide, puis hors ligne (service worker). // // Copyright (C) 2026 les auteurs d'Ustagammon. // Licence : GPLv3 ou ultérieure (voir LICENSE). // // Usage : node play.mjs // Lancé par tests/browser.sh, dans l'image de Playwright. Sort en erreur au // premier problème, ou si la page signale une erreur (dont une violation de // la politique de sécurité du contenu). 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 play.mjs "); process.exit(2); } const browser = await browserType.launch(); const context = await browser.newContext({ viewport: { width: 360, height: 740 }, deviceScaleFactor: 2, locale: "fr-FR", }); const page = await context.newPage(); const problems = []; 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()}`); } }); const log = (text) => console.log(`[${browserName}] ${text}`); const shot = (name) => page.screenshot({ path: `${shots}/${browserName}-${name}.png` }); function check(condition, message) { if (!condition) { throw new Error(message); } } /** Les coups joués de chaque camp, d'après le journal enregistré. */ async function savedJournal() { return page.evaluate(() => JSON.parse(localStorage.getItem("ustagammon.current-game"))); } function countMoves(journal, side) { return journal.events.filter((e) => e.type === "MovePlayed" && e.side === side).length; } /** * Attend que le joueur ait quelque chose à faire, et dit quoi. (Pas de * page.waitForFunction : Playwright y passe par eval, que la politique de * sécurité du site interdit, à raison.) */ async function waitForPlayer() { for (let i = 0; i < 600; i++) { const todo = await page.evaluate(() => { if (document.body.classList.contains("busy")) { return null; } const usable = (id) => { const b = document.getElementById(id); return !b.hidden && !b.disabled; }; for (const id of ["again", "take", "roll"]) { if (usable(id)) { return id; } } return document.querySelector(".hit.can-move") ? "move" : null; }); if (todo !== null) { return todo; } await new Promise((resolve) => setTimeout(resolve, 100)); } throw new Error("rien à faire pour le joueur depuis 60 s"); } const start = Date.now(); await page.goto(base); const first = await waitForPlayer(); log(`moteur chargé, partie prête en ${Date.now() - start} ms (${first})`); await shot("1-debut"); let doubled = false; let selectionShot = false; let reloaded = false; let computerMs = []; for (let actions = 0; ; actions++) { check(actions < 800, "la partie ne finit pas"); const todo = await waitForPlayer(); const info = await page.textContent("#info"); const timing = /\((\d+),(\d) s\)/.exec(info ?? ""); if (timing) { computerMs.push(Number(timing[1]) * 1000 + Number(timing[2]) * 100); } if (todo === "again") { break; } if (todo === "take") { log("l'ordinateur double : vous prenez"); await shot("videau-propose"); await page.click("#take"); } else if (todo === "roll") { if (!doubled && (await page.isEnabled("#double"))) { doubled = true; log("vous doublez"); await page.click("#double"); await waitForPlayer(); const answer = (await page.textContent("#info")) || (await page.textContent("#prompt")); log(`réponse : ${answer}`); continue; } await page.click("#roll"); } else { await page.click(".hit.can-move"); await page.waitForSelector(".hit.can-land"); if (!selectionShot) { selectionShot = true; await shot("2-pion-touche"); } await page.click(".hit.can-land"); } // Une fois, au milieu de la partie : fermer et rouvrir la page. const journal = await savedJournal(); if (!reloaded && countMoves(journal, "computer") >= 3 && countMoves(journal, "player") >= 3) { reloaded = true; await waitForPlayer(); await shot("3-milieu-de-partie"); const before = await savedJournal(); const pips = await page.textContent("#pips"); await page.reload(); await waitForPlayer(); const after = await savedJournal(); check(after.head === before.head, "la partie n'a pas été reprise après rechargement"); check((await page.textContent("#pips")) === pips, "plateau différent après rechargement"); log(`partie reprise après rechargement (événement ${after.head})`); } } const journal = await savedJournal(); const result = await page.textContent("#prompt"); log( `partie finie : ${result} (${countMoves(journal, "player")} coups du joueur, ` + `${countMoves(journal, "computer")} de l'ordinateur, ${journal.events.length} événements)`, ); check(countMoves(journal, "player") >= 3 && countMoves(journal, "computer") >= 3, "trop peu de coups"); check(reloaded, "la reprise après rechargement n'a pas été essayée"); if (computerMs.length > 0) { log(`temps affichés des tours de l'ordinateur : max ${Math.max(...computerMs)} ms, ` + `${computerMs.length} tours`); } await shot("4-fin-de-partie"); // Aide : scénario et glossaire lus dans les copies de docs/. await page.click("#help-button"); await page.waitForSelector(".glossary dt"); const terms = await page.locator(".glossary dt").count(); const scenarios = await page.locator("#help-content h3").count(); check(terms >= 20 && scenarios >= 5, `aide incomplète : ${terms} termes, ${scenarios} scénarios`); log(`aide : ${scenarios} scénarios, ${terms} termes du glossaire`); await shot("5-aide"); await page.click("#help-back"); // Thème sombre. await page.emulateMedia({ colorScheme: "dark" }); await page.click("#again"); await waitForPlayer(); await shot("6-sombre"); await page.emulateMedia({ colorScheme: "light" }); // Hors ligne : une fois le cache du service worker rempli, la page se // recharge sans réseau, et le moteur répond. const cached = await page.evaluate(async () => { await navigator.serviceWorker.ready; for (let i = 0; i < 600; i++) { const names = await caches.keys(); if (names.length === 1) { const cache = await caches.open(names[0]); const keys = await cache.keys(); if (keys.some((r) => r.url.endsWith("/wasm/engine.data"))) { return keys.length; } } await new Promise((resolve) => setTimeout(resolve, 100)); } return 0; }); check(cached > 0, "le service worker n'a pas mis le site en cache"); await context.setOffline(true); await page.reload(); const offline = await waitForPlayer(); if (offline === "roll") { await page.click("#roll"); await waitForPlayer(); } await page.click("#help-button"); await page.waitForSelector(".glossary dt"); await page.click("#help-back"); log(`hors ligne : page rechargée depuis le cache (${cached} adresses), moteur et aide disponibles`); await shot("7-hors-ligne"); await browser.close(); if (problems.length > 0) { console.error(problems.join("\n")); process.exit(1); } log("aucune erreur dans la page");