Files
Pierre MARTINandClaude Opus 5.5 984d6cf060 Toucher les dés pour les lancer, le videau pour doubler
Quand vous pouvez lancer, vos dés en attente apparaissent dans la moitié
gauche du plateau ; quand vous pouvez doubler, le videau est souligné.
Les toucher fait comme les boutons, qui restent. Cibles d'au moins
44 px, avec rôle et nom accessibles.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 02:11:08 +01:00

478 lines
20 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",
});
// Pour le diagnostic d'un blocage : la dernière commande envoyée au moteur
// (Web Worker), et si elle a reçu sa réponse ; et, pour vérifier la
// politique de sécurité du contenu, chaque violation signalée par la page.
await context.addInitScript(() => {
window.__engine = { sent: 0, received: 0, last: null };
const post = Worker.prototype.postMessage;
Worker.prototype.postMessage = function (message, ...rest) {
window.__engine.sent++;
window.__engine.last = message?.args ?? null;
if (!this.__counted) {
this.__counted = true;
this.addEventListener("message", () => window.__engine.received++);
}
return post.call(this, message, ...rest);
};
window.__csp = [];
document.addEventListener("securitypolicyviolation", (e) =>
window.__csp.push(`${e.effectiveDirective} ${e.blockedURI}`),
);
});
const page = await context.newPage();
const problems = [];
page.on("pageerror", (error) => problems.push(`erreur de la page : ${error.message}`));
page.on("crash", () => {
console.error(`[${browserName}] la page a planté`);
process.exit(1);
});
// Vrai pendant le contrôle final de la politique de sécurité, qui provoque
// exprès une violation.
let cspProbe = false;
page.on("console", (message) => {
if (message.type() === "error" && !(cspProbe && /Content.Security.Policy/i.test(message.text()))) {
problems.push(`console : ${message.text()}`);
}
});
const log = (text) => console.log(`[${browserName}] ${text}`);
const shot = (name) => page.screenshot({ path: `${shots}/${browserName}-${name}.png` });
/** Relève les violations de la CSP signalées par la page (avant chaque
* rechargement, qui les efface). */
const violations = [];
async function collectViolations() {
violations.push(...(await page.evaluate(() => window.__csp)));
}
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", "skip", "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));
}
await shot("bloque").catch(() => {});
const screen = await page
.evaluate(() => {
const text = (id) => document.getElementById(id)?.textContent;
const busy = document.body.classList.contains("busy") ? "occupé" : "libre";
const engine = window.__engine;
return (
`${busy} ; « ${text("info")} » ; « ${text("prompt")} » ; moteur : ` +
`${engine.sent} commandes, ${engine.received} réponses, dernière ` +
`${JSON.stringify(engine.last)} ; journal ` +
localStorage.getItem("ustagammon.current-game")
);
})
.catch((error) => `page illisible (${error.message})`);
throw new Error(`rien à faire pour le joueur depuis 60 s : ${screen}`);
}
/** Le dernier coup de l'ordinateur, s'il est la dernière chose jouée (fin de
* partie mise à part) et qu'il a déplacé des pions ; sinon null. */
function computerMoveToShow(journal) {
const last = journal.events.filter((e) => e.type !== "GameEnded").at(-1);
return last?.type === "MovePlayed" && last.side === "computer" && last.halfMoves.length > 0
? last
: null;
}
/** Ce que montre le plateau du coup de l'ordinateur. */
async function shownTraces() {
return page.evaluate(() => ({
arrows: document.querySelectorAll(".trace-arrow").length,
landed: document.querySelectorAll(".trace-landed").length,
ghosts: document.querySelectorAll(".trace-ghost").length,
hits: document.querySelectorAll(".trace-hit").length,
replay: !document.getElementById("replay").hidden,
}));
}
/** Après un tour de l'ordinateur : ses traces sont là si son coup est la
* dernière chose jouée, et rien sinon. */
async function checkTraces() {
const journal = await savedJournal();
const move = computerMoveToShow(journal);
const traces = await shownTraces();
const last = journal.events.filter((e) => e.type !== "GameEnded").at(-1);
if (last?.type === "MovePlayed" && last.side === "computer" && last.halfMoves.length === 0) {
// L'ordinateur n'a pas pu jouer : son jet reste barré, avec un message.
const info = await page.textContent("#info");
const blocked = await page.locator(".die-blocked").count();
check(/^L'ordinateur ne peut pas jouer \d-\d\.$/.test(info ?? "") && blocked === 2,
`jet de l'ordinateur sans coup mal montré : « ${info} », ${blocked} dés barrés`);
log(`l'ordinateur ne peut pas jouer : « ${info} »`);
}
if (move === null) {
check(traces.arrows + traces.landed + traces.ghosts === 0 && !traces.replay,
`traces sans coup de l'ordinateur à montrer : ${JSON.stringify(traces)}`);
return null;
}
check(traces.arrows === move.halfMoves.length && traces.landed > 0 && traces.ghosts > 0 &&
traces.replay, `coup de l'ordinateur mal montré : ${JSON.stringify(traces)}`);
return traces;
}
/** La zone à toucher d'une action du plateau (dés ou videau) : un bouton
* accessible, d'au moins 44 px de côté. Renvoie son sélecteur. */
async function touchTarget(action, label) {
const selector = `[data-action="${action}"]`;
const zone = page.locator(selector);
check((await zone.count()) === 1, `zone ${action} absente du plateau`);
check((await zone.getAttribute("role")) === "button" &&
(await zone.getAttribute("aria-label")) === label, `zone ${action} sans rôle accessible`);
const box = await zone.boundingBox();
check(box.width >= 44 && box.height >= 44,
`zone ${action} trop petite : ${Math.round(box.width)} x ${Math.round(box.height)} px`);
return selector;
}
/** Observe la page pendant ms millisecondes : le plus grand nombre
* d'animations en cours à la fois, et le plus petit nombre de flèches. */
async function watchReplay(ms) {
let animations = 0;
let arrows = Infinity;
for (const end = Date.now() + ms; Date.now() < end; ) {
const seen = await page.evaluate(() => ({
animations: document.getAnimations().length,
arrows: document.querySelectorAll(".trace-arrow").length,
}));
animations = Math.max(animations, seen.animations);
arrows = Math.min(arrows, seen.arrows);
await new Promise((resolve) => setTimeout(resolve, 40));
}
return { animations, arrows };
}
/** « Revoir » : rejoue le coup (sans animation en mouvement réduit), puis
* remet les mêmes traces. */
async function checkReplay(traces, reducedMotion) {
await page.emulateMedia({ reducedMotion: reducedMotion ? "reduce" : "no-preference" });
await page.click("#replay");
const seen = await watchReplay(300);
if (reducedMotion) {
check(seen.animations === 0, "Revoir en mouvement réduit : des pions glissent quand même");
} else {
check(seen.animations > 0 && seen.arrows === 0,
`Revoir : pas d'animation sans traces (${JSON.stringify(seen)})`);
}
await waitForPlayer();
const after = await shownTraces();
check(JSON.stringify(after) === JSON.stringify(traces),
`traces différentes après Revoir : ${JSON.stringify(after)}`);
await page.emulateMedia({ reducedMotion: "no-preference" });
}
// Garde-fou : une étape bloquée plus de deux minutes arrête tout, avec une
// capture et l'état de l'écran.
let lastStep = Date.now();
let stepName = "chargement";
function step(name) {
lastStep = Date.now();
stepName = name;
}
const watchdog = setInterval(async () => {
if (Date.now() - lastStep > 120_000) {
clearInterval(watchdog);
console.error(`[${browserName}] bloqué depuis 2 minutes (étape : ${stepName})`);
await shot("bloque").catch(() => {});
process.exit(1);
}
}, 5_000);
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 = [];
let tracesSeen = 0;
let replays = 0;
let skips = 0;
let rolls = 0;
for (let actions = 0; ; actions++) {
check(actions < 800, "la partie ne finit pas");
const todo = await waitForPlayer();
step(`partie, action ${actions}`);
// Le coup de l'ordinateur est montré jusqu'à l'action suivante du
// joueur ; deux fois, on le revoit (la seconde en mouvement réduit).
const traces = await checkTraces();
if (traces !== null) {
tracesSeen++;
if (replays < 2) {
replays++;
await checkReplay(traces, replays === 2);
if (replays === 1) {
await shot("coup-adverse");
}
log(`coup de l'ordinateur revu${replays === 2 ? " en mouvement réduit" : ""} : ` +
`${traces.arrows} flèches, ${traces.landed} pions entourés, ` +
`${traces.ghosts} fantômes, ${traces.hits} pions frappés signalés`);
}
}
if (process.env.TRACE) {
log(`${todo} : ${await page.textContent("#info")} / ${await page.textContent("#prompt")}`);
}
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 === "skip") {
// Aucun coup possible : le jet reste affiché, barré, avec un
// message, et le tour ne passe qu'en touchant « Continuer ».
const info = await page.textContent("#info");
const blocked = await page.locator(".die-blocked").count();
check(/^Aucun coup possible avec \d-\d\.$/.test(info ?? "") && blocked === 2,
`aucun coup possible mal montré : « ${info} », ${blocked} dés barrés`);
skips++;
log(`aucun coup possible : « ${info} », vous continuez`);
await page.click("#skip");
} else 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 en touchant le videau");
await page.click(await touchTarget("double", "Doubler"));
await waitForPlayer();
const answer = (await page.textContent("#info")) || (await page.textContent("#prompt"));
log(`réponse : ${answer}`);
continue;
}
// Une fois sur deux, en touchant les dés en attente plutôt que le
// bouton.
rolls++;
await page.click(rolls % 2 === 0 ? await touchTarget("roll", "Lancer les dés") : "#roll");
} else {
check((await page.locator("[data-action]").count()) === 0,
"dés ou videau touchables pendant que vous jouez votre coup");
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 collectViolations();
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");
check(replays === 2, "le coup de l'ordinateur n'a pas été revu");
check(doubled && rolls >= 2, "dés ou videau jamais touchés sur le plateau");
log(`dés lancés ${Math.floor(rolls / 2)} fois en les touchant, videau touché pour doubler`);
log(`coup de l'ordinateur montré ${tracesSeen} fois, et effacé à chaque action suivante`);
log(skips > 0 ? `« Continuer » demandé ${skips} fois` : "aucun jet sans coup possible dans cette partie");
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");
step("aide");
// 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");
step("thème sombre");
// Thème sombre.
await page.emulateMedia({ colorScheme: "dark" });
await page.click("#again");
await waitForPlayer();
await shot("6-sombre");
await page.emulateMedia({ colorScheme: "light" });
step("paysage");
// Téléphone tourné : le plateau et les boutons tiennent dans l'écran.
for (const [width, height] of [[915, 412], [740, 360]]) {
await page.setViewportSize({ width, height });
const board = await page.locator("#board").boundingBox();
const actions = await page.locator(".actions").boundingBox();
check(board.y + board.height <= height && board.x + board.width <= width,
`paysage ${width}x${height} : plateau hors de l'écran`);
check(actions.y + actions.height <= height, `paysage ${width}x${height} : boutons hors de l'écran`);
}
log("paysage : plateau et boutons visibles en entier");
await shot("6-paysage");
await page.setViewportSize({ width: 360, height: 740 });
step("mise en cache");
// 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 () => {
// Le service worker est enregistré une fois le moteur chargé ; s'il ne
// s'installe pas, l'attente s'arrête au bout d'une minute.
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();
const active = (await navigator.serviceWorker.getRegistration())?.active;
if (active && 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");
// Firefox sous Playwright refuse toute navigation une fois le contexte
// « hors ligne », même servie par le service worker (NS_ERROR_OFFLINE) : on
// y vérifie seulement que la page rechargée est contrôlée par le service
// worker, dont le cache est complet.
const offlineMode = browserName === "chromium";
if (offlineMode) {
await context.setOffline(true);
}
step("rechargement hors ligne");
await collectViolations();
await page.reload();
const controlled = await page.evaluate(() => navigator.serviceWorker.controller !== null);
check(controlled, "la page rechargée n'est pas contrôlée par le service worker");
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(
offlineMode
? `hors ligne : page rechargée sans réseau depuis le cache (${cached} adresses), moteur et aide disponibles`
: `cache du service worker rempli (${cached} adresses), page rechargée sous son contrôle ` +
"(le hors ligne lui-même n'est pas simulable dans Firefox sous Playwright)",
);
await shot("7-hors-ligne");
step("CSP");
// Politique de sécurité du contenu : aucune violation pendant toute la
// partie (moteur WebAssembly, Web Worker, service worker, aide compris) ; et
// elle est bien appliquée : un script en ligne ajouté à la page est bloqué.
await collectViolations();
check(violations.length === 0, `violations de la CSP : ${violations.join(", ")}`);
cspProbe = true;
const probe = await page.evaluate(async () => {
const script = document.createElement("script");
script.textContent = "window.__inline = true;";
document.head.append(script);
await new Promise((resolve) => setTimeout(resolve, 300));
return { ran: window.__inline === true, violations: window.__csp };
});
check(!probe.ran, "CSP non appliquée : un script en ligne a été exécuté");
check(
probe.violations.some((v) => v.startsWith("script-src")),
"CSP : le script en ligne bloqué n'a pas été signalé",
);
log("CSP : aucune violation pendant la partie, script en ligne bloqué");
clearInterval(watchdog);
await browser.close();
if (problems.length > 0) {
console.error(problems.join("\n"));
process.exit(1);
}
log("aucune erreur dans la page");