Fusion de main dans moteur-analyse ; plan 1a clos

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Pierre MARTIN
2026-11-03 15:06:09 +01:00
co-authored by Claude Opus 5.5
44 changed files with 1264 additions and 740 deletions
+155 -32
View File
@@ -1,6 +1,7 @@
// 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
// production : dés, coups au toucher (validés, annulés), coups de
// l'ordinateur, videau (avec sa confirmation), reprise
// après rechargement, aide, puis hors ligne (service worker).
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
@@ -101,7 +102,7 @@ async function waitForPlayer() {
const b = document.getElementById(id);
return !b.hidden && !b.disabled;
};
for (const id of ["again", "take", "skip", "roll"]) {
for (const id of ["again", "take", "skip", "confirm", "roll"]) {
if (usable(id)) {
return id;
}
@@ -161,7 +162,8 @@ async function checkTraces() {
// 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,
check(/^L'ordinateur ne peut pas jouer \d-\d\.$/.test(info ?? "") && blocked >= 2 &&
blocked === (await shownDice()).dice,
`jet de l'ordinateur sans coup mal montré : « ${info} », ${blocked} dés barrés`);
log(`l'ordinateur ne peut pas jouer : « ${info} »`);
}
@@ -175,18 +177,35 @@ async function checkTraces() {
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;
/** Une zone à toucher d'une action du plateau (dés ou videau) : count zones
* (les dés en ont une de chaque côté du plateau), chacune un bouton
* accessible d'au moins 44 px de côté. Renvoie le locator de la zone index. */
async function touchTarget(action, label, count = 1, index = 0) {
const zones = page.locator(`[data-action="${action}"]`);
check((await zones.count()) === count,
`zone ${action} : ${await zones.count()} sur le plateau, ${count} attendues`);
for (const zone of await zones.all()) {
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 zones.nth(index);
}
/** Le nombre de dés montrés, et de dés estompés (déjà joués). */
async function shownDice() {
return {
dice: await page.locator("g.die[data-value]").count(),
used: await page.locator("g.die-used").count(),
};
}
/** Le dernier jet du joueur, d'après le journal. */
function lastPlayerRoll(journal) {
const rolled = journal.events.filter((e) => e.type === "DiceRolled").at(-1);
return rolled?.roll ?? [];
}
/** Observe la page pendant ms millisecondes : le plus grand nombre
@@ -256,13 +275,24 @@ let tracesSeen = 0;
let replays = 0;
let skips = 0;
let rolls = 0;
// Saisie du coup : demi-coups du coup en cours, pions avancés d'un seul
// toucher ou en deux (pion, puis case), coups validés et annulations.
let halfMoves = 0;
let singleTouch = 0;
let twoTouches = 0;
let confirms = 0;
let undone = 0;
let doubles = 0;
// Vrai dès que le joueur a touché un pion ou « Annuler » pendant son coup :
// le coup de l'ordinateur n'est plus montré.
let touched = false;
for (let actions = 0; ; actions++) {
check(actions < 800, "la partie ne finit pas");
check(actions < 1500, "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();
const traces = touched ? null : await checkTraces();
if (traces !== null) {
tracesSeen++;
if (replays < 2) {
@@ -292,7 +322,8 @@ for (let actions = 0; ; actions++) {
// 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,
check(/^Aucun coup possible avec \d-\d\.$/.test(info ?? "") && blocked >= 2 &&
blocked === (await shownDice()).dice,
`aucun coup possible mal montré : « ${info} », ${blocked} dés barrés`);
skips++;
log(`aucun coup possible : « ${info} », vous continuez`);
@@ -304,30 +335,115 @@ for (let actions = 0; ; actions++) {
} 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"));
// Toucher le videau demande confirmation ; « Annuler » ne
// propose rien, puis « Doubler » (le bouton) et confirmer.
const dialog = page.locator("#double-dialog");
const question = "Doubler à 2 ? L'ordinateur pourra prendre ou passer.";
await (await touchTarget("double", "Doubler")).click();
check(await dialog.isVisible(), "videau touché : pas de confirmation");
check((await page.textContent("#double-question")) === question,
`confirmation du videau : « ${await page.textContent("#double-question")} »`);
await shot("videau-confirmation");
await page.click("#double-cancel");
check(!(await dialog.isVisible()), "Annuler ne ferme pas la confirmation");
const offered = (await savedJournal()).events.some((e) => e.type === "CubeOffered");
check(!offered && (await page.isVisible("#roll")), "double proposé malgré Annuler");
await page.click("#double");
check(await dialog.isVisible(), "« Doubler » : pas de confirmation");
await page.click("#double-confirm");
await waitForPlayer();
const answer = (await page.textContent("#info")) || (await page.textContent("#prompt"));
log(`réponse : ${answer}`);
log(`videau touché : confirmation demandée, annulée, puis double confirmé ; réponse : ${answer}`);
continue;
}
// Une fois sur deux, en touchant les dés en attente plutôt que le
// bouton.
// Une fois sur deux, en touchant les dés plutôt que le bouton, d'un
// côté puis de l'autre du plateau.
rolls++;
await page.click(rolls % 2 === 0 ? await touchTarget("roll", "Lancer les dés") : "#roll");
if (rolls % 2 === 0) {
await (await touchTarget("roll", "Lancer les dés", 2, (rolls / 2) % 2)).click();
} else {
await page.click("#roll");
}
} else if (todo === "confirm") {
// Coup complet : rien n'est joué tant qu'il n'est pas validé.
const journal = await savedJournal();
const last = journal.events.at(-1);
check(last?.type === "DiceRolled" && (await page.isEnabled("#cancel")),
`coup complet déjà joué ou non annulable (dernier événement ${last?.type})`);
check(/Valider/.test((await page.textContent("#prompt")) ?? ""),
`consigne de validation absente : « ${await page.textContent("#prompt")} »`);
const dice = await shownDice();
check(dice.used === halfMoves && dice.used > 0,
`dés estompés : ${dice.used} pour ${halfMoves} demi-coups`);
if (undone === 0 && halfMoves >= 2) {
// Une fois : « Annuler » défait les demi-coups un par un.
if (confirms === 0) {
await shot("2b-coup-a-valider");
}
for (let left = halfMoves - 1; left >= 0; left--) {
await page.click("#cancel");
await waitForPlayer();
const after = await shownDice();
check(after.used === left && (await page.isHidden("#confirm")),
`Annuler : ${after.used} dés estompés, ${left} attendus`);
undone++;
}
check(await page.isDisabled("#cancel"), "Annuler reste actif sans demi-coup à défaire");
check((await savedJournal()).head === journal.head, "Annuler a touché au journal");
log(`Annuler : ${undone} demi-coups défaits un par un, journal intact`);
halfMoves = 0;
continue;
}
confirms++;
// Une fois sur trois avec le bouton, sinon en touchant les dés
// (d'un côté ou de l'autre).
if (confirms % 3 === 0) {
await page.click("#confirm");
} else {
await (await touchTarget("confirm", "Valider", 2, confirms % 2)).click();
}
await waitForPlayer();
const played = await savedJournal();
check(countMoves(played, "player") === countMoves(journal, "player") + 1,
"le coup validé n'est pas dans le journal");
halfMoves = 0;
touched = false;
} 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");
if (halfMoves === 0) {
// Quatre dés pour un double, deux sinon.
const [a, b] = lastPlayerRoll(await savedJournal());
const dice = await shownDice();
check(dice.dice === (a === b ? 4 : 2) && dice.used === 0,
`jet ${a}-${b} : ${dice.dice} dés montrés, ${dice.used} estompés`);
if (a === b) {
doubles++;
}
}
await page.click(".hit.can-land");
touched = true;
// Un pion à destination unique avance dès qu'on le touche ; sinon,
// ses cases d'arrivée s'allument.
await page.click(".hit.can-move");
if ((await page.locator(".hit.can-land").count()) > 0) {
twoTouches++;
if (!selectionShot) {
selectionShot = true;
await shot("2-pion-touche");
}
await page.click(".hit.can-land");
} else {
singleTouch++;
}
halfMoves++;
await waitForPlayer();
const dice = await shownDice();
check(dice.used === halfMoves, `${dice.used} dés estompés après ${halfMoves} demi-coups`);
continue;
}
// Une fois, au milieu de la partie : fermer et rouvrir la page.
// Une fois, au milieu de la partie, entre deux coups : fermer et rouvrir
// la page.
const journal = await savedJournal();
if (!reloaded && countMoves(journal, "computer") >= 3 && countMoves(journal, "player") >= 3) {
reloaded = true;
@@ -355,7 +471,14 @@ check(countMoves(journal, "player") >= 3 && countMoves(journal, "computer") >= 3
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`);
check(singleTouch > 0 && twoTouches > 0,
`saisie : ${singleTouch} pions avancés d'un toucher, ${twoTouches} en deux touchers`);
check(undone >= 2, "Annuler n'a pas été essayé sur un coup complet");
check(confirms >= 3, "trop peu de coups validés pour essayer dés et bouton");
log(`dés lancés ${Math.floor(rolls / 2)} fois en les touchant, des deux côtés du plateau`);
log(`demi-coups : ${singleTouch} d'un seul toucher (destination unique), ${twoTouches} en deux ` +
`touchers ; ${confirms} coups validés (${Math.floor(confirms / 3)} par le bouton, les autres ` +
`en touchant les dés) ; ${doubles} doubles montrés en quatre dés`);
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) {
+14 -10
View File
@@ -1,8 +1,9 @@
#!/usr/bin/env bash
# Vérifie l'image de production (deploy/) avec le site assemblé (make package) :
# construit l'image en local, la lance, et contrôle ce que reçoit un
# navigateur (application, pages de test du moteur, aide, manifeste, service
# worker, types MIME, compression, politique de sécurité, version).
# navigateur (application, aide, manifeste, service worker, types MIME,
# compression, politique de sécurité, version), et l'absence des anciennes
# pages de test du moteur.
set -euo pipefail
cd "$(dirname "$0")/.."
@@ -71,10 +72,12 @@ check "wasm/engine.mjs" "text/javascript"
check "wasm/engine.wasm" "application/wasm"
check "wasm/engine.data" "application/octet-stream"
check "version.json" "application/json"
# Pages de test du moteur (étape 0b).
check "moteur/" "text/html"
check "moteur/bench.html" "text/html"
check "moteur/engine-client.mjs" "text/javascript"
# Les pages de test du moteur (étape 0b) sont retirées (ADR 0009,
# amendement du 2026-11-03) : plus rien sous /moteur/.
[ ! -e build/site/moteur ] || report "build/site/moteur/ existe encore"
for gone in moteur/ moteur/bench.html; do
curl -s -o /dev/null -w "%{http_code}" "$base/$gone" | grep -qx 404 || report "/$gone : 404 attendu"
done
# Aide : copies du glossaire et des scénarios (ADR 0008).
check "docs/scenarios/jouer-une-partie.feature" "text/plain"
check "docs/glossaire/README.md" "text/markdown"
@@ -94,15 +97,16 @@ done < <(grep -o '"src": "[^"]*"' <<<"$manifest" | sed 's/"src": "\(.*\)"/\1/')
# Le service worker met en cache chaque fichier du site, avec une version.
sw="$(curl -s "$base/sw.js")"
grep -qE '^const VERSION = "[0-9a-f]{16}";' <<<"$sw" || report "sw.js : version absente"
for file in ./ index.html app/main.js wasm/engine.data wasm/engine.wasm moteur/ \
for file in ./ index.html app/main.js wasm/engine.data wasm/engine.wasm \
docs/glossaire/videau.md version.json fonts/AlegreyaSans-Bold.woff2 icons/favicon.svg; do
grep -qF " \"$file\"," <<<"$sw" || report "sw.js : $file absent de la liste à mettre en cache"
done
listed="$(grep -c '^ "' <<<"$sw")"
present="$(find build/site -type f ! -name sw.js | wc -l)"
# Chaque fichier, plus « ./ » et « moteur/ » pour les deux index.html.
[ "$listed" -eq "$((present + 2))" ] \
|| report "sw.js liste $listed adresses pour $present fichiers (plus 2 dossiers)"
# Chaque fichier, plus « ./ » pour index.html.
[ "$listed" -eq "$((present + 1))" ] \
|| report "sw.js liste $listed adresses pour $present fichiers (plus « ./ »)"
grep -q '"moteur/' <<<"$sw" && report "sw.js met encore en cache des pages de test du moteur"
grep -o '^ "[^"]*"' <<<"$sw" | tr -d ' "' | while read -r file; do
curl -sf -o /dev/null "$base/$file" || echo "sw.js : $file introuvable sur le site"
done | grep . && report "sw.js liste des fichiers introuvables"