diff --git a/app/tests/steps/revoir-ma-partie.steps.ts b/app/tests/steps/revoir-ma-partie.steps.ts new file mode 100644 index 0000000..d1a91a8 --- /dev/null +++ b/app/tests/steps/revoir-ma-partie.steps.ts @@ -0,0 +1,226 @@ +// Étapes du scénario docs/scenarios/revoir-ma-partie.feature (ADR 0008) : +// la partie se joue par le domaine sur le vrai moteur, avec des dés +// imposés ; son analyse passe par l'analyse en arrière-plan de l'interface +// (review-runner.ts, sans DOM), le bilan par le domaine +// (game-analysis.ts) et les phrases du maître, et l'ancienne partie par le +// stockage des parties sur un localStorage simulé. +// +// Copyright (C) 2026 les auteurs d'Ustagammon. +// Licence : GPLv3 ou ultérieure (voir LICENSE). + +import assert from "node:assert/strict"; +import { BeforeAll, Given, setDefaultTimeout, Then, When } from "@cucumber/cucumber"; +import { + chanceCurve, + gameTurns, + missingAnalyses, + readyForSummary, + replayPoint, + runAnalysis, + summarizeAnalysis, +} from "../../src/domain/analysis/game-analysis.js"; +import { diceWords, summaryReading } from "../../src/domain/analysis/phrases.js"; +import type { Engine } from "../../src/domain/game/engine.js"; +import type { GameJournal } from "../../src/domain/game/events.js"; +import { Game } from "../../src/domain/game/game.js"; +import { parseHalfMoves } from "../../src/domain/game/position.js"; +import { fixedDice } from "../../src/domain/game/roll.js"; +import { memoryStorage, openGameLibrary } from "../../src/storage/open-library.js"; +import { createReviewRunner } from "../../src/ui/review-runner.js"; +import { loadPhrases } from "../support/advice.js"; +import { loadNodeEngine } from "../support/node-engine.js"; + +setDefaultTimeout(60_000); + +/** Le joueur au trait : un pion sur son point 6, un sur son point 1 ; + * l'ordinateur : un sur son point 1, un sur son point 2 (Position ID de + * gnubg, chacun vu de lui-même). */ +const BEAROFF = "BQAACAIAAAAAAA"; + +const phrases = loadPhrases(); +let engine: Engine; +let game: Game; +let dice: number[]; +/** Le journal analysé de la partie. */ +let analysed: GameJournal; +/** La partie d'origine, avant « Rejouer d'ici ». */ +let original: { head: string; events: number }; +/** Les analyses rangées par l'analyse en arrière-plan. */ +let recorded: number; + +BeforeAll(async () => { + engine = await loadNodeEngine(); +}); + +/** Analyse ce qui manque au journal, comme l'application en + * arrière-plan. */ +async function analyse(journal: GameJournal): Promise { + const runner = createReviewRunner((j, task) => runAnalysis(engine, j, task)); + recorded = 0; + let latest = journal; + await new Promise((resolve) => { + runner.start( + "partie", + journal, + (j) => { + latest = j; + recorded++; + }, + resolve, + ); + }); + return latest; +} + +async function playMove(move: string): Promise { + for (const halfMove of parseHalfMoves(move)) { + assert.ok(await game.playHalfMove(halfMove), `demi-coup refusé : ${move}`); + } + await game.confirmMove(); +} + +Given( + "il vous reste deux pions, sur vos points 6 et 1, et à l'ordinateur deux pions sur ses points 1 et 2", + () => { + dice = []; + // Les dés sont tirés au moment voulu : ceux qu'impose le scénario. + game = Game.start(engine, { + setup: { position: BEAROFF, firstTurn: "player" }, + level: "2ply", + dice: () => { + const die = dice.shift(); + if (die === undefined) { + throw new Error("plus de dés imposés"); + } + return die; + }, + }); + }, +); + +Given("vous avez {int}-{int} à jouer", async (a: number, b: number) => { + dice.push(a, b); + await game.roll(); + assert.deepEqual(game.state.roll, [a, b]); +}); + +When(/^vous choisissez (?:cette fois )?le coup (.+)$/, async (move: string) => { + await playMove(move); +}); + +When("l'ordinateur sort ses deux pions avec {int}-{int}", async (a: number, b: number) => { + dice.push(a, b); + await game.playComputerTurn(); + assert.equal(game.state.phase, "over", "l'ordinateur a doublé au lieu de jouer"); + analysed = await analyse(game.journal); +}); + +Then("la partie est finie, gagnée par l'ordinateur", () => { + assert.equal(game.state.result?.winner, "computer"); +}); + +Then("le bilan compte une grosse erreur", () => { + assert.ok(readyForSummary(analysed)); + assert.equal(summarizeAnalysis(analysed).counts.veryBad, 1); +}); + +Then("sa phrase dit ce qui a fait la différence, et comment étaient vos dés", () => { + const summary = summarizeAnalysis(analysed); + const reading = summaryReading(phrases, summary); + assert.ok(reading.startsWith("D'après mon analyse, "), reading); + assert.ok(!reading.includes("!"), reading); + // Perdue : le jeu de l'ordinateur a fait la différence, si le résultat + // corrigé de la chance reste négatif ; sinon les dés. + if (summary.luckAdjustedResult < 0) { + assert.ok(reading.includes(diceWords(phrases, summary.player.luck.rating)), reading); + } else { + assert.ok(reading.includes("les dés ont fait la différence"), reading); + } +}); + +Then("le coup à revoir est votre coup {}, le premier de la partie", (move: string) => { + const toReview = summarizeAnalysis(analysed).toReview; + assert.equal(toReview?.turn, 1); + assert.equal(toReview?.advice.kind, "move"); + assert.equal(toReview?.advice.kind === "move" && toReview.advice.played.notation, move); +}); + +Then("le maître y aurait joué {}", (move: string) => { + const advice = summarizeAnalysis(analysed).toReview?.advice; + assert.equal(advice?.kind === "move" && advice.best.notation, move); + assert.equal(advice?.skill, "veryBad"); +}); + +Then("la courbe de vos chances de gagner finit à {int} %", (percent: number) => { + const curve = chanceCurve(analysed); + assert.equal(curve.length, gameTurns(analysed).length); + assert.equal(curve.at(-1)?.chance, percent / 100); + assert.ok(curve.every((p) => p.chance !== null && p.chance >= 0 && p.chance <= 1)); +}); + +When("vous rejouez d'ici le coup à revoir", () => { + game.recordAnalyses(analysed); + original = { head: game.journal.head ?? "", events: game.events.length }; + const toReview = summarizeAnalysis(game.journal).toReview; + const turn = gameTurns(game.journal)[(toReview?.turn ?? 0) - 1]; + assert.ok(turn !== undefined); + const point = replayPoint(game.journal, turn); + assert.ok(point !== null); + game.goBackTo(point); +}); + +Then("vous avez de nouveau {int}-{int} à jouer", (a: number, b: number) => { + assert.equal(game.state.phase, "moving"); + assert.equal(game.state.turn, "player"); + assert.deepEqual(game.state.roll, [a, b]); +}); + +Then("la partie est finie, gagnée par vous", () => { + assert.equal(game.state.result?.winner, "player"); +}); + +Then("la partie a deux branches, dont la partie d'origine, intacte", () => { + const branches = game.branches; + assert.equal(branches.length, 2); + const origin = branches.find((b) => b.forkedFrom === null); + assert.equal(origin?.head, original.head); + assert.ok(origin?.over); + // L'analyse de la partie d'origine reste attachée à ses événements. + assert.ok(game.journal.review !== undefined); +}); + +Given("une partie finie enregistrée sans aucune analyse", async () => { + dice = [6, 1, 2, 1]; + game = Game.start(engine, { + setup: { position: BEAROFF, firstTurn: "player" }, + level: "2ply", + dice: fixedDice(dice), + }); + await game.roll(); + await playMove("6/5 5/off"); + await game.playComputerTurn(); + assert.equal(game.state.phase, "over"); + const { library } = await openGameLibrary({ localStorage: memoryStorage() }); + await library.save("ancienne", game.journal, { level: "2ply" }); + const saved = await library.load("ancienne"); + assert.ok(saved !== null); + analysed = saved.journal; + assert.equal(analysed.advice, undefined); + assert.equal(analysed.review, undefined); +}); + +When("vous ouvrez son bilan", () => { + assert.ok(!readyForSummary(analysed)); +}); + +Then("le maître l'analyse d'abord en arrière-plan", async () => { + const before = missingAnalyses(analysed).length; + analysed = await analyse(analysed); + assert.equal(recorded, before); + assert.ok(readyForSummary(analysed)); +}); + +Then("le bilan nomme ensuite le coup à revoir", () => { + const advice = summarizeAnalysis(analysed).toReview?.advice; + assert.equal(advice?.kind === "move" && advice.played.notation, "6/off"); +}); diff --git a/docs/glossaire/README.md b/docs/glossaire/README.md index a4e3e45..ddf8fe0 100644 --- a/docs/glossaire/README.md +++ b/docs/glossaire/README.md @@ -45,6 +45,7 @@ exportée. `make test` vérifie le lien dans les deux sens : chaque fiche avec | [Compte de points](compte-de-points.md) | Pip count | | [Conseil du maître](conseil-du-maitre.md) | Advice | | [Coup](coup.md) | Move | +| [Courbe des chances de gagner](courbe-des-chances.md) | Win chance curve | | [Demi-coup](demi-coup.md) | Half-move | | [Course](course.md) | Race | | [Doubler](doubler.md) | Double | @@ -70,4 +71,5 @@ exportée. `make test` vérifie le lien dans les deux sens : chaque fiche avec | [Règle de Jacoby](regle-de-jacoby.md) | Jacoby rule | | [SGF](sgf.md) | SGF | | [Sortie](sortie.md) | Bear-off | +| [Taux d'erreur](taux-d-erreur.md) | Error rate | | [Videau](videau.md) | Cube | diff --git a/docs/glossaire/courbe-des-chances.md b/docs/glossaire/courbe-des-chances.md new file mode 100644 index 0000000..89f13f6 --- /dev/null +++ b/docs/glossaire/courbe-des-chances.md @@ -0,0 +1,24 @@ +--- +term: Courbe des chances de gagner +english: Win chance curve +code: WinChanceCurve +--- +# Courbe des chances de gagner + +**En bref** : vos chances de gagner la partie après chaque coup, selon le moteur, tracées coup après coup en pourcentage. + +Après chaque coup, des deux camps, le moteur évalue la position et donne +vos chances de gagner la partie, sans tenir compte du videau. La courbe +monte quand la partie tourne en votre faveur, descend sinon ; la ligne en +pointillé marque 50 %. Le dernier point est le résultat : 100 % si vous avez +gagné, 0 % sinon. + +Les points en couleur sont vos coups commentés par le maître, avec la +couleur de leur [appréciation](appreciation.md) ; les petits points sont les +coups de l'ordinateur. Une chute juste après l'un de vos coups montre ce +qu'une erreur a coûté ; une chute après un jet montre la part des dés. + +C'est le « graphe d'équité » de GNU Backgammon, en pourcentage plutôt qu'en +équité, plus parlant pour un débutant. + +Voir aussi : [Équité](equite.md), [Taux d'erreur](taux-d-erreur.md), [Chance](chance.md) diff --git a/docs/glossaire/taux-d-erreur.md b/docs/glossaire/taux-d-erreur.md new file mode 100644 index 0000000..a91419b --- /dev/null +++ b/docs/glossaire/taux-d-erreur.md @@ -0,0 +1,36 @@ +--- +term: Taux d'erreur +english: Error rate +code: ErrorRate +--- +# Taux d'erreur + +**En bref** : l'équité que vous avez perdue en moyenne à chaque décision de la partie, d'après le moteur ; plus il est bas, mieux vous avez joué. + +Le moteur compare chacun de vos coups et chacune de vos décisions de videau +au meilleur choix, et note l'équité perdue (0 pour le meilleur coup). Le +taux d'erreur additionne ces pertes et les divise par le nombre de +décisions qui comptent : les coups où vous aviez le choix (un coup forcé ne +compte pas), vos doubles, vos prises et vos refus, et vos jets sans doubler +quand doubler se discutait. C'est le calcul de GNU Backgammon (« Error rate +per decision »). + +Le bilan traduit ce taux en niveau, avec les seuils de GNU Backgammon et +les noms des niveaux de l'ordinateur, dans le même ordre : + +| Équité perdue par décision | Classement de GNU Backgammon | Niveau dans Ustagammon | +| --- | --- | --- | +| moins de 0,005 | Super Grandmaster | Champion | +| 0,005 à 0,008 | Grandmaster | Expert | +| 0,008 à 0,013 | Master | Avancé | +| 0,013 à 0,020 | Advanced | Intermédiaire | +| 0,020 à 0,032 | Intermediate | Amateur | +| 0,032 et plus | Beginner | Débutant | + +Le bilan montre à côté la [chance](chance.md) de vos dés sur la partie : par +tour, la chance de vos jets ; « un peu » pour vous ou contre vous au-delà de +0,03 par tour, « nettement » au-delà de 0,06, comme GNU Backgammon. La +phrase du bilan compare enfin le résultat à ce qu'il aurait été sans la +chance des deux camps : si l'écart vient surtout des dés, elle le dit. + +Voir aussi : [Appréciation](appreciation.md), [Chance](chance.md), [Équité](equite.md), [Courbe des chances de gagner](courbe-des-chances.md) diff --git a/docs/scenarios/README.md b/docs/scenarios/README.md index 9b14727..20c5adf 100644 --- a/docs/scenarios/README.md +++ b/docs/scenarios/README.md @@ -58,6 +58,7 @@ Règles d'écriture : | [jouer-une-partie.feature](jouer-une-partie.feature) | Jouer une partie contre l'ordinateur | Étape 0d | | [jouer-un-match.feature](jouer-un-match.feature) | Choisir sa partie (libre ou match, niveau) et jouer un match | Étape 1c | | [comprendre-mes-coups.feature](comprendre-mes-coups.feature) | Comprendre ses coups avec le conseil du maître | Étape 1d | +| [revoir-ma-partie.feature](revoir-ma-partie.feature) | Revoir sa partie : bilan, coup à revoir, analyse, rejouer d'ici | Étape 1e | | [mes-parties.feature](mes-parties.feature) | Retrouver mes parties, leurs branches, les exporter en SGF | Étape 1b | | [partager-une-position.feature](partager-une-position.feature) | Ouvrir et partager le lien d'une position | Étape 0e.4 | | [regler-l-application.feature](regler-l-application.feature) | Régler l'application (menu), la mettre à jour | Étape 0e | diff --git a/docs/scenarios/revoir-ma-partie.feature b/docs/scenarios/revoir-ma-partie.feature new file mode 100644 index 0000000..1649a18 --- /dev/null +++ b/docs/scenarios/revoir-ma-partie.feature @@ -0,0 +1,61 @@ +# language: fr +Fonctionnalité: Revoir ma partie avec le bilan et l'analyse + + À la fin de chaque partie, le maître fait le bilan : le résultat, vos + décisions par appréciation (bons coups, imprécisions, erreurs), votre jeu + traduit en niveau, la chance de vos dés, et le coup à revoir, celui qui + vous a coûté le plus. « Voir l'analyse » montre la courbe de vos chances de + gagner, coup après coup : parcourez-la du doigt pour revoir chaque coup sur + le plateau, le vôtre et celui du maître. Tout vient de GNU Backgammon, + comme le conseil du maître ; ce qui manque est calculé en arrière-plan, + même pour une partie jouée avant que le conseil existe. + + Scénario: Que dit le bilan d'une partie ? + Le bilan compte vos décisions par appréciation, traduit votre jeu en + niveau, et dit en une phrase ce qui a fait la différence : votre jeu, + celui de l'ordinateur, ou les dés. + + Étant donné qu'il vous reste deux pions, sur vos points 6 et 1, et à l'ordinateur deux pions sur ses points 1 et 2 + Et que vous avez 6-1 à jouer + Quand vous choisissez le coup 6/5 5/off + Et que l'ordinateur sort ses deux pions avec 2-1 + Alors la partie est finie, gagnée par l'ordinateur + Et le bilan compte une grosse erreur + Et sa phrase dit ce qui a fait la différence, et comment étaient vos dés + + Scénario: Quel est le coup à revoir ? + C'est votre décision qui a perdu le plus d'équité selon le moteur. Le + bilan la montre sur le plateau, avec le coup que le maître aurait joué ; + « Revoir ce coup » ouvre l'analyse sur ce coup. + + Étant donné qu'il vous reste deux pions, sur vos points 6 et 1, et à l'ordinateur deux pions sur ses points 1 et 2 + Et que vous avez 6-1 à jouer + Quand vous choisissez le coup 6/5 5/off + Et que l'ordinateur sort ses deux pions avec 2-1 + Alors le coup à revoir est votre coup 6/off, le premier de la partie + Et le maître y aurait joué 6/off 1/off + Et la courbe de vos chances de gagner finit à 0 % + + Scénario: Comment rejouer un coup autrement ? + Dans l'analyse, « Rejouer d'ici » vous remet juste avant le coup, avec le + même jet. Jouez autre chose : une nouvelle branche commence là, et la + partie d'origine reste intacte dans « Mes parties ». + + Étant donné qu'il vous reste deux pions, sur vos points 6 et 1, et à l'ordinateur deux pions sur ses points 1 et 2 + Et que vous avez 6-1 à jouer + Quand vous choisissez le coup 6/5 5/off + Et que l'ordinateur sort ses deux pions avec 2-1 + Et que vous rejouez d'ici le coup à revoir + Alors vous avez de nouveau 6-1 à jouer + Quand vous choisissez cette fois le coup 6/off 1/off + Alors la partie est finie, gagnée par vous + Et la partie a deux branches, dont la partie d'origine, intacte + + Scénario: Puis-je voir le bilan d'une partie plus ancienne ? + Oui : une partie enregistrée sans analyse, par exemple jouée avant le + conseil du maître, est analysée quand vous ouvrez son bilan. + + Étant donné une partie finie enregistrée sans aucune analyse + Quand vous ouvrez son bilan + Alors le maître l'analyse d'abord en arrière-plan + Et le bilan nomme ensuite le coup à revoir diff --git a/tests/browser.sh b/tests/browser.sh index beccdd5..9867947 100755 --- a/tests/browser.sh +++ b/tests/browser.sh @@ -9,10 +9,12 @@ # captures dans build/browser-shots/match/. # # Le conseil du maître (tests/browser/maitre.mjs), captures dans -# build/browser-shots/maitre/. +# build/browser-shots/maitre/. Le bilan de fin de partie et l'écran +# d'analyse (tests/browser/bilan.mjs), captures dans +# build/browser-shots/bilan/. # -# BROWSERS=chromium et SCRIPTS=accueil (ou play, match, maitre) restreignent le -# test. +# BROWSERS=chromium et SCRIPTS=accueil (ou play, match, maitre, bilan) +# restreignent le test. # # Outils : l'image de Playwright (navigateurs) et le paquet playwright, # installé une fois par npm dans build/browser-tools/ (réseau nécessaire la @@ -25,7 +27,7 @@ cd "$(dirname "$0")/.." image="${PLAYWRIGHT_IMAGE:?lancer via make test-browser}" browsers="${BROWSERS:-chromium firefox}" # Les scripts à lancer (tests/browser/.mjs), tous par défaut. -scripts="${SCRIPTS:-play accueil match maitre}" +scripts="${SCRIPTS:-play accueil match maitre bilan}" unset MAKEFLAGS MFLAGS MAKELEVEL make -s package >/dev/null @@ -63,7 +65,7 @@ update_port="$(docker port "$update_container" 80/tcp | head -n1 | sed 's/.*://' rm -rf build/browser-shots mkdir -p build/browser-shots cp tests/browser/play.mjs tests/browser/accueil.mjs tests/browser/match.mjs tests/browser/recette.mjs \ - tests/browser/maitre.mjs "$tools/" + tests/browser/maitre.mjs tests/browser/bilan.mjs "$tools/" status=0 for browser in $browsers; do # Réseau de l'hôte : le site est sur 127.0.0.1. Le service worker @@ -96,5 +98,12 @@ for browser in $browsers; do -v "$PWD/$tools:/tools" -v "$PWD/build/browser-shots:/shots" -w /tools "$image" \ node maitre.mjs "http://localhost:$port/" "$browser" /shots || status=1 fi + if [[ " $scripts " == *" bilan "* ]]; then + # Bilan de fin de partie et écran d'analyse ; captures de la recette + # dans build/browser-shots/bilan/. + docker run --rm --init --ipc=host --network host --user "$(id -u):$(id -g)" -e HOME=/tmp \ + -v "$PWD/$tools:/tools" -v "$PWD/build/browser-shots:/shots" -w /tools "$image" \ + node bilan.mjs "http://localhost:$port/" "$browser" /shots || status=1 + fi done exit "$status" diff --git a/tests/browser/bilan.mjs b/tests/browser/bilan.mjs new file mode 100644 index 0000000..976add5 --- /dev/null +++ b/tests/browser/bilan.mjs @@ -0,0 +1,384 @@ +// 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 } 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(", ")}`, + ); + 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 : le joueur passe. +await until("double de l'ordinateur", () => !document.getElementById("drop").hidden); +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 === "Vous passez", `titre : ${summary.title}`); +check(summary.reading.startsWith("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}`); +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(" | ")}`); + +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)`);