Fusion de la MR !9 : étape 0b, moteur en WebAssembly
This commit was merged in pull request #9.
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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` | ⏳ | |
|
||||
| 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` | ⏳ | |
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}));
|
||||
@@ -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;
|
||||
@@ -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
@@ -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 \
|
||||
|
||||
@@ -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
|
||||
@@ -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`.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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" "$@"
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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)),
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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) });
|
||||
}
|
||||
};
|
||||
@@ -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>
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user