Bilan et analyse : scénario, fiches du glossaire, test dans le navigateur
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
006fd62704
commit
2c5ae08f46
@@ -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<GameJournal> {
|
||||
const runner = createReviewRunner((j, task) => runAnalysis(engine, j, task));
|
||||
recorded = 0;
|
||||
let latest = journal;
|
||||
await new Promise<void>((resolve) => {
|
||||
runner.start(
|
||||
"partie",
|
||||
journal,
|
||||
(j) => {
|
||||
latest = j;
|
||||
recorded++;
|
||||
},
|
||||
resolve,
|
||||
);
|
||||
});
|
||||
return latest;
|
||||
}
|
||||
|
||||
async function playMove(move: string): Promise<void> {
|
||||
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");
|
||||
});
|
||||
@@ -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 |
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
+14
-5
@@ -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/<nom>.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"
|
||||
|
||||
@@ -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 <adresse du site> <chromium|firefox> <dossier des captures>
|
||||
// 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 <dossier des captures>/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 <adresse> <chromium|firefox> <dossier des captures>");
|
||||
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)`);
|
||||
Reference in New Issue
Block a user