Plateau jouable en PWA : interface, Web Worker, stockage, service worker

L'application est à la racine du site : plateau en SVG, saisie au toucher
(pion puis case d'arrivée, proposées à partir du moteur), boutons du jet,
du videau et de nouvelle partie, coup de l'ordinateur affiché avec son
temps, fin de partie, reprise depuis localStorage, aide lue dans les
copies de docs/. Le moteur tourne dans le Web Worker existant ; main.ts
est le seul assembleur (nouvelles règles dependency-cruiser). Le service
worker met tout le site en cache, avec une empreinte du contenu comme
version. Les pages de test du moteur passent sous /moteur/. Politique de
sécurité du contenu dans le Caddyfile ; make test-browser joue une partie
dans Chromium et Firefox sans écran (Playwright, hors de make test).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Pierre MARTIN
2026-11-02 21:47:12 +01:00
co-authored by Claude Opus 5.5
parent 613ff38254
commit a8636b3f83
37 changed files with 2548 additions and 106 deletions
+98
View File
@@ -0,0 +1,98 @@
// bench-page.mjs -- page de mesure (bench.html) : chronomètre le moteur
// dans un Web Worker et produit le rapport à copier.
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
import { DOWNLOADED_FILES, DOWNLOAD_LIMIT_BYTES, formatReport, measureTimes, summarize }
from "./benchmark.mjs";
import { startEngine } from "./engine-client.mjs";
const $ = (id) => document.getElementById(id);
// Taille d'un fichier du site, brute et compressée gzip (comme le ferait un
// serveur qui compresse ses réponses).
// name est relatif à la racine du site, un dossier au-dessus de cette page.
async function fileSize(name) {
const blob = await (await fetch("../" + name, { cache: "no-store" })).blob();
const gzipped = blob.stream().pipeThrough(new CompressionStream("gzip"));
const gzipBytes = (await new Response(gzipped).arrayBuffer()).byteLength;
return { name, bytes: blob.size, gzipBytes };
}
function addRow(cells) {
const row = $("table").insertRow();
for (const text of cells) {
row.insertCell().textContent = text;
}
}
async function measure() {
$("start").disabled = true;
$("results").hidden = true;
$("table").replaceChildren();
$("status").textContent = "Chargement du moteur…";
const start = performance.now();
const engine = startEngine();
await engine.ready;
const loadMs = performance.now() - start;
const { positions } = await (await fetch("../wasm/positions.json")).json();
let memoryBytes = 0;
const times = await measureTimes({
bestMove: (...args) => engine.bestMove(...args).then(keepMemory),
cubeDecision: (...args) => engine.cubeDecision(...args).then(keepMemory),
}, positions, (done, total) => {
$("status").textContent = "Calcul " + done + " sur " + total + "…";
});
function keepMemory(answer) {
memoryBytes = answer.memoryBytes;
return answer;
}
$("status").textContent = "Mesure de la taille à télécharger…";
const files = await Promise.all(DOWNLOADED_FILES.map(fileSize));
for (const s of summarize(times)) {
addRow([s.label, Math.round(s.medianMs) + " ms", Math.round(s.maxMs) + " ms",
s.limitMs + " ms", s.ok ? "OK" : "dépassé"]);
}
const gzipTotal = files.reduce((sum, f) => sum + f.gzipBytes, 0);
addRow(["Taille compressée", "", (gzipTotal / 1e6).toFixed(1).replace(".", ",") + " Mo",
DOWNLOAD_LIMIT_BYTES / 1e6 + " Mo", gzipTotal < DOWNLOAD_LIMIT_BYTES ? "OK" : "dépassé"]);
$("report").value = formatReport({
environment: navigator.userAgent, files, loadMs, memoryBytes, times,
});
$("results").hidden = false;
$("status").textContent = "Mesure terminée.";
}
// Copie dans le presse-papiers. L'API moderne n'existe que sur les pages
// sécurisées (https ou localhost) : sur le réseau local en http, on
// sélectionne le texte et on utilise l'ancienne commande de copie.
async function copyReport() {
try {
if (navigator.clipboard) {
await navigator.clipboard.writeText($("report").value);
} else {
$("report").select();
if (!document.execCommand("copy")) {
throw new Error();
}
}
$("copy-status").textContent = "Rapport copié : collez-le dans la conversation.";
} catch {
$("report").select();
$("copy-status").textContent = "Copie impossible : le texte est sélectionné, "
+ "copiez-le à la main (appui long, puis Copier).";
}
}
$("start").onclick = () => measure().catch((error) => {
$("status").textContent = "La mesure a échoué : " + error.message;
$("status").classList.add("error");
$("start").disabled = false;
});
$("copy").onclick = copyReport;
+35
View File
@@ -0,0 +1,35 @@
<!doctype html>
<html lang="fr">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Ustagammon : mesure du moteur</title>
<link rel="stylesheet" href="style.css">
<script type="module" src="bench-page.mjs"></script>
</head>
<body>
<main>
<h1>Mesure du moteur</h1>
<p>Cette page chronomètre le moteur sur les positions de référence : meilleur coup
aux niveaux rapide et fort, décision de videau au niveau fort. Elle mesure aussi
la taille à télécharger et la mémoire utilisée. Comptez une à quelques minutes ;
gardez l'écran allumé et la page au premier plan.</p>
<button id="start" type="button">Lancer la mesure</button>
<p id="status" class="status"></p>
<section id="results" hidden>
<h2>Résultats</h2>
<table>
<thead><tr><th>Mesure</th><th>Médiane</th><th>Maximum</th><th>Seuil</th><th></th></tr></thead>
<tbody id="table"></tbody>
</table>
<h2>Rapport</h2>
<button id="copy" type="button">Copier les résultats</button>
<p id="copy-status" class="status"></p>
<textarea id="report" readonly></textarea>
</section>
<p><a href="./">Page de test</a></p>
</main>
</body>
</html>
+138
View File
@@ -0,0 +1,138 @@
// benchmark.mjs -- mesure du moteur WebAssembly, partagée par la page de
// mesure (bench.html, dans le navigateur) et `make bench` (sous Node).
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
//
// Sur les positions de référence (tests/reference/positions.json) : temps du
// meilleur coup à 0 et 2 plis pour les positions avec dés, temps de la
// décision de videau à 2 plis pour les autres. Le rapport compare aux seuils
// de l'étape 0b (docs/etape-0.md).
// Seuils de l'étape 0b. Un temps respecte son seuil si son maximum, sur
// toutes les positions, est en dessous (la médiane est donnée en plus).
export const TIME_THRESHOLDS = [
{ key: "move0", label: "Meilleur coup, 0 pli", limitMs: 300 },
{ key: "move2", label: "Meilleur coup, 2 plis", limitMs: 3000 },
{ key: "cube2", label: "Décision de videau, 2 plis", limitMs: 3000 },
];
// Taille totale à télécharger, compressée : moins de 40 Mo.
export const DOWNLOAD_LIMIT_BYTES = 40e6;
// Fichiers téléchargés pour faire tourner le moteur dans la page de test,
// chemins relatifs à la racine du site (web/ ; wasm/ est build/wasm/).
export const DOWNLOADED_FILES = [
"moteur/index.html",
"moteur/style.css",
"moteur/page.mjs",
"moteur/engine-client.mjs",
"engine-worker.mjs",
"engine-wasm.mjs",
"wasm/engine.mjs",
"wasm/engine.wasm",
"wasm/engine.data",
];
// engine : objet avec bestMove(positionId, matchId, dice, ply) et
// cubeDecision(positionId, matchId, ply), qui renvoient (ou promettent)
// { result, ms }.
// positions : le tableau « positions » de positions.json.
// onProgress(done, total) : appelé après chaque calcul.
// Renvoie, pour chaque clé de TIME_THRESHOLDS, la liste des
// { id, ms } mesurés.
export async function measureTimes(engine, positions, onProgress = () => {}) {
const tasks = [];
for (const p of positions) {
if (p.type === "move") {
tasks.push(["move0", p, () => engine.bestMove(p.position_id, p.match_id, p.dice, 0)]);
tasks.push(["move2", p, () => engine.bestMove(p.position_id, p.match_id, p.dice, 2)]);
} else {
tasks.push(["cube2", p, () => engine.cubeDecision(p.position_id, p.match_id, 2)]);
}
}
const times = Object.fromEntries(TIME_THRESHOLDS.map((t) => [t.key, []]));
for (const [i, [key, position, compute]] of tasks.entries()) {
const { ms } = await compute();
times[key].push({ id: position.id, ms });
onProgress(i + 1, tasks.length);
}
return times;
}
function median(values) {
const sorted = [...values].sort((a, b) => a - b);
const middle = Math.floor(sorted.length / 2);
return sorted.length % 2 ? sorted[middle] : (sorted[middle - 1] + sorted[middle]) / 2;
}
// Nombres à la française : virgule décimale.
function decimal(value, digits) {
return value.toFixed(digits).replace(".", ",");
}
function megabytes(bytes) {
return decimal(bytes / 1e6, 1) + " Mo";
}
function verdict(ok) {
return ok ? "OK" : "DÉPASSÉ";
}
// Résumé d'une série de temps : { count, medianMs, maxMs, maxId, ok }.
export function summarize(times) {
return TIME_THRESHOLDS.map((threshold) => {
const list = times[threshold.key];
const worst = list.reduce((a, b) => (b.ms > a.ms ? b : a));
return {
...threshold,
count: list.length,
medianMs: median(list.map((t) => t.ms)),
maxMs: worst.ms,
maxId: worst.id,
ok: worst.ms < threshold.limitMs,
};
});
}
// Rapport texte, à coller dans la conversation.
// environment : description de l'appareil (navigateur ou Node) ;
// files : [{ name, bytes, gzipBytes }] ; loadMs : chargement du moteur ;
// memoryBytes : mémoire WebAssembly après la mesure ; times : measureTimes.
export function formatReport({ environment, files, loadMs, memoryBytes, times }) {
const total = files.reduce((sum, f) => sum + f.bytes, 0);
const totalGzip = files.reduce((sum, f) => sum + f.gzipBytes, 0);
const sizeOk = totalGzip < DOWNLOAD_LIMIT_BYTES;
const summary = summarize(times);
const allOk = sizeOk && summary.every((s) => s.ok);
const lines = [
"Ustagammon : mesure du moteur WebAssembly (étape 0b)",
"Date : " + new Date().toISOString(),
"Environnement : " + environment,
"",
"Chargement du moteur : " + Math.round(loadMs) + " ms",
"Mémoire WebAssembly après la mesure : " + megabytes(memoryBytes),
"Taille téléchargée : " + megabytes(total) + " non compressée, "
+ megabytes(totalGzip) + " compressée gzip ; seuil "
+ megabytes(DOWNLOAD_LIMIT_BYTES) + " compressée : " + verdict(sizeOk),
"",
];
for (const s of summary) {
lines.push(s.label + " (" + s.count + " positions) : médiane "
+ decimal(s.medianMs, 1) + " ms, maximum " + decimal(s.maxMs, 1) + " ms ("
+ s.maxId + ") ; seuil " + s.limitMs + " ms : " + verdict(s.ok));
}
lines.push("", "Bilan : " + (allOk ? "tous les seuils sont respectés."
: "au moins un seuil est dépassé."), "", "Détail des fichiers :");
for (const f of files) {
lines.push(" " + f.name + " : " + f.bytes + " octets, " + f.gzipBytes + " compressé gzip");
}
lines.push("", "Détail des temps (ms) :");
for (const s of summary) {
lines.push(" " + s.label + " : " + times[s.key]
.map((t) => t.id + " " + Math.round(t.ms)).join(", "));
}
return lines.join("\n") + "\n";
}
+66
View File
@@ -0,0 +1,66 @@
// engine-client.mjs -- le moteur vu depuis les pages de test : démarre le
// Web Worker (../engine-worker.mjs, partagé avec l'application) et lui envoie
// les commandes.
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
//
// Toutes les fonctions renvoient une promesse de { result, ms, memoryBytes } :
// result est l'objet JSON d'engine-cli, ms le temps de calcul mesuré dans le
// Worker. La promesse est rejetée si le moteur signale une erreur.
import { commands } from "../engine-wasm.mjs";
export function startEngine() {
const worker = new Worker(new URL("../engine-worker.mjs", import.meta.url), { type: "module" });
const pending = new Map();
let nextId = 1;
let resolveReady, rejectReady;
const ready = new Promise((resolve, reject) => {
resolveReady = resolve;
rejectReady = reject;
});
worker.onmessage = (event) => {
const message = event.data;
if (message.type === "ready") {
resolveReady(message);
} else if (message.type === "failed") {
rejectReady(new Error(message.message));
} else {
const { resolve, reject } = pending.get(message.id);
pending.delete(message.id);
if (message.error) {
reject(new Error(message.error));
} else {
resolve(message);
}
}
};
// Erreur au chargement du Worker lui-même (fichier absent, navigateur
// trop ancien pour les Workers en module ES…).
worker.onerror = (event) => {
rejectReady(new Error(event.message || "le Web Worker du moteur n'a pas démarré"));
};
function run(args) {
const id = nextId++;
return new Promise((resolve, reject) => {
pending.set(id, { resolve, reject });
worker.postMessage({ id, args: args.map(String) });
});
}
return {
// Promesse de { loadMs, memoryBytes } une fois le moteur chargé.
ready,
bestMove: (positionId, matchId, dice, ply) =>
run(commands.bestMove(positionId, matchId, dice, ply)),
cubeDecision: (positionId, matchId, ply) =>
run(commands.cubeDecision(positionId, matchId, ply)),
evaluate: (positionId, matchId, ply) =>
run(commands.evaluate(positionId, matchId, ply)),
reference: (positionId, matchId, dice, ply) =>
run(commands.reference(positionId, matchId, dice, ply)),
};
}
+48
View File
@@ -0,0 +1,48 @@
<!doctype html>
<html lang="fr">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Ustagammon : test du moteur</title>
<link rel="stylesheet" href="style.css">
<script type="module" src="page.mjs"></script>
</head>
<body>
<main>
<h1>Test du moteur</h1>
<p>Le moteur GNU Backgammon, compilé en WebAssembly, calcule dans le navigateur.</p>
<p id="status" class="status">Chargement du moteur…</p>
<form id="form">
<label>Exemple
<select id="example"><option value="">(choisir une position de référence)</option></select>
</label>
<label>Position ID
<input id="position-id" required autocomplete="off" spellcheck="false" value="4HPwATDgc/ABMA">
</label>
<label>Match ID
<input id="match-id" required autocomplete="off" spellcheck="false" value="cIkFAAAAAAAA">
</label>
<div class="row">
<label>Dé 1 <input id="die1" type="number" min="1" max="6" required value="3"></label>
<label>Dé 2 <input id="die2" type="number" min="1" max="6" required value="1"></label>
<label>Niveau
<select id="ply">
<option value="0">rapide (0 pli)</option>
<option value="2">fort (2 plis)</option>
</select>
</label>
</div>
<button id="best-move" type="submit" disabled>Meilleur coup</button>
</form>
<section id="result" class="result" hidden>
<p>Meilleur coup : <strong id="move"></strong></p>
<p id="details"></p>
<p>Temps de calcul : <strong id="time"></strong></p>
</section>
<p><a href="bench.html">Page de mesure</a> · <a href="../">L'application</a></p>
</main>
</body>
</html>
+71
View File
@@ -0,0 +1,71 @@
// page.mjs -- page de test du moteur (index.html) : meilleur coup d'une
// position, avec son temps de calcul.
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
import { startEngine } from "./engine-client.mjs";
const $ = (id) => document.getElementById(id);
const engine = startEngine();
engine.ready.then(
({ loadMs }) => {
$("status").textContent = "Moteur prêt, chargé en " + Math.round(loadMs) + " ms.";
$("best-move").disabled = false;
},
(error) => {
$("status").textContent = "Le moteur n'a pas pu se charger : " + error.message;
$("status").classList.add("error");
},
);
// Exemples : les positions de référence avec des dés.
fetch("../wasm/positions.json")
.then((response) => response.json())
.then(({ positions }) => {
for (const p of positions.filter((p) => p.type === "move")) {
const option = new Option(p.id + " : " + p.description, p.id);
option.position = p;
$("example").add(option);
}
})
.catch(() => {}); // la page reste utilisable sans les exemples
$("example").onchange = () => {
const p = $("example").selectedOptions[0].position;
if (p) {
$("position-id").value = p.position_id;
$("match-id").value = p.match_id;
[$("die1").value, $("die2").value] = p.dice;
}
};
function showResult(move, details, time) {
$("move").textContent = move;
$("details").textContent = details;
$("time").textContent = time;
$("result").hidden = false;
}
$("form").onsubmit = async (event) => {
event.preventDefault();
$("best-move").disabled = true;
$("result").hidden = true;
try {
const { result, ms } = await engine.bestMove($("position-id").value.trim(),
$("match-id").value.trim(), [$("die1").value, $("die2").value], $("ply").value);
const best = result.best_move;
if (best) {
showResult(best.move, "Équité " + best.equity.toFixed(3).replace(".", ",")
+ ", chances de gain " + (100 * best.probabilities.win).toFixed(1).replace(".", ",")
+ " %, évalué à " + best.ply + " pli(s).", Math.round(ms) + " ms");
} else {
showResult("aucun coup possible", "", Math.round(ms) + " ms");
}
} catch (error) {
showResult("erreur", error.message, "");
} finally {
$("best-move").disabled = false;
}
};
+113
View File
@@ -0,0 +1,113 @@
/* Pages de test du moteur : mise en forme minimale, lisible sur téléphone. */
:root {
--text: #1d1d1b;
--background: #fbfaf7;
--muted: #5f5e5a;
--accent: #1f5c4a;
--error: #a3261b;
--border: #cfccc4;
}
@media (prefers-color-scheme: dark) {
:root {
--text: #ecebe6;
--background: #1b1c1a;
--muted: #a9a8a2;
--accent: #7cc4a8;
--error: #f08a7e;
--border: #4a4a46;
}
}
* {
box-sizing: border-box;
}
body {
margin: 0;
font: 17px/1.5 system-ui, sans-serif;
color: var(--text);
background: var(--background);
}
main {
max-width: 40rem;
margin: 0 auto;
padding: 1rem;
}
h1 {
font-size: 1.5rem;
}
label {
display: block;
margin-bottom: 0.75rem;
}
input,
select,
button,
textarea {
display: block;
width: 100%;
margin-top: 0.25rem;
padding: 0.5rem;
font: inherit;
color: inherit;
background: transparent;
border: 1px solid var(--border);
border-radius: 6px;
}
.row {
display: flex;
gap: 0.75rem;
}
.row label {
flex: 1;
}
button {
margin: 0.5rem 0 1rem;
font-weight: 600;
color: var(--background);
background: var(--accent);
border-color: var(--accent);
}
button:disabled {
opacity: 0.5;
}
textarea {
height: 16rem;
font: 13px/1.4 monospace;
}
table {
width: 100%;
border-collapse: collapse;
}
th,
td {
padding: 0.25rem;
text-align: left;
border-bottom: 1px solid var(--border);
}
.status {
color: var(--muted);
}
.error {
color: var(--error);
}
.result {
padding: 0.5rem 1rem;
border-left: 4px solid var(--accent);
}