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
+37 -6
View File
@@ -499,6 +499,10 @@ body.busy .prompt {
stroke-linecap: round;
}
/* Dé déjà utilisé par un demi-coup du coup en cours : estompé, sans être
* barré (il n'est pas perdu : « Annuler » le rend). */
.die-used { opacity: 0.35; }
.cube-face { fill: var(--cube); stroke: var(--checker-player-edge); stroke-width: 2; }
.cube-value {
@@ -513,6 +517,39 @@ body.busy .prompt {
cursor: pointer;
}
/* ------------------------------------------------ Confirmation (videau) */
/* Une carte au milieu de l'écran, comme le glossaire ; le fond de la page
* s'assombrit derrière. */
.confirm {
width: min(22rem, calc(100vw - 2 * var(--space-4)));
padding: var(--space-4);
color: var(--text);
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-md);
box-shadow: var(--shadow-raised);
}
.confirm::backdrop {
background: rgba(43, 29, 21, 0.5);
}
.confirm p {
margin: 0 0 var(--space-4);
font-weight: 700;
}
.confirm-actions {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
}
.confirm-actions button {
flex: 1 1 auto;
}
/* --------------------------------------------------------------- Aide */
.help {
@@ -582,12 +619,6 @@ body.busy .prompt {
color: var(--muted);
}
/* Une cible tactile confortable pour le lien. */
.about a {
display: inline-block;
padding-block: 12px;
}
/* ------------------------------------------------------- Mode paysage */
/* Téléphone tourné : le plateau prend toute la hauteur de l'écran, à
+9
View File
@@ -43,11 +43,20 @@
<button id="double" type="button" class="secondary" hidden></button>
<button id="take" type="button" hidden></button>
<button id="drop" type="button" class="secondary" hidden></button>
<button id="confirm" type="button" hidden></button>
<button id="cancel" type="button" class="secondary" hidden></button>
<button id="skip" type="button" hidden></button>
<button id="replay" type="button" class="secondary" hidden></button>
<button id="again" type="button" hidden></button>
</div>
<!-- Confirmation avant de doubler (ouverte par app/ui/app.js). -->
<dialog id="double-dialog" class="confirm" aria-labelledby="double-question">
<p id="double-question"></p>
<div class="confirm-actions">
<button id="double-confirm" type="button"></button>
<button id="double-cancel" type="button" class="secondary"></button>
</div>
</dialog>
<noscript><p>Ustagammon a besoin de JavaScript.</p></noscript>
</main>
-99
View File
@@ -1,99 +0,0 @@
// 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),
analyseMove: (...args) => engine.analyseMove(...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
@@ -1,35 +0,0 @@
<!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>
-151
View File
@@ -1,151 +0,0 @@
// 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 (ADR 0009).
// 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 },
// Étape 1a : l'analyse du coup joué, pour le conseil du maître, calculée
// en arrière-plan ; même repère que le meilleur coup (ADR 0009).
{ key: "analysis2", label: "Analyse du coup joué, 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),
// cubeDecision(positionId, matchId, ply) et analyseMove(positionId, matchId,
// dice, playedPositionId, ply), qui renvoient (ou promettent) { result, ms }.
// Le coup analysé est le meilleur coup à 0 pli : celui qu'un joueur aurait
// pu jouer, analysé à 2 plis comme pour le conseil du maître.
// 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 = [];
const played = new Map();
for (const p of positions) {
if (p.type === "move") {
tasks.push(["move0", p, async () => {
const answer = await engine.bestMove(p.position_id, p.match_id, p.dice, 0);
// Sans coup possible, n'importe quelle position fait l'affaire.
played.set(p.id, answer.result.best_move?.position_id ?? p.position_id);
return answer;
}]);
tasks.push(["move2", p, () => engine.bestMove(p.position_id, p.match_id, p.dice, 2)]);
tasks.push(["analysis2", p, () => engine.analyseMove(p.position_id, p.match_id, p.dice,
played.get(p.id), 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";
}
-68
View File
@@ -1,68 +0,0 @@
// 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)),
analyseMove: (positionId, matchId, dice, playedPositionId, ply) =>
run(commands.analyseMove(positionId, matchId, dice, playedPositionId, 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
@@ -1,48 +0,0 @@
<!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
@@ -1,71 +0,0 @@
// 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
@@ -1,113 +0,0 @@
/* 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);
}