Compare commits

...
9 Commits
Author SHA1 Message Date
Pierre MARTINandClaude Opus 5.5 c411771e48 Étape 0b close : plan supprimé, feuille de route et journal à jour
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 19:23:43 +01:00
Pierre MARTIN 07dd36605c Merge remote-tracking branch 'origin/main' into wasm 2026-11-02 19:20:31 +01:00
Pierre MARTINandClaude Opus 5.5 d83dbfb1e5 Plan 0b : résultat, documentation de la suite WebAssembly
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 19:20:04 +01:00
Pierre MARTINandClaude Opus 5.5 120367e6d3 Page de test et page de mesure du moteur WebAssembly
web/ : le moteur tourne dans un Web Worker (module ES) ; la page de
test donne le meilleur coup d'une position et son temps de calcul ;
la page de mesure chronomètre les positions de référence, mesure la
taille téléchargée et la mémoire, compare aux seuils de l'étape 0b et
produit un rapport à copier. make bench fait la même mesure sous Node,
make deploy-web sert les pages sur le réseau local. Guide de test pour
le Fairphone 4 dans docs/guide-test-etape-0.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 19:13:04 +01:00
Pierre MARTINandClaude Opus 5.5 b0030521d8 Moteur en WebAssembly : make engine-wasm et suite de contrat
Le moteur et engine-cli sont compilés avec Emscripten (image figée
emscripten/emsdk:3.1.64), en C générique, sans threads ; les données
de build/data/ sont incluses dans engine.data. Le JavaScript appelle
engine_init une fois puis main pour chaque commande : la sortie est le
JSON d'engine-cli, sans code de mise en forme dupliqué.

tests/test_wasm_reference.sh rejoue les 32 positions de référence sous
Node avec le même comparateur (option --cli) et la même tolérance.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 19:07:01 +01:00
pierre 39052215b1 Fusion de la MR !8 : étape 0a.4, comparaison aux références 2026-11-02 18:04:52 +00:00
Pierre MARTINandClaude Opus 5.5 4b8e2f73cc ADR 0004 amendé : outils de l'étape 0 validés par Pierre
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 19:04:24 +01:00
Pierre MARTIN 12a90aec03 Merge remote-tracking branch 'origin/main' into comparaison 2026-11-02 19:03:47 +01:00
pierre 51801e8491 Fusion de la MR !7 : étape 0a.3, moteur embarqué et engine-cli 2026-11-02 18:03:46 +00:00
25 changed files with 1060 additions and 113 deletions
+8
View File
@@ -41,6 +41,14 @@ Les contenus pédagogiques (leçons, textes) sont sous licence CC BY-SA 4.0.
**Python 3** (licence PSF, compatible GPL), qui exécute le comparateur
`tests/reference/compare.py`, et **jq** (licence MIT), qui lit les JSON
dans `tests/test_reference.sh`. Aucun des deux n'est utilisé sur le PC.
Le serveur HTTP de Python sert aussi la page de test (`make deploy-web`).
- **Emscripten** (licence MIT et University of Illinois/NCSA), image Docker
`emscripten/emsdk:3.1.64`, compile le moteur en WebAssembly
(`make engine-wasm`). Le code JavaScript qu'il génère
(`build/wasm/engine.mjs`) et sa bibliothèque C (musl, licence MIT) sont
inclus dans le moteur WebAssembly, licences compatibles GPLv3. L'image
fournit aussi **Node.js** (MIT), qui rejoue la suite de contrat sur le
moteur WebAssembly et lance `make bench`.
## Remerciements
+34 -2
View File
@@ -18,14 +18,19 @@ REFERENCE_IMAGE := ustagammon/gnubg-reference:14b6798
# Chaîne de compilation C (docker/build-c/Dockerfile) : changer l'étiquette
# à chaque modification du Dockerfile, pour que make setup la reconstruise.
BUILD_C_IMAGE := ustagammon/build-c:2
# Emscripten (compilation WebAssembly ; contient aussi Node et Python 3).
WASM_IMAGE := emscripten/emsdk:3.1.64
# Port de la page de test servie sur le réseau local (make deploy-web).
WEB_PORT := 8080
export SHELLCHECK_IMAGE GHERKIN_IMAGE BUILD_C_IMAGE
export SHELLCHECK_IMAGE GHERKIN_IMAGE BUILD_C_IMAGE WASM_IMAGE
# Lance une commande dans la chaîne C, sous l'utilisateur courant pour que
# build/ lui appartienne.
DOCKER_C := docker run --rm --user "$$(id -u):$$(id -g)" -v "$(CURDIR):/src" -w /src $(BUILD_C_IMAGE)
DOCKER_WASM := docker run --rm --user "$$(id -u):$$(id -g)" -e HOME=/tmp -v "$(CURDIR):/src" -w /src $(WASM_IMAGE)
.PHONY: help setup build engine test lint deploy clean reference-image reference
.PHONY: help setup build engine engine-wasm bench test lint deploy deploy-web clean reference-image reference
help: ## Liste les commandes disponibles
@echo "Ustagammon : commandes disponibles"
@@ -41,6 +46,8 @@ setup: ## Installe la chaîne d'outils (images Docker) ; sans effet si déjà fa
@$(MAKE) reference-image
@docker image inspect $(BUILD_C_IMAGE) >/dev/null 2>&1 \
|| docker build -t $(BUILD_C_IMAGE) docker/build-c
@docker image inspect $(WASM_IMAGE) >/dev/null 2>&1 \
|| docker pull $(WASM_IMAGE)
@echo "Chaîne d'outils prête."
build: engine ## Compile le moteur (si nécessaire) puis l'application
@@ -50,6 +57,14 @@ engine: ## Compile le moteur, ses données et build/engine-cli (dans Docker)
|| { echo "Chaîne C absente : lancer d'abord make setup"; exit 1; }
@$(DOCKER_C) make -f engine/engine.mk -j2 --no-print-directory
engine-wasm: engine ## Compile le moteur en WebAssembly dans build/wasm/ (dans Docker)
@docker image inspect $(WASM_IMAGE) >/dev/null 2>&1 \
|| { echo "Emscripten absent : lancer d'abord make setup"; exit 1; }
@$(DOCKER_WASM) make -f engine-wasm/wasm.mk -j2 --no-print-directory
bench: engine-wasm ## Mesure le moteur WebAssembly sous Node, sur ce PC (rapport texte)
@$(DOCKER_WASM) node engine-wasm/bench.mjs
test: ## Lance tous les tests
@tests/run.sh
@@ -58,8 +73,25 @@ lint: ## Vérifie le style et les erreurs courantes
deploy: ## Installe l'application sur le téléphone (pas encore disponible)
@echo "Pas encore disponible : le socle (PWA ou Android) sera choisi à la fin de l'étape 0."
@echo "Page de test du moteur WebAssembly : make deploy-web"
@exit 1
# Sert web/ à la racine et build/wasm/ sous /wasm/ avec le serveur HTTP de
# Python (types MIME corrects, dont application/wasm). Les deux dossiers sont
# montés en lecture seule puis copiés dans le conteneur au démarrage : relancer
# après une modification.
# L'adresse affichée est celle du PC sur l'interface de sa route par défaut,
# c'est-à-dire sur le réseau local (Wi-Fi ou câble).
deploy-web: engine-wasm ## Sert la page de test WebAssembly sur le réseau local (Ctrl+C pour arrêter)
@ip="$$(ip -4 route get 1.1.1.1 2>/dev/null | awk '{ for (i = 1; i < NF; i++) if ($$i == "src") print $$(i + 1) }')"; \
echo "Page de test : http://$${ip:-ADRESSE-DU-PC}:$(WEB_PORT)/"; \
echo "Page de mesure : http://$${ip:-ADRESSE-DU-PC}:$(WEB_PORT)/bench.html"; \
echo "À ouvrir depuis le téléphone, sur le même Wi-Fi. Ctrl+C pour arrêter."
@docker run --rm --init -p $(WEB_PORT):8080 \
-v "$(CURDIR)/web:/web:ro" -v "$(CURDIR)/build/wasm:/wasm:ro" $(BUILD_C_IMAGE) \
sh -c 'cp -r /web /tmp/site && cp -r /wasm /tmp/site/wasm \
&& exec python3 -m http.server 8080 --directory /tmp/site'
clean: ## Supprime les artefacts de build (garde les images Docker)
rm -rf $(BUILD_DIR)
@@ -28,3 +28,21 @@ par `make setup`. Premier cas : ShellCheck (GPLv3), image
- `make lint` demande d'avoir lancé `make setup` une fois ; il le dit
explicitement sinon.
- Chaque nouvelle image est signalée avec sa licence dans `CREDITS.md`.
## Amendements
### 2026-11-02 — Outils validés pour l'étape 0
Validé par Pierre. Tous tournent dans Docker et servent seulement à
construire ou tester ; rien n'est embarqué dans l'application.
| Outil | Licence | Image | Usage |
| --- | --- | --- | --- |
| ShellCheck | GPLv3 | `koalaman/shellcheck-alpine:v0.10.0` | Lint des scripts shell |
| Node.js, `@cucumber/gherkin` | MIT | `ustagammon/gherkin` (`docker/gherkin/`) | Validation des scénarios |
| Debian trixie, GNU Backgammon | logiciels libres, GPLv3+ | `ustagammon/gnubg-reference` | gnubg de bureau de référence |
| gcc, make | GPLv3 | `ustagammon/build-c` (`docker/build-c/`) | Compilation du moteur |
| GLib | LGPL 2.1+ | `ustagammon/build-c` | Outils officiels de génération des données (`makeweights`, `makebearoff`) |
| Python 3 | PSF | `ustagammon/build-c` | Comparateur des références |
| jq | MIT | `ustagammon/build-c` | Lecture du JSON dans les tests |
| Emscripten | MIT / NCSA | `emscripten/emsdk:3.1.64` | Compilation du moteur en WebAssembly (étape 0b) |
+3 -3
View File
@@ -12,12 +12,12 @@ Légende : 🔄 en cours · ⏳ à venir · ⏸ conditionnel
Acquis : socle du dépôt, organisation (ADR 0001, 0003, 0006), source du moteur
(ADR 0005 : gnubg officiel, commit 14b6798), gnubg de référence et 32 positions
de référence (0a.2), moteur embarqué et `engine-cli` (0a.3), comparaison
automatique aux références, tolérance 0,002 (0a.4).
automatique aux références, tolérance 0,002 (0a.4), moteur en WebAssembly avec
page de test, page de mesure et guide de test (0b).
| Sous-étape | Contenu | État | Plan |
| --- | --- | --- | --- |
| 0b | Moteur en WebAssembly, page de test dans un Web Worker, `make deploy-web`, `make bench` | 🔄 | `plans/2026-11-02-moteur-webassembly.md` |
| Mesures | Pierre relève les mesures sur le Fairphone 4 | ⏳ | |
| Mesures | Pierre relève les mesures sur le Fairphone 4, en suivant `docs/guide-test-etape-0.md` | 🔄 | |
| Décision | Socle PWA ou Android natif, consigné dans un ADR — validation de Pierre | ⏳ | |
| 0c | Android natif (NDK, Gradle en ligne de commande, `make deploy` via adb) | ⏸ si 0b échoue | |
| 0d | Plateau minimal jouable sur le socle retenu ; vérifié avec Pierre en suivant `docs/scenarios/jouer-une-partie.feature` | ⏳ | |
+85
View File
@@ -0,0 +1,85 @@
# Guide de test : le moteur sur le Fairphone 4 (étape 0b)
Document temporaire de l'étape 0 : il sera supprimé avec `docs/etape-0.md`.
Le but : ouvrir sur le téléphone une page web où le moteur de backgammon
calcule, puis lancer une mesure et coller son rapport dans la conversation.
Comptez un quart d'heure.
## 1. Préparer le PC
1. Ouvrez un terminal dans le dossier du projet (`~/Code/ustagammon`).
2. Si ce n'est pas déjà fait, tapez `make setup` puis Entrée. Cela prépare les
outils. La première fois, c'est long ; ensuite, c'est immédiat.
3. Le PC et le téléphone doivent être sur **le même Wi-Fi** (la même box).
## 2. Lancer la page
1. Tapez `make deploy-web` puis Entrée.
2. La première fois, le moteur se compile : quelques minutes.
3. Le terminal affiche deux adresses, par exemple :
```
Page de test : http://192.168.1.21:8080/
Page de mesure : http://192.168.1.21:8080/bench.html
```
Vos chiffres peuvent être différents : notez ceux de votre écran.
4. Laissez ce terminal ouvert pendant tout le test. Pour arrêter la page à la
fin : Ctrl+C dans le terminal.
## 3. Essayer un meilleur coup
1. Sur le Fairphone, ouvrez le navigateur (Chrome ou Firefox).
2. Tapez l'adresse de la **page de test** dans la barre d'adresse, exactement
comme affichée (avec `http://` et `:8080`).
3. Attendez le message « Moteur prêt, chargé en … ms ».
4. Dans « Exemple », choisissez une position, par exemple « middlegame-1 ».
5. Dans « Niveau », choisissez « fort (2 plis) ».
6. Touchez « Meilleur coup ».
7. La page affiche le coup conseillé et le **temps de calcul**. Notez ce temps
si vous voulez, il sera aussi dans le rapport.
## 4. Lancer la mesure
1. En bas de la page, touchez « Page de mesure » (ou tapez l'adresse de la
page de mesure).
2. Touchez « Lancer la mesure ».
3. Attendez « Mesure terminée » : de une à quelques minutes. Pendant ce temps,
gardez l'écran allumé et la page ouverte, sans changer d'application.
4. Un tableau résume les résultats : « OK » si le seuil est respecté,
« dépassé » sinon.
## 5. Envoyer le rapport
1. Touchez « Copier les résultats ».
2. Si la page dit « Rapport copié », c'est bon.
3. Sinon, le texte du rapport est sélectionné : faites un appui long dessus,
puis « Copier ».
4. Collez le rapport dans la conversation avec Claude Code (sur le téléphone,
ou envoyez-le vous sur le PC).
Si vous en avez le temps, refaites la mesure une deuxième fois (rechargez la
page de mesure) et collez aussi ce second rapport : cela montre si les temps
sont stables. Si les deux navigateurs sont installés, faites la mesure dans
Chrome **et** dans Firefox : sur le PC, Firefox s'est montré nettement plus
lent, et le rapport indique le navigateur utilisé.
## Si la page ne s'ouvre pas
- **Vérifiez l'adresse** : `http://` (et non `https://`), les chiffres exacts,
et `:8080` à la fin.
- **Vérifiez le Wi-Fi** : le téléphone doit être sur le même réseau que le PC,
pas sur la 4G ni sur un Wi-Fi invité.
- **Vérifiez que la page tourne** : sur le PC, ouvrez
`http://localhost:8080/` dans un navigateur. Si elle ne s'ouvre pas non
plus, relancez `make deploy-web` et lisez le message d'erreur.
- **Le pare-feu du PC** peut bloquer le téléphone. S'il est actif
(`sudo ufw status` affiche « Status: active »), autorisez le port le temps
du test : `sudo ufw allow 8080/tcp`, puis à la fin `sudo ufw delete allow
8080/tcp`.
- **« Le moteur n'a pas pu se charger »** : notez le message exact et
collez-le dans la conversation. Essayez aussi l'autre navigateur (Chrome ou
Firefox), à jour.
- **Le port 8080 est déjà pris** : un autre programme l'utilise. Lancez
`make deploy-web WEB_PORT=8081` et utilisez `:8081` dans l'adresse.
+22 -7
View File
@@ -38,14 +38,29 @@ Une entrée datée par session : ce qui a été fait, décidé, et la suite.
- Les hash de commit s'écrivent sans backticks, pour les liens automatiques de
Gitea (demande de Pierre).
**Incident ouvert**
- La MR !1 a bien été fusionnée côté git (commit de fusion 6958acc sur `main`),
mais Gitea l'affiche toujours « open ». Cause non établie ; la base de
données du serveur refuse déjà les émojis (erreur MySQL 1366), piste à
vérifier dans les journaux de Gitea.
**Incident (clos)**
- Les MR fusionnées restaient « open » dans Gitea : les hooks git du serveur ne
s'exécutaient plus, sur toute l'instance (diagnostic par un sous-agent, en
lecture seule ; brief transmis à Pierre). Pierre a réparé les hooks et
marqué les MR !1 à !8 comme fusionnées. Les branches fusionnées ont été
supprimées du serveur.
**Suite de la session (après-midi)**
- ADR 0007 (architecture et conventions de code) et ADR 0008 (glossaire et
scénarios, source unique), puis amendements : scénarios en Gherkin standard
validés par l'analyseur officiel, ADR amendables (0001).
- Étape 0a terminée, par sous-agents : gnubg de référence et 32 positions
(0a.2, MR !6) ; moteur embarqué, `engine.h` et `engine-cli` (0a.3, MR !7) ;
comparaison automatique aux références, tolérance 0,002, deux défauts du
moteur corrigés (0a.4, MR !8).
- Outils de compilation et de test validés par Pierre (amendement de
l'ADR 0004).
- Étape 0b : moteur en WebAssembly, même suite de contrat (écart maximal
9,5e-4), 7,3 Mo compressés, page de test et de mesure, `make deploy-web`,
guide de test `docs/guide-test-etape-0.md`.
**Reste à faire**
- Étape 0a.2 : image Docker de gnubg de bureau compilé depuis le même commit,
et 30 positions de référence ou plus.
- Mesures sur le Fairphone 4 avec Pierre, puis décision du socle (ADR).
- Étape 0d : plateau minimal jouable sur le socle retenu.
- `adb` n'est pas installé sur le PC : `sudo apt install adb` avant les tests sur
le téléphone.
@@ -1,92 +0,0 @@
# Moteur en WebAssembly : page de test, contrat et mesures
- **Date** : 2026-11-02
- **Étape** : 0b
- **Statut** : en cours
- **Exécutant** : sous-agent general-purpose, dans un worktree isolé
## Objectif
Le même moteur (`engine/`, `engine.h`) compilé en WebAssembly tourne dans un
Web Worker d'une page web minimale servie sur le réseau local. Il donne les
mêmes résultats que le moteur natif sur les 32 positions de référence, et une
page de mesure produit les chiffres qui permettront de trancher entre PWA et
Android natif (`docs/etape-0.md`, section 0b).
## Consignes
1. **Compilation** : `make engine-wasm` compile le moteur avec Emscripten, dans
l'image `emscripten/emsdk:3.1.64` (déjà téléchargée ; version figée),
en C générique (pas de SIMD), sans threads, `-j2`. Sorties dans
`build/wasm/`. Les données (`gnubg.weights` ou `gnubg.wd`, bases `.bd`,
table d'équité) viennent de `build/data/` produit par `make engine`, et sont
chargées une fois (système de fichiers virtuel d'Emscripten ou équivalent
simple). Recompilation minimale si rien n'a changé. Cible visible dans
`make help`.
2. **Interface JavaScript minimale** au-dessus d'`engine.h` (par exemple
`bestMoves`, `cubeDecision`, `evaluate`, `reference`), qui renvoie les mêmes
objets JSON que `engine-cli`. Réutiliser le code de mise en JSON
d'`engine-cli` plutôt que de le réécrire, si c'est simple.
3. **Même suite de contrat** (ADR 0007, point 7) : rejouer les 32 positions de
référence aux niveaux 0 et 2 plis avec le build WebAssembly, sous Node (dans
l'image Emscripten, qui contient Node, ou une image Node figée), avec le
même comparateur `tests/reference/compare.py` et la même tolérance. Le plus
simple : un petit programme Node qui imite l'interface en ligne de commande
d'`engine-cli`, et une option du comparateur pour choisir le programme
interrogé. Test lancé par `make test` (`tests/test_wasm_reference.sh`).
4. **Page de test `web/`** (HTML, CSS et JavaScript sans framework ni étape de
build) : moteur chargé dans un Web Worker, jamais sur le fil d'affichage ;
saisie d'un Position ID, d'un Match ID et des dés ; bouton « Meilleur coup » ;
affichage du résultat et du temps de calcul. Textes en français, identifiants
en anglais.
5. **Page de mesure** (dans `web/`) : sur une série de positions de référence,
temps du meilleur coup à 0 et 2 plis et de la décision de videau à 2 plis
(médiane et maximum), temps de chargement du moteur, taille totale
téléchargée (non compressée et compressée gzip), mémoire WebAssembly
utilisée. Un bouton « Copier les résultats » met un rapport texte dans le
presse-papiers, que Pierre collera dans la conversation. La page compare
elle-même aux seuils de `docs/etape-0.md`.
6. **`make deploy-web`** sert `web/` et `build/wasm/` sur le réseau local
(serveur dans Docker, rien sur le PC ; types MIME corrects dont
`application/wasm`), et affiche l'adresse exacte à ouvrir depuis le
téléphone (IP du PC sur le réseau local). Le serveur s'arrête par Ctrl+C.
Mettre à jour la cible `deploy` si pertinent.
7. **`make bench`** : la même mesure que la page, sous Node, sur le PC, pour un
premier ordre de grandeur (le chiffre qui compte reste celui du téléphone).
8. **Guide de test** pour Pierre : `docs/guide-test-etape-0.md` (document
temporaire de l'étape 0), pas à pas : préparer le PC, lancer
`make deploy-web`, ouvrir l'adresse sur le Fairphone 4 (même Wi-Fi), faire un
essai de meilleur coup, lancer la page de mesure, copier le rapport et le
coller dans la conversation. Phrases courtes, aucune connaissance technique
supposée, que faire si la page ne s'ouvre pas (pare-feu, réseau).
Contraintes :
- respecter `CLAUDE.md` et les ADR 0004, 0005, 0007 : simplicité, pas de
framework, identifiants en anglais, commentaires et textes en français, tout
outil dans Docker ;
- ne pas modifier `engine/gnubg/` ni les références ; toute adaptation du
moteur passe par `engine/compat/` ou le code d'Ustagammon ;
- signaler chaque nouvelle dépendance et sa licence dans `CREDITS.md`
(Emscripten : MIT/NCSA) ;
- `make lint` et `make test` doivent passer ; commits petits, en français, avec
la ligne `Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>` ;
- ne pas pousser ni créer de merge request ;
- ne rien simuler : les mesures viennent d'exécutions réelles.
Rapport attendu : ce qui a été fait, commandes de vérification, taille des
fichiers téléchargés, mesures `make bench` sur le PC, résultat de la suite de
contrat en WebAssembly (écart maximal), difficultés, écarts au plan.
## Critères de réussite
- `make engine-wasm` puis `make test` : les 32 positions concordent en
WebAssembly avec la même tolérance qu'en natif.
- `make deploy-web` affiche une adresse ; la page donne le meilleur coup d'une
position de référence avec son temps de calcul (vérifié dans un navigateur
sans interface si possible, sinon par une requête HTTP sur les fichiers).
- `make bench` produit un rapport.
- `make lint` et `make test` passent.
## Résultat
À remplir à la clôture.
+7
View File
@@ -22,6 +22,11 @@
* --ply : niveau de gnubg, de 0 (défaut) à 4.
* --data : dossier des données ; par défaut « data » à côté du programme.
*
* En WebAssembly (engine-wasm/), le même programme sert d'interface : le
* JavaScript appelle main pour chaque commande, sans recharger le moteur.
* engine_init ne fait rien quand le moteur est déjà prêt, et main ne l'arrête
* pas à la fin.
*
* Sortie : un objet JSON sur la sortie standard. En cas d'erreur, un objet
* {"error": {"code": ..., "message": ...}} et un code de sortie non nul :
* 1 pour une erreur du moteur, 2 pour une erreur d'utilisation.
@@ -391,6 +396,8 @@ main(int argc, char **argv)
else
result = usage_error("commande inconnue : bestmove, moves, legal, cube, eval ou reference");
#ifndef __EMSCRIPTEN__
engine_shutdown();
#endif
return result;
}
+44
View File
@@ -0,0 +1,44 @@
// bench.mjs -- la mesure de la page bench.html, sous Node, sur le PC
// (`make bench`). Premier ordre de grandeur seulement : le chiffre qui compte
// est celui du téléphone.
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
import { readFileSync } from "node:fs";
import os from "node:os";
import { fileURLToPath } from "node:url";
import { gzipSync } from "node:zlib";
import createEngineModule from "../build/wasm/engine.mjs";
import { DOWNLOADED_FILES, formatReport, measureTimes } from "../web/benchmark.mjs";
import { commands, loadEngine } from "../web/engine-wasm.mjs";
const root = new URL("../", import.meta.url);
const wasmDir = fileURLToPath(new URL("build/wasm/", root));
// Les fichiers du site : web/, et build/wasm/ servi sous wasm/.
function sitePath(name) {
const path = name.startsWith("wasm/") ? "build/" + name : "web/" + name;
return fileURLToPath(new URL(path, root));
}
const files = DOWNLOADED_FILES.map((name) => {
const content = readFileSync(sitePath(name));
return { name, bytes: content.length, gzipBytes: gzipSync(content).length };
});
const start = performance.now();
const engine = await loadEngine(createEngineModule, wasmDir);
const loadMs = performance.now() - start;
const { positions } = JSON.parse(readFileSync(sitePath("wasm/positions.json"), "utf8"));
const times = await measureTimes({
bestMove: (...args) => engine.run(commands.bestMove(...args)),
cubeDecision: (...args) => engine.run(commands.cubeDecision(...args)),
}, positions, (done, total) => process.stderr.write("\r " + done + "/" + total + " calculs"));
process.stderr.write("\n");
const environment = "Node " + process.version + ", " + os.cpus()[0].model + " (PC de développement)";
process.stdout.write(formatReport({
environment, files, loadMs, memoryBytes: engine.memoryBytes(), times,
}));
+24
View File
@@ -0,0 +1,24 @@
// engine-cli.mjs -- engine-cli, mais avec le moteur WebAssembly, sous Node.
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
//
// Mêmes arguments, même sortie et mêmes codes de sortie que build/engine-cli
// (voir engine-cli/main.c) : c'est le même main, compilé en WebAssembly.
// Sert à rejouer la suite de contrat sur le build WebAssembly
// (tests/test_wasm_reference.sh) :
//
// node engine-wasm/engine-cli.mjs --ply 2 reference 4HPwATDgc/ABMA cIkFAAAAAAAA 31
//
// L'option --data est ignorée : les données sont incluses dans
// build/wasm/engine.data.
import { fileURLToPath } from "node:url";
import createEngineModule from "../build/wasm/engine.mjs";
import { loadEngine } from "../web/engine-wasm.mjs";
const wasmDir = fileURLToPath(new URL("../build/wasm/", import.meta.url));
const engine = await loadEngine(createEngineModule, wasmDir);
const { text, exitCode } = engine.runText(process.argv.slice(2));
console.log(text);
process.exitCode = exitCode;
+80
View File
@@ -0,0 +1,80 @@
# wasm.mk -- compilation du moteur en WebAssembly avec Emscripten.
#
# Lancé dans l'image Docker emscripten/emsdk par `make engine-wasm` (Makefile
# racine), depuis la racine du dépôt, après `make engine` qui produit les
# données dans build/data/. Tout est produit dans build/wasm/ :
#
# engine.mjs module JavaScript (ES) qui charge le WebAssembly
# engine.wasm le moteur et engine-cli compilés : mêmes sources qu'en
# natif (engine/engine.mk), en C générique, sans threads
# engine.data les données de build/data/, chargées une fois dans le
# système de fichiers virtuel d'Emscripten, sous /data
# positions.json les positions de référence, pour la page de mesure
#
# Le JavaScript appelle engine_init une fois, puis main (celui
# d'engine-cli/main.c) pour chaque commande : voir web/engine-wasm.mjs.
BUILD := build
OUT := $(BUILD)/wasm
GNUBG := engine/gnubg
COMPAT := engine/compat
CC := emcc
include engine/sources.mk
# Mêmes options que le build natif : le moteur calcule comme en natif.
OPTIMISATION := -O3 -ffast-math -DG_DISABLE_ASSERT
OUR_WARNINGS := -std=c11 -Wall -Wextra -Werror
ENGINE_INCLUDES := -I$(COMPAT) -I$(COMPAT)/glib-shim -I$(GNUBG) -I$(GNUBG)/lib -Iengine/include
# Édition des liens :
# - module ES « createEngineModule », utilisable dans un Web Worker et sous Node ;
# - main n'est pas lancé au chargement, et le programme ne s'arrête pas à sa
# sortie : le moteur reste chargé entre deux commandes ;
# - mémoire extensible : les bases de fin de partie occupent plusieurs
# mégaoctets.
LINK_FLAGS := -O3 \
-sMODULARIZE -sEXPORT_ES6 -sEXPORT_NAME=createEngineModule \
-sENVIRONMENT=web,worker,node \
-sINVOKE_RUN=0 -sEXIT_RUNTIME=0 \
-sALLOW_MEMORY_GROWTH \
-sEXPORTED_FUNCTIONS=_main,_engine_init \
-sEXPORTED_RUNTIME_METHODS=callMain,ccall,HEAPU8 \
--preload-file $(BUILD)/data@/data
DATA := $(BUILD)/data/gnubg.wd $(BUILD)/data/gnubg_os0.bd $(BUILD)/data/gnubg_ts0.bd \
$(BUILD)/data/Kazaross-XG2.xml
OBJECTS := $(GNUBG_ENGINE:%.c=$(OUT)/obj/gnubg/%.o) \
$(OUT)/obj/compat/glib-shim.o $(OUT)/obj/compat/gnubg-support.o \
$(OUT)/obj/engine.o $(OUT)/obj/engine-cli/main.o
.PHONY: all
all: $(OUT)/engine.mjs $(OUT)/positions.json
$(OUT)/obj/gnubg/%.o: $(GNUBG)/%.c
@mkdir -p $(@D)
$(CC) $(OPTIMISATION) $(ENGINE_INCLUDES) -MMD -MP -c $< -o $@
$(OUT)/obj/compat/%.o: $(COMPAT)/%.c
@mkdir -p $(@D)
$(CC) $(OPTIMISATION) $(OUR_WARNINGS) $(ENGINE_INCLUDES) -MMD -MP -c $< -o $@
$(OUT)/obj/engine.o: engine/engine.c
@mkdir -p $(@D)
$(CC) $(OPTIMISATION) $(OUR_WARNINGS) $(ENGINE_INCLUDES) -MMD -MP -c $< -o $@
$(OUT)/obj/engine-cli/%.o: engine-cli/%.c
@mkdir -p $(@D)
$(CC) $(OPTIMISATION) $(OUR_WARNINGS) -Iengine/include -MMD -MP -c $< -o $@
# engine.wasm et engine.data sont produits en même temps qu'engine.mjs.
$(OUT)/engine.mjs: $(OBJECTS) $(DATA) engine-wasm/wasm.mk
$(CC) $(OBJECTS) $(LINK_FLAGS) -o $@
$(OUT)/positions.json: tests/reference/positions.json
@mkdir -p $(@D)
cp $< $@
-include $(shell find $(OUT) -name '*.d' 2>/dev/null)
+2 -5
View File
@@ -35,11 +35,8 @@ HOST_INCLUDES := -I$(COMPAT) -I$(GNUBG) -I$(GNUBG)/lib $(shell pkg-config --cfla
HOST_DEFINES := -DLOCALEDIR=\"/usr/share/locale\"
HOST_LIBS := $(shell pkg-config --libs glib-2.0) -lm
# Fichiers de gnubg compilés dans le moteur (liste : engine/gnubg/PROVENANCE.md).
GNUBG_CORE := eval.c positionid.c matchid.c matchequity.c mec.c bearoff.c \
bearoffgammon.c mtsupport.c lib/neuralnet.c lib/neuralnetsse.c lib/inputs.c \
lib/cache.c lib/list.c lib/isaac.c lib/md5.c
GNUBG_ENGINE := $(GNUBG_CORE) drawboard.c
# Fichiers de gnubg compilés dans le moteur : GNUBG_CORE et GNUBG_ENGINE.
include engine/sources.mk
ENGINE_OBJECTS := $(GNUBG_ENGINE:%.c=$(BUILD)/obj/gnubg/%.o) \
$(BUILD)/obj/compat/glib-shim.o $(BUILD)/obj/compat/gnubg-support.o \
+11
View File
@@ -0,0 +1,11 @@
# sources.mk -- fichiers de gnubg compilés dans le moteur (liste commentée :
# engine/gnubg/PROVENANCE.md). Partagé par la compilation native
# (engine/engine.mk) et WebAssembly (engine-wasm/wasm.mk).
# Cœur de gnubg, aussi lié aux outils hôte qui génèrent les données.
GNUBG_CORE := eval.c positionid.c matchid.c matchequity.c mec.c bearoff.c \
bearoffgammon.c mtsupport.c lib/neuralnet.c lib/neuralnetsse.c lib/inputs.c \
lib/cache.c lib/list.c lib/isaac.c lib/md5.c
# Fichiers du moteur embarqué.
GNUBG_ENGINE := $(GNUBG_CORE) drawboard.c
+7 -2
View File
@@ -232,8 +232,13 @@ comparateur `compare.py` (Python 3, dans l'image Docker de la chaîne C) lance
build/engine-cli --ply N reference POSITION_ID MATCH_ID [DÉS]
```
qui produit un objet de même structure que `plies["<n>"]` : `evaluation`,
puis `moves` (avec les dés) ou `cube` (sans). Le classement des coups suit la
La même suite est rejouée sur le moteur compilé en WebAssembly
(`tests/test_wasm_reference.sh`), avec le même comparateur et la même
tolérance : `--cli "node engine-wasm/engine-cli.mjs"` interroge, sous Node
dans l'image Emscripten, le même programme compilé en WebAssembly.
Le programme interrogé produit un objet de même structure que
`plies["<n>"]` : `evaluation`, puis `moves` (avec les dés) ou `cube` (sans). Le classement des coups suit la
méthode de `hint` décrite plus haut (`engine_best_moves` dans
`engine/engine.c`). Pour voir tous les nombres comparés :
`BUILD_C_IMAGE=ustagammon/build-c:2 bash tests/test_engine_reference.sh --details`.
+10 -2
View File
@@ -11,12 +11,19 @@ tests/test_engine_reference.sh) :
python3 tests/reference/compare.py [--cli build/engine-cli]
[--results tests/reference/results] [--tolerance 0.002] [--details]
--cli choisit le programme interrogé, qui doit imiter engine-cli ; ce peut
être une commande avec ses arguments. Pour le moteur WebAssembly
(tests/test_wasm_reference.sh, dans l'image Emscripten qui contient Node) :
python3 tests/reference/compare.py --cli "node engine-wasm/engine-cli.mjs"
"""
import argparse
import glob
import json
import os
import shlex
import subprocess
import sys
@@ -55,7 +62,7 @@ class Report:
def run_engine(cli, position, ply):
command = [cli, "--ply", str(ply), "reference", position["position_id"],
command = shlex.split(cli) + ["--ply", str(ply), "reference", position["position_id"],
position["match_id"]]
dice = position["context"]["dice"]
if dice:
@@ -142,7 +149,8 @@ def print_table(lines):
def main():
here = os.path.dirname(os.path.abspath(__file__))
parser = argparse.ArgumentParser(description="Compare engine-cli aux références de gnubg.")
parser.add_argument("--cli", default="build/engine-cli")
parser.add_argument("--cli", default="build/engine-cli",
help="programme interrogé, éventuellement avec ses arguments")
parser.add_argument("--results", default=os.path.join(here, "results"))
parser.add_argument("--tolerance", type=float, default=TOLERANCE)
parser.add_argument("--details", action="store_true",
+22
View File
@@ -0,0 +1,22 @@
#!/usr/bin/env bash
# Même suite de contrat que tests/test_engine_reference.sh (ADR 0007, point 7),
# rejouée sur le moteur compilé en WebAssembly : les 32 positions de
# référence aux niveaux 0 et 2 plis, avec le même comparateur
# (tests/reference/compare.py) et la même tolérance. Le moteur WebAssembly est
# interrogé sous Node par engine-wasm/engine-cli.mjs, qui imite engine-cli.
# Tout tourne dans l'image Emscripten, qui contient Node et Python 3.
#
# Lance `make engine-wasm` (sans effet si tout est à jour). Les arguments sont
# passés au comparateur, par exemple --details pour voir tous les nombres.
set -euo pipefail
cd "$(dirname "$0")/.."
image="${WASM_IMAGE:?lancer via make test}"
# Lancé depuis `make test` : repartir d'un make indépendant du make parent.
unset MAKEFLAGS MFLAGS MAKELEVEL
make engine-wasm >/dev/null
docker run --rm --user "$(id -u):$(id -g)" -e HOME=/tmp -v "$PWD:/src:ro" -w /src "$image" \
python3 tests/reference/compare.py --cli "node engine-wasm/engine-cli.mjs" "$@"
+97
View File
@@ -0,0 +1,97 @@
// 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).
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, chemins
// relatifs à la racine du site (web/ ; wasm/ est build/wasm/).
export const DOWNLOADED_FILES = [
"index.html",
"style.css",
"page.mjs",
"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";
}
+65
View File
@@ -0,0 +1,65 @@
// engine-client.mjs -- le moteur vu depuis la page : démarre le Web Worker
// (engine-worker.mjs) 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)),
};
}
+78
View File
@@ -0,0 +1,78 @@
// engine-wasm.mjs -- le moteur WebAssembly vu depuis JavaScript.
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
//
// Le WebAssembly (build/wasm/, voir engine-wasm/wasm.mk) contient le moteur
// et le programme engine-cli. On charge le module une fois, on initialise le
// moteur, puis chaque commande est un appel à main avec les mêmes arguments
// qu'engine-cli en ligne de commande : la sortie est donc le même JSON.
//
// Utilisé par le Web Worker de la page (engine-worker.mjs) et, sous Node,
// par engine-wasm/engine-cli.mjs et engine-wasm/bench.mjs. Le moteur calcule
// sur le fil qui l'appelle : dans le navigateur, toujours dans un Worker.
// Dossier des données dans le système de fichiers virtuel (--preload-file).
const DATA_DIR = "/data";
// createEngineModule : la fonction exportée par build/wasm/engine.mjs.
// wasmDir : où trouver engine.wasm et engine.data, terminé par « / » (une
// adresse dans le navigateur, un chemin de fichier sous Node).
// Renvoie le moteur prêt, avec :
// - runText(args) : { text, exitCode }, la sortie brute d'engine-cli ;
// - run(args) : { result, ms }, le JSON décodé et le temps de calcul
// (lève une erreur si le moteur en signale une) ;
// - memoryBytes() : taille actuelle de la mémoire WebAssembly.
export async function loadEngine(createEngineModule, wasmDir) {
let output = [];
const module = await createEngineModule({
locateFile: (name) => wasmDir + name,
print: (line) => output.push(line),
printErr: (line) => output.push(line),
});
const status = module.ccall("engine_init", "number", ["string"], [DATA_DIR]);
if (status !== 0) {
throw new Error("le moteur n'a pas pu charger ses données (code " + status + ")");
}
function runText(args) {
output = [];
const exitCode = module.callMain(args.map(String));
return { text: output.join("\n"), exitCode };
}
function run(args) {
const start = performance.now();
const { text } = runText(args);
const ms = performance.now() - start;
const result = JSON.parse(text);
if (result.error) {
throw new Error(result.error.message);
}
return { result, ms };
}
return {
runText,
run,
memoryBytes: () => module.HEAPU8.length,
};
}
// Arguments d'engine-cli pour chaque fonction de l'interface. Les dés sont
// un tableau [3, 1] ou une chaîne « 31 ».
export const commands = {
bestMove: (positionId, matchId, dice, ply) =>
["--ply", ply, "bestmove", positionId, matchId, diceText(dice)],
cubeDecision: (positionId, matchId, ply) =>
["--ply", ply, "cube", positionId, matchId],
evaluate: (positionId, matchId, ply) =>
["--ply", ply, "eval", positionId, matchId],
reference: (positionId, matchId, dice, ply) =>
["--ply", ply, "reference", positionId, matchId, ...(dice ? [diceText(dice)] : [])],
};
function diceText(dice) {
return Array.isArray(dice) ? dice.join("") : String(dice);
}
+38
View File
@@ -0,0 +1,38 @@
// engine-worker.mjs -- Web Worker qui fait tourner le moteur WebAssembly.
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
//
// Le moteur calcule ici, jamais sur le fil d'affichage de la page. Messages :
// - envoyé une fois au chargement : { type: "ready", loadMs, memoryBytes }
// ou { type: "failed", message } ;
// - reçu : { id, args }, les arguments d'une commande engine-cli ;
// - répondu : { id, result, ms, memoryBytes } ou { id, error }.
// Les commandes sont traitées une à une, dans l'ordre d'arrivée.
import createEngineModule from "./wasm/engine.mjs";
import { loadEngine } from "./engine-wasm.mjs";
const start = performance.now();
const wasmDir = new URL("wasm/", import.meta.url).href;
const ready = loadEngine(createEngineModule, wasmDir);
ready.then(
(engine) => postMessage({
type: "ready",
loadMs: performance.now() - start,
memoryBytes: engine.memoryBytes(),
}),
(error) => postMessage({ type: "failed", message: String(error.message || error) }),
);
onmessage = async (event) => {
const { id, args } = event.data;
try {
const engine = await ready;
const { result, ms } = engine.run(args);
postMessage({ id, result, ms, memoryBytes: engine.memoryBytes() });
} catch (error) {
postMessage({ id, error: String(error.message || error) });
}
};
+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></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);
}