Fusion de main dans moteur-analyse ; plan 1a clos
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+155
-32
@@ -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
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user