// bilan.mjs -- le bilan de fin de partie et l'écran d'analyse (étape 1e) // joués par l'interface, dans un vrai navigateur sans écran (Playwright), // sur le site servi par l'image de production (charte, // ecrans-etape-1.md, « 1e. Bilan et analyse »). // // Copyright (C) 2026 les auteurs d'Ustagammon. // Licence : GPLv3 ou ultérieure (voir LICENSE). // // Usage : node bilan.mjs // Lancé par tests/browser.sh. Une fin de course courte, partie depuis un // lien de position avec des dés imposés : le joueur a 6-1 et deux pions, // sur 6 et 1 ; 6/off 1/off gagne, 6/5/off perd (l'ordinateur sortira ses // deux derniers pions au coup suivant : il double, et le joueur passe). Le // joueur joue 6/5/off. Vérifie que le // bilan remplace le panneau de fin de partie et nomme ce coup comme le coup // à revoir, que l'analyse s'ouvre par son adresse (courbe, point touché, // liste filtrée, visualiseur), que « Rejouer d'ici » crée une branche, la // règle de la recette (rien d'interactif sous une barre d'action), et // l'absence d'erreur et de violation de la politique de sécurité. Captures // (Chromium) dans /bilan/, en 412×915, 915×412 et // 844×320. import { mkdirSync } from "node:fs"; import { chromium, firefox } from "playwright"; import { hiddenUnderActionBar, tallButtons } from "./recette.mjs"; const [base, browserName, shots] = process.argv.slice(2); const browserType = { chromium, firefox }[browserName]; if (!base || !browserType || !shots) { console.error("usage : node bilan.mjs "); process.exit(2); } const full = browserName === "chromium"; const dir = `${shots}/bilan`; mkdirSync(dir, { recursive: true }); const PORTRAIT = { width: 412, height: 915 }; const LANDSCAPE = { width: 915, height: 412 }; const SMALL_LANDSCAPE = { width: 844, height: 320 }; const SIZES = [PORTRAIT, LANDSCAPE, SMALL_LANDSCAPE]; // Le joueur au trait : un pion sur 6, un sur 1 ; l'ordinateur : un sur 1, // un sur 2 (positions de gnubg, chacun vu de lui-même). const BEAROFF = "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", // Sans animation : les captures montrent les coups posés, avec leurs // traces. reducedMotion: "reduce", }); await context.addInitScript(() => { // Les dés imposés d'abord (un dé par valeur), puis une graine fixe. window.__dice = []; let state = 5; 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}`), ); }); 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 = 120_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}`); } const idle = () => until("écran libre", () => !document.body.classList.contains("busy")); /** Une capture de la recette dans les trois tailles (Chromium seulement), * après la règle commune : rien d'interactif sous une barre d'action. */ async function capture(name) { if (!full) { return; } await until("bandeau parti", () => document.getElementById("banner").hidden, null, 15_000); for (const size of SIZES) { await page.setViewportSize(size); await sleep(300); const hidden = await hiddenUnderActionBar(page); check( hidden.length === 0, `${name} ${size.width}x${size.height} : caché sous la barre d'action : ${hidden.join(", ")}`, ); const tall = await tallButtons(page); check(tall.length === 0, `${name} ${size.width}x${size.height} : boutons étirés : ${tall.join(", ")}`); await page.screenshot({ path: `${dir}/${name}-${size.width}x${size.height}.png` }); captures++; } await page.setViewportSize(PORTRAIT); await sleep(300); } /** Une nouvelle partie à partir d'une position, joueur au trait. */ async function startFrom(position) { 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, ); } /** Une case du plateau de jeu : « off » pour la sortie du joueur. */ const place = (point) => (point === 0 ? "off-player" : `p${point}`); /** Joue un coup déjà lancé, demi-coup par demi-coup, puis le valide. */ async function playMove(halfMoves) { for (const [from, to] of halfMoves) { await page.click(`#board .hit[data-place="${place(from)}"]`); await idle(); const land = page.locator(`#board .hit.can-land[data-place="${place(to)}"]`); if ((await land.count()) > 0) { await land.click(); await idle(); } } check(!(await page.locator("#confirm").isHidden()), `coup incomplet : ${JSON.stringify(halfMoves)}`); await page.click("#confirm"); } /** Lance les dés imposés et joue un coup. */ async function play(dice, halfMoves) { await page.evaluate((d) => { window.__dice.push(...d); }, dice); await page.click("#roll"); await idle(); await playMove(halfMoves); } // --------------------------------------------- Une partie, puis son bilan await startFrom(BEAROFF); await play([6, 1], [[6, 5], [5, 0]]); // L'ordinateur, sûr de gagner, double. La ligne du maître commente le // coup : sa feuille d'explication écrit le coup joué dans la notation du // moteur, comme le bilan et l'analyse plus bas. await until("double de l'ordinateur", () => !document.getElementById("drop").hidden); await until("ligne du maître", () => !document.getElementById("advice-line").hidden); await page.click("#advice-line"); await until("feuille du maître", () => document.getElementById("explain-mine") !== null); await page.click("#explain-mine"); const sheetLegend = await page.textContent("#explain-legend"); check(sheetLegend.includes(" 6/off,"), `feuille du maître : ${sheetLegend}`); // Le coup y est un lien vers la fiche « Notation des coups ». const notationLink = await page.evaluate(() => { const a = document.querySelector("#explain-legend a.notation"); return a === null ? null : { text: a.textContent, href: a.getAttribute("href") }; }); check( notationLink?.text === "6/off" && notationLink.href === "#/glossaire/notation-des-coups", `feuille du maître, lien de la notation : ${JSON.stringify(notationLink)}`, ); await page.click("#explain-done"); await until("feuille fermée", () => document.getElementById("explain-veil").hidden); // Le joueur passe. await page.click("#drop"); // Le panneau de fin de partie, puis le bilan à sa place, à son adresse. await until( "bilan prêt", () => /^#\/parties\/[a-z0-9-]+\/bilan$/.test(location.hash) && document.getElementById("summary")?.dataset.ready === "true", ); await until("mini-plateau du coup à revoir", () => document.querySelector("#summary-board .board-frame") !== null, ); const summary = await page.evaluate(() => ({ hash: location.hash, panelHidden: document.getElementById("panel-veil").hidden, title: document.getElementById("summary-title").textContent, reading: document.getElementById("summary-reading").textContent, counts: [...document.querySelectorAll(".summary-count .summary-number")].map( (n) => n.textContent, ), verdict: document.querySelector(".summary-verdict")?.textContent ?? "", played: document.querySelector(".summary-played")?.textContent ?? "", master: document.querySelector(".summary-master")?.textContent ?? "", board: document.querySelector("#summary-board .board-frame") !== null, play: document.querySelector("#gauge-play .gauge-value")?.textContent ?? "", dice: document.querySelector("#gauge-dice .gauge-value")?.textContent ?? "", slot: document.querySelector("#summary-review .ask-ai-slot") !== null, })); const id = summary.hash.split("/")[2]; check(summary.panelHidden, "le panneau de fin de partie reste sous le bilan"); check(summary.title === "L'ordinateur gagne la partie simple", `titre : ${summary.title}`); check( summary.reading.startsWith("Vous avez passé son double : 1 point pour lui. "), `lecture : ${summary.reading}`, ); check(summary.reading.includes("D'après mon analyse"), `lecture : ${summary.reading}`); check(!summary.reading.includes("!"), `lecture : ${summary.reading}`); // Le refus du double, juste ; le coup 6/5/off, grosse erreur ; le jet // sans doubler, compté seulement s'il était une faute. check( summary.counts[0] === "1" && summary.counts[2] !== "0", `décisions : ${summary.counts.join()}`, ); check(summary.verdict.startsWith("Grosse erreur"), `coup à revoir : ${summary.verdict}`); check(summary.played === "Votre coup : 6/off", `coup joué : ${summary.played}`); check(summary.master === "J'aurais joué 6/off 1/off", `coup du maître : ${summary.master}`); check(summary.board, "pas de mini-plateau dans le coup à revoir"); check(summary.play !== "" && summary.dice !== "", "jauges vides"); check(summary.slot, "pas d'emplacement pour « Demander à une IA »"); await capture("1-bilan"); log(`bilan : « ${summary.reading} » ; coup à revoir « ${summary.played} » contre « ${summary.master} »`); // Le bilan se recharge par son adresse. await page.reload(); await until( "bilan rouvert", () => document.getElementById("summary")?.dataset.ready === "true", ); // ------------------------------------------------------- Écran d'analyse await page.click("#summary-revisit"); await until( "analyse ouverte sur le coup à revoir", (game) => location.hash === `#/parties/${game}/analyse/1` && !document.getElementById("analysis").hidden && document.querySelector("#analysis-board .board-frame") !== null && document.querySelectorAll("#analysis-curve .curve-band").length === 2 && document.querySelector("#analysis-curve .curve-line") !== null, id, ); const viewer = await page.evaluate(() => ({ turn: document.getElementById("analysis-turn").textContent, phrase: document.getElementById("analysis-phrase").textContent, mine: document.getElementById("analysis-mine").getAttribute("aria-checked"), choice: !document.getElementById("analysis-choice").hidden, replay: !document.getElementById("analysis-replay").hidden, slot: document.querySelector("#analysis-viewer .ask-ai-slot") !== null, })); check(viewer.turn === "Coup 1 sur 2 · 6-1", `visualiseur : ${viewer.turn}`); check(viewer.phrase.startsWith("Grosse erreur."), `phrase : ${viewer.phrase}`); check(viewer.choice && viewer.mine === "true", "votre coup n'est pas montré d'abord"); check(viewer.replay && viewer.slot, "« Rejouer d'ici » ou l'emplacement de l'IA manquent"); await capture("2-analyse-courbe"); // Toucher un point de la courbe : la bande du coup 2 (le double de // l'ordinateur, que le joueur a passé). const band = await page.locator('#analysis-curve .curve-band[data-turn="2"]').boundingBox(); await page.mouse.click(band.x + band.width / 2, band.y + band.height / 3); await until( "coup 2 choisi", (game) => location.hash === `#/parties/${game}/analyse/2` && document.getElementById("analysis-turn").textContent.startsWith("Coup 2 sur 2"), id, ); const touched = await page.evaluate(() => ({ phrase: document.getElementById("analysis-phrase").textContent, ring: document.querySelector("#analysis-curve .curve-ring") !== null, replay: document.getElementById("analysis-replay").hidden, })); check(touched.phrase.startsWith("Bonne décision."), `coup 2 : ${touched.phrase}`); check(touched.ring, "le point choisi n'est pas cerclé"); check(!touched.replay, "pas de « Rejouer d'ici » sur une décision du joueur"); await capture("3-point-touche"); // Au clavier, ← revient au coup 1. await page.focus("#analysis-curve"); await page.keyboard.press("ArrowLeft"); await until("coup 1 au clavier", (game) => location.hash === `#/parties/${game}/analyse/1`, id); // La liste, filtrée : une erreur, aucune imprécision. await page.click('#analysis-filters .filter[data-filter="errors"]'); const errors = await page.evaluate(() => ({ rows: [...document.querySelectorAll("#analysis-list .analysis-row")].map((r) => r.getAttribute("aria-label"), ), filters: [...document.querySelectorAll("#analysis-filters .filter")].map((f) => f.textContent), })); check(errors.rows.length === 1 && errors.rows[0].includes("Grosse erreur"), `liste : ${errors.rows}`); check(errors.filters.join() === "Tout · 2,Erreurs · 1,Imprécisions · 0", `filtres : ${errors.filters}`); await page.locator("#analysis-list").scrollIntoViewIfNeeded(); await capture("4-liste-filtree"); await page.click('#analysis-filters .filter[data-filter="doubtful"]'); check( (await page.locator("#analysis-list .analysis-row").count()) === 0, "une imprécision dans la liste", ); await page.click('#analysis-filters .filter[data-filter="errors"]'); await page.click("#analysis-list .analysis-row"); // Le visualiseur : le coup du maître sur la position d'avant le coup. await page.locator("#analysis-viewer").scrollIntoViewIfNeeded(); await page.click("#analysis-best"); const best = await page.evaluate(() => ({ legend: document.getElementById("analysis-legend").textContent, move: document.getElementById("analysis-board").dataset.move, })); check(best.move === "best" && best.legend.includes("6/off 1/off"), `maître : ${best.legend}`); // Une seule notation, celle du moteur : le même coup s'écrit pareil dans // la feuille du maître, le bilan, la liste et le visualiseur. await page.click("#analysis-mine"); const notations = await page.evaluate(() => ({ legend: document.getElementById("analysis-legend").textContent, row: document.querySelector('#analysis-list .analysis-row[data-turn="1"] .row-move') ?.textContent, })); check( notations.legend.includes(" 6/off,") && notations.row === "6/off", `notation : visualiseur « ${notations.legend} », liste « ${notations.row} »`, ); await page.click("#analysis-best"); await capture("5-visualiseur"); log("analyse : courbe, point touché, clavier, liste filtrée, visualiseur"); // L'analyse s'ouvre aussi par son adresse, dans un nouvel onglet. const other = await context.newPage(); await other.goto(`${base}#/parties/${id}/analyse/2`); for (let waited = 0; ; waited += 100) { const ready = await other.evaluate( () => !document.getElementById("analysis").hidden && document.getElementById("analysis-turn")?.textContent.startsWith("Coup 2 sur 2"), ); if (ready) { break; } check(waited < 60_000, "l'analyse ne s'ouvre pas par son adresse"); await sleep(100); } await other.close(); // ------------------------------------------------------- Rejouer d'ici await page.click("#analysis-replay"); await until( "partie reprise avant le coup", () => location.hash === "#/" && !document.body.classList.contains("busy") && document.querySelector("#board .hit.can-move") !== null, ); await capture("6-rejouer-d-ici"); // Le pion du point 1 d'abord : chacun n'a alors qu'une arrivée, la sortie. await playMove([ [1, 0], [6, 0], ]); await until( "partie gagnée, son bilan", () => /\/bilan$/.test(location.hash) && document.getElementById("summary")?.dataset.ready === "true", ); const won = await page.evaluate(() => ({ title: document.getElementById("summary-title").textContent, none: document.querySelector(".summary-none")?.textContent ?? "", })); check(won.title === "Vous gagnez la partie simple", `branche : ${won.title}`); check(won.none.startsWith("Aucun coup à revoir"), `branche : ${won.none}`); // Deux branches dans « Mes parties », l'originale intacte. await page.goto(`${base}#/parties/${id}`); await until( "branches dépliées", () => document.querySelectorAll(".games-branch").length > 0, ); const branches = await page.evaluate(() => [...document.querySelectorAll(".games-branch span")].map((s) => s.textContent), ); check(branches.length === 2, `branches : ${branches.join(" | ")}`); check(branches[0].startsWith("Partie d'origine : 1 coup, finie"), `origine : ${branches[0]}`); log(`rejouer d'ici : ${branches.join(" | ")}`); // ------------------------------------------- Une partie plus longue // Pour juger la courbe en usage réel (Chromium seulement) : une longue // course (tests/reference/positions.json, race-2) jouée jusqu'au bout, // le joueur prenant à chaque fois le premier pion jouable et sa première // arrivée, puis son analyse complète. if (full) { await startFrom("cLezAQBwN2cGAA"); for (let actions = 0; ; actions++) { check(actions < 2000, "la longue partie ne finit pas"); const what = await page.evaluate(() => { if (document.body.classList.contains("busy")) { return null; } if (!document.getElementById("summary-veil").hidden) { return "over"; } if (!document.getElementById("panel-veil").hidden) { return "panel"; } 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; } } if (document.querySelector("#board .hit.can-land")) { return "land"; } return document.querySelector("#board .hit.can-move") ? "move" : null; }); if (what === "over") { break; } if (what === null || what === "panel") { await sleep(100); continue; } const target = { take: "#take", skip: "#skip", confirm: "#confirm", roll: "#roll", land: "#board .hit.can-land", move: "#board .hit.can-move", }[what]; await page.locator(target).first().click(); await idle(); } await until("bilan de la longue partie", () => document.getElementById("summary")?.dataset.ready === "true"); await page.click("#summary-analysis"); await until("analyse complète de la longue partie", () => { const bands = document.querySelectorAll("#analysis-curve .curve-band").length; return ( !document.getElementById("analysis").hidden && bands >= 20 && document.querySelectorAll("#analysis-curve .curve-dot").length === bands ); }, null, 600_000); const turns = await page.locator("#analysis-curve .curve-band").count(); await page.evaluate(() => window.scrollTo(0, 0)); await capture("7-analyse-partie-longue"); log(`longue partie : ${turns} coups, courbe complète`); } 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(`bilan et analyse : tout est conforme (${captures} captures)`);