L'application est à la racine du site : plateau en SVG, saisie au toucher (pion puis case d'arrivée, proposées à partir du moteur), boutons du jet, du videau et de nouvelle partie, coup de l'ordinateur affiché avec son temps, fin de partie, reprise depuis localStorage, aide lue dans les copies de docs/. Le moteur tourne dans le Web Worker existant ; main.ts est le seul assembleur (nouvelles règles dependency-cruiser). Le service worker met tout le site en cache, avec une empreinte du contenu comme version. Les pages de test du moteur passent sous /moteur/. Politique de sécurité du contenu dans le Caddyfile ; make test-browser joue une partie dans Chromium et Firefox sans écran (Playwright, hors de make test). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
216 lines
7.8 KiB
JavaScript
216 lines
7.8 KiB
JavaScript
// 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 <adresse du site> <chromium|firefox> <dossier des captures>
|
|
// 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 <adresse> <chromium|firefox> <dossier des captures>");
|
|
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");
|