Fusion de la MR !13 : étape 0d.2, plateau jouable en PWA

This commit was merged in pull request #13.
This commit is contained in:
2026-11-03 00:02:38 +00:00
48 changed files with 2982 additions and 117 deletions
+6 -1
View File
@@ -41,7 +41,7 @@ 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`).
Le serveur HTTP de Python sert aussi le site sur le réseau local (`make deploy`).
- **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
@@ -57,6 +57,11 @@ Les contenus pédagogiques (leçons, textes) sont sous licence CC BY-SA 4.0.
`docs/scenarios/` ; **dependency-cruiser** 18.6.0 (MIT), qui vérifie les
règles d'architecture. Ces outils et leurs dépendances servent seulement à
compiler et tester : rien n'est embarqué dans l'application.
- **Playwright** 1.48.2 (Apache-2.0), image Docker
`mcr.microsoft.com/playwright:v1.48.2-noble` (Chromium, Firefox, WebKit,
sous leurs licences libres) et paquet npm `playwright`, installé dans
`build/browser-tools/` : joue une partie dans des navigateurs sans écran
(`make test-browser`). Outil de test seulement, hors de `make test`.
## Serveur de production
+38 -25
View File
@@ -23,12 +23,14 @@ WASM_IMAGE := emscripten/emsdk:3.1.64
# Outils de l'application web (docker/web/) : TypeScript, Biome, Cucumber,
# dependency-cruiser. Changer l'étiquette à chaque modification du Dockerfile.
WEB_IMAGE := ustagammon/web:1
# Port de la page de test servie sur le réseau local (make deploy-web).
# Port du site servi sur le réseau local (make deploy).
WEB_PORT := 8080
# Serveur statique de production (deploy/Dockerfile) : même version ici.
CADDY_IMAGE := caddy:2.8.4-alpine
# Navigateurs sans écran pour make test-browser (Playwright, Apache-2.0).
PLAYWRIGHT_IMAGE := mcr.microsoft.com/playwright:v1.48.2-noble
export SHELLCHECK_IMAGE GHERKIN_IMAGE BUILD_C_IMAGE WASM_IMAGE CADDY_IMAGE WEB_IMAGE
export SHELLCHECK_IMAGE GHERKIN_IMAGE BUILD_C_IMAGE WASM_IMAGE CADDY_IMAGE WEB_IMAGE PLAYWRIGHT_IMAGE
# Lance une commande dans la chaîne C, sous l'utilisateur courant pour que
# build/ lui appartienne.
@@ -36,7 +38,7 @@ DOCKER_C := docker run --rm --user "$$(id -u):$$(id -g)" -v "$(CURDIR):/src" -w
DOCKER_WASM := docker run --rm --user "$$(id -u):$$(id -g)" -e HOME=/tmp -v "$(CURDIR):/src" -w /src $(WASM_IMAGE)
DOCKER_WEB := docker run --rm --user "$$(id -u):$$(id -g)" -v "$(CURDIR):/src" -w /src $(WEB_IMAGE)
.PHONY: help setup build engine engine-wasm web bench test lint deploy deploy-web site package deploy-prod clean reference-image reference
.PHONY: help setup build engine engine-wasm web bench test test-browser lint deploy deploy-web site package deploy-prod clean reference-image reference
help: ## Liste les commandes disponibles
@echo "Ustagammon : commandes disponibles"
@@ -88,40 +90,51 @@ bench: engine-wasm ## Mesure le moteur WebAssembly sous Node, sur ce PC (rapport
test: ## Lance tous les tests
@tests/run.sh
# Hors de make test : plusieurs minutes, et une image de 3 Go, récupérée ici
# à la première utilisation plutôt que par make setup.
test-browser: ## Joue une partie dans Chromium et Firefox sans écran (lent)
@docker image inspect $(PLAYWRIGHT_IMAGE) >/dev/null 2>&1 || docker pull $(PLAYWRIGHT_IMAGE)
@tests/browser.sh
lint: ## Vérifie le style et les erreurs courantes
@tests/lint.sh
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)
# Sert le site assemblé (build/site/, voir make site) avec le serveur HTTP de
# Python. 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). En http sur le
# réseau local, le navigateur refuse le service worker : l'application
# fonctionne, mais pas hors ligne (il faut https, comme en production).
deploy: site ## Sert l'application 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 "Application : http://$${ip:-ADRESSE-DU-PC}:$(WEB_PORT)/"; \
echo "Test du moteur : http://$${ip:-ADRESSE-DU-PC}:$(WEB_PORT)/moteur/"; \
echo "Page de mesure : http://$${ip:-ADRESSE-DU-PC}:$(WEB_PORT)/moteur/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'
@docker run --rm --init -p $(WEB_PORT):8080 -v "$(CURDIR)/$(BUILD_DIR)/site:/site:ro" \
$(BUILD_C_IMAGE) python3 -m http.server 8080 --directory /site
# Le site tel qu'il sera publié : web/ à la racine, le moteur sous wasm/, et
# version.json (commit déployé). Reconstruit entièrement à chaque appel.
site: engine-wasm ## Assemble le site publié dans build/site/
deploy-web: deploy ## Comme make deploy (ancien nom, étape 0b)
# Le site tel qu'il sera publié, reconstruit entièrement à chaque appel :
# - web/ à la racine : la page de l'application, son style, le manifeste, les
# icônes, le Web Worker du moteur, et les pages de test sous moteur/ ;
# - l'application compilée (build/web/src/) sous app/ ;
# - le moteur sous wasm/ ;
# - les copies du glossaire et des scénarios sous docs/, lues par l'aide de
# l'application (ADR 0008 : jamais commitées ailleurs) ;
# - version.json (commit publié) ;
# - sw.js, complété par la liste de tous ces fichiers (scripts/service-worker.sh).
site: engine-wasm web ## Assemble le site publié dans build/site/
rm -rf $(BUILD_DIR)/site
mkdir -p $(BUILD_DIR)/site/wasm
mkdir -p $(BUILD_DIR)/site/wasm $(BUILD_DIR)/site/docs
cp -r web/. $(BUILD_DIR)/site/
cp -r $(BUILD_DIR)/web/src $(BUILD_DIR)/site/app
cp $(BUILD_DIR)/wasm/engine.mjs $(BUILD_DIR)/wasm/engine.wasm $(BUILD_DIR)/wasm/engine.data \
$(BUILD_DIR)/wasm/positions.json $(BUILD_DIR)/site/wasm/
cp -r docs/glossaire docs/scenarios $(BUILD_DIR)/site/docs/
printf '{"commit": "%s", "dirty": %s}\n' "$$(git rev-parse HEAD)" \
"$$(git diff --quiet HEAD && echo false || echo true)" > $(BUILD_DIR)/site/version.json
scripts/service-worker.sh $(BUILD_DIR)/site
# L'archive envoyée à CapRover : captain-definition, Dockerfile, Caddyfile et
# le site. CapRover construit l'image (une simple copie) sur le serveur.
+18
View File
@@ -24,6 +24,24 @@ module.exports = {
from: { path: "^src/ui/" },
to: { path: "^src/engine/" },
},
{
name: "only-main-assembles",
comment:
"Seul le point d'entrée (src/main.ts) assemble l'application : hors de " +
"leur propre dossier, le moteur (src/engine), le stockage (src/storage) " +
"et l'interface (src/ui) ne sont importés que par lui, et par les tests.",
severity: "error",
// $1 : le dossier de l'importateur (correspondance de groupe).
from: { path: "^src/([^/]+)/" },
to: { path: "^src/(engine|storage|ui)/", pathNot: "^src/$1/" },
},
{
name: "nothing-imports-main",
comment: "Le point d'entrée n'est importé par personne.",
severity: "error",
from: {},
to: { path: "^src/main\\.ts$" },
},
{
name: "no-circular",
comment: "Pas de dépendance circulaire.",
+104 -5
View File
@@ -9,25 +9,25 @@
// l'ordinateur, videau, fin de partie et points.
import { CENTERED_CUBE, type Cube } from "./cube.js";
import type { Engine, EngineCube, Ply } from "./engine.js";
import type { BoardSide, Engine, EngineCube, Ply } from "./engine.js";
import {
appendEvent,
currentLine,
EMPTY_JOURNAL,
EVENT_VERSION,
type GameEvent,
type GameJournal,
goBackTo,
type NewGameEvent,
} from "./events.js";
import { type HalfMove, type PositionId, START_POSITION } from "./position.js";
import { BAR, type HalfMove, OFF, type PositionId, START_POSITION } from "./position.js";
import { type DiceSource, type Roll, randomDice, rollDice } from "./roll.js";
import { otherSide, type Side } from "./side.js";
import { type GameState, replay } from "./state.js";
/**
* Niveau « standard » de l'ordinateur : 2 plis. C'est le niveau mesuré à
* l'étape 0b (au plus 0,3 s par coup sous Chromium et 2,3 s sous Firefox sur
* le PC de 2011) ; il reste à confirmer sur le téléphone.
* Niveau « standard » de l'ordinateur : 2 plis. Sur le Fairphone 4 (Chrome),
* le meilleur coup à ce niveau prend au plus 348 ms (ADR 0009).
*/
export const STANDARD_PLY: Ply = 2;
@@ -40,6 +40,13 @@ export interface GameSetup {
readonly cube?: Cube;
}
/** Le plateau vu de chaque camp, chacun avec sa propre numérotation des
* cases (sa case 1 est la plus proche de sa sortie). */
export interface SideBoards {
readonly player: BoardSide;
readonly computer: BoardSide;
}
export interface GameOptions {
/** Source des dés ; le hasard ordinaire par défaut. */
readonly dice?: DiceSource;
@@ -85,6 +92,25 @@ export class Game {
return game;
}
/**
* Reprend une partie enregistrée, à partir de son journal. Erreur si le
* journal est d'un autre format ou incohérent.
*/
static resume(engine: Engine, journal: GameJournal, options: GameOptions = {}): Game {
const first = journal.events[0];
if (first?.type !== "GameStarted" || journal.head === null) {
throw new Error("journal de partie vide ou mal formé");
}
if (journal.events.some((event) => event.version !== EVENT_VERSION)) {
throw new Error("journal de partie d'une autre version");
}
const game = new Game(engine, options);
game.#journal = journal;
// Vérifie que chaque événement de la ligne courante existe.
currentLine(journal);
return game;
}
/** Tous les événements, branches comprises, et l'événement courant. */
get journal(): GameJournal {
return this.#journal;
@@ -109,6 +135,68 @@ export class Game {
return this.#pendingPosition ?? this.state.position;
}
/** Le plateau affiché (après les demi-coups déjà saisis), décodé par le
* moteur. */
async displayedBoard(): Promise<SideBoards> {
const state = this.state;
// La position est vue du camp qui a la main (le joueur pendant le jet
// d'ouverture : la position de départ est la même pour les deux).
const onRoll = state.turn ?? "player";
const board = await this.#engine.board(
this.displayedPosition,
engineCube(state.cube, onRoll),
);
return onRoll === "player"
? { player: board.onRoll, computer: board.opponent }
: { player: board.opponent, computer: board.onRoll };
}
/**
* Les demi-coups que le joueur peut saisir maintenant, à la suite de ceux
* déjà saisis (liste vide hors de son tour de jeu). Chaque candidat
* déplace un de ses pions de la valeur d'un dé (vers la sortie si le dé
* dépasse la case 1) ; seuls restent ceux que le moteur déclare menant à
* un coup légal. Aucune règle n'est jugée ici.
*/
async nextHalfMoves(): Promise<HalfMove[]> {
const state = this.state;
if (state.turn !== "player" || state.phase !== "moving") {
return [];
}
const roll = rollToPlay(state);
const { player } = await this.displayedBoard();
const candidates = new Map<string, HalfMove>();
for (const from of occupiedPoints(player)) {
for (const die of roll) {
const to = Math.max(from - die, OFF);
candidates.set(`${from}/${to}`, { from, to });
}
}
const moves = [...candidates.values()];
const checks = await Promise.all(
moves.map((h) =>
this.#engine.checkHalfMoves(state.position, roll, [...this.#pending, h]),
),
);
return moves.filter((_, i) => checks[i]?.valid === true);
}
/** Le joueur peut-il doubler maintenant ? (Le moteur dit si le videau le
* permet.) */
async canDouble(): Promise<boolean> {
const state = this.state;
if (state.turn !== "player" || state.phase !== "rolling") {
return false;
}
// Le droit de doubler ne dépend pas du niveau : 0 pli suffit.
const decision = await this.#engine.cubeDecision(
state.position,
engineCube(state.cube, "player"),
0,
);
return decision.available;
}
// ------------------------------------------------------------- Joueur
/** Le joueur lance les dés. S'il ne peut pas jouer, son tour passe. */
@@ -304,6 +392,17 @@ function rollToPlay(state: GameState): Roll {
return state.roll;
}
/** Les cases d'où un camp a des pions à jouer : la barre, puis 24 à 1. */
function occupiedPoints(side: BoardSide): number[] {
const points = side.bar > 0 ? [BAR] : [];
for (let point = 24; point >= 1; point--) {
if ((side.points[point - 1] ?? 0) > 0) {
points.push(point);
}
}
return points;
}
/** Le videau vu du camp au trait, pour le moteur. */
function engineCube(cube: Cube, onRoll: Side): EngineCube {
if (cube.owner === null) {
+1
View File
@@ -1,6 +1,7 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"lib": ["ES2023"],
"types": [],
"noEmit": true
},
+81
View File
@@ -0,0 +1,81 @@
// Les commandes du moteur exécutées dans un Web Worker (web/engine-worker.mjs),
// pour que le calcul ne bloque jamais l'affichage.
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
//
// Messages du Worker (voir engine-worker.mjs) : une fois au chargement
// { type: "ready" } ou { type: "failed", message } ; puis, pour chaque
// commande { id, args } envoyée, { id, result } ou { id, error }. Le Worker
// traite les commandes une à une, dans l'ordre d'arrivée.
import type { RunCommand } from "./wasm-engine.js";
export interface EngineWorker {
/** Résolue quand le moteur est chargé ; rejetée s'il ne peut pas l'être. */
readonly ready: Promise<void>;
readonly run: RunCommand;
}
interface Pending {
resolve(result: unknown): void;
reject(error: Error): void;
}
export function startEngineWorker(url: URL): EngineWorker {
const worker = new Worker(url, { type: "module" });
const pending = new Map<number, Pending>();
let nextId = 1;
let failure: Error | null = null;
let markReady: () => void = () => {};
let markFailed: (error: Error) => void = () => {};
const ready = new Promise<void>((resolve, reject) => {
markReady = resolve;
markFailed = reject;
});
// Le moteur est inutilisable : toutes les commandes échouent.
function fail(error: Error): void {
failure = error;
markFailed(error);
for (const { reject } of pending.values()) {
reject(error);
}
pending.clear();
}
worker.onmessage = (event: MessageEvent) => {
const message = event.data;
if (message.type === "ready") {
markReady();
} else if (message.type === "failed") {
fail(new Error(message.message));
} else {
const request = pending.get(message.id);
pending.delete(message.id);
if (message.error !== undefined) {
request?.reject(new Error(message.error));
} else {
request?.resolve(message.result);
}
}
};
// Le Worker lui-même n'a pas démarré (fichier absent, navigateur trop
// ancien pour les Workers en module ES…).
worker.onerror = (event: ErrorEvent) => {
fail(new Error(event.message || "le Web Worker du moteur n'a pas démarré"));
};
function run(args: string[]): Promise<unknown> {
if (failure !== null) {
return Promise.reject(failure);
}
const id = nextId++;
return new Promise((resolve, reject) => {
pending.set(id, { resolve, reject });
worker.postMessage({ id, args });
});
}
return { ready, run };
}
+48
View File
@@ -0,0 +1,48 @@
// Point d'entrée de l'application dans le navigateur (index.html) : le seul
// endroit qui assemble le domaine, le moteur, le stockage et l'interface
// (ADR 0007, vérifié par dependency-cruiser).
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
import { Game } from "./domain/game/game.js";
import { createWasmEngine } from "./engine/wasm-engine.js";
import { startEngineWorker } from "./engine/worker-run.js";
import { createGameStore } from "./storage/game-store.js";
import { startApp } from "./ui/app.js";
// Ce fichier est servi sous app/ ; le Worker et le service worker sont à la
// racine du site.
const worker = startEngineWorker(new URL("../engine-worker.mjs", import.meta.url));
const engine = createWasmEngine(worker.run);
const store = createGameStore(window.localStorage);
startApp({
engineReady: worker.ready,
newGame: () => Game.start(engine),
savedGame: () => {
const journal = store.load();
if (journal === null) {
return null;
}
try {
return Game.resume(engine, journal);
} catch {
return null; // partie illisible : on en commence une nouvelle
}
},
saveGame: (journal) => store.save(journal),
confirm: (question) => window.confirm(question),
});
// Hors ligne : le service worker met tout le site en cache. Le navigateur le
// refuse hors https (sauf localhost) : l'application marche alors sans.
// Il est enregistré une fois le moteur chargé : si le Worker et le service
// worker téléchargent les données du moteur (8,7 Mo) en même temps, Chromium
// fait échouer l'installation (« Cache.add() encountered a network error »),
// et l'application n'est pas disponible hors ligne. Cela évite aussi de
// télécharger deux fois ces données à la première visite.
if ("serviceWorker" in navigator) {
const sw = new URL("../sw.js", import.meta.url);
worker.ready.then(() => navigator.serviceWorker.register(sw)).catch(() => {});
}
+47
View File
@@ -0,0 +1,47 @@
// L'enregistrement de la partie en cours, pour la reprendre après fermeture
// de la page : son journal d'événements, en JSON, dans un stockage clé-valeur
// (localStorage dans le navigateur).
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
import type { GameJournal } from "../domain/game/events.js";
/** Ce qu'il faut d'un stockage clé-valeur (localStorage en a davantage). */
export interface KeyValueStorage {
getItem(key: string): string | null;
setItem(key: string, value: string): void;
}
export interface GameStore {
/** Le journal enregistré, ou null s'il n'y en a pas de lisible. */
load(): GameJournal | null;
save(journal: GameJournal): void;
}
const KEY = "ustagammon.current-game";
export function createGameStore(storage: KeyValueStorage): GameStore {
return {
load() {
try {
const journal = JSON.parse(storage.getItem(KEY) ?? "null");
const valid =
journal !== null &&
Array.isArray(journal.events) &&
(journal.head === null || typeof journal.head === "string");
return valid ? journal : null;
} catch {
return null;
}
},
save(journal) {
try {
storage.setItem(KEY, JSON.stringify(journal));
} catch {
// Stockage plein ou interdit (navigation privée) : la partie
// continue, elle ne pourra seulement pas être reprise.
}
},
};
}
+373
View File
@@ -0,0 +1,373 @@
// L'écran de jeu : le plateau, les boutons et les messages, branchés sur une
// partie du domaine.
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
//
// Tout passe par la partie (Game) : l'interface ne connaît ni le moteur ni le
// stockage, qu'elle reçoit déjà branchés (main.ts). Après chaque action du
// joueur, la partie est enregistrée, puis l'ordinateur joue tant que c'est
// son tour ; le plateau est ensuite redessiné à partir de l'état de la partie.
import type { GameJournal } from "../domain/game/events.js";
import type { Game, SideBoards } from "../domain/game/game.js";
import { formatHalfMoves, type HalfMove } from "../domain/game/position.js";
import type { Side } from "../domain/game/side.js";
import { boardModel, moveTargets, type Place, placeOf } from "./board-model.js";
import { renderBoard, type ShownDie } from "./board-view.js";
import { showHelp } from "./help.js";
import { TEXTS } from "./texts.js";
export interface AppOptions {
/** Résolue quand le moteur est chargé, rejetée s'il ne peut pas l'être. */
readonly engineReady: Promise<unknown>;
/** Une nouvelle partie (le jet d'ouverture est déjà tiré). */
readonly newGame: () => Game;
/** La partie enregistrée, ou null s'il n'y en a pas. */
readonly savedGame: () => Game | null;
readonly saveGame: (journal: GameJournal) => void;
readonly confirm: (question: string) => boolean;
}
function byId<T extends HTMLElement>(id: string): T {
const element = document.getElementById(id);
if (element === null) {
throw new Error(`élément #${id} absent de la page`);
}
return element as T;
}
/** Démarre l'écran de jeu dans la page (index.html). */
export function startApp(options: AppOptions): void {
const ui = {
board: byId("board") as unknown as SVGSVGElement,
info: byId("info"),
prompt: byId("prompt"),
pips: byId("pips"),
roll: byId<HTMLButtonElement>("roll"),
double: byId<HTMLButtonElement>("double"),
take: byId<HTMLButtonElement>("take"),
drop: byId<HTMLButtonElement>("drop"),
cancel: byId<HTMLButtonElement>("cancel"),
again: byId<HTMLButtonElement>("again"),
newGame: byId<HTMLButtonElement>("new-game"),
helpButton: byId<HTMLButtonElement>("help-button"),
helpBack: byId<HTMLButtonElement>("help-back"),
game: byId("game"),
help: byId("help"),
helpContent: byId("help-content"),
};
ui.board.setAttribute("aria-label", TEXTS.boardLabel);
ui.roll.textContent = TEXTS.roll;
ui.double.textContent = TEXTS.double;
ui.take.textContent = TEXTS.take;
ui.drop.textContent = TEXTS.drop;
ui.cancel.textContent = TEXTS.cancel;
ui.again.textContent = TEXTS.newGame;
ui.newGame.textContent = TEXTS.newGame;
ui.helpButton.textContent = TEXTS.help;
ui.helpBack.textContent = TEXTS.back;
// État de l'écran, en plus de la partie elle-même.
let game: Game | null = null;
let sides: SideBoards | null = null;
let targets = new Map<Place, Map<Place, HalfMove>>();
let selected: Place | null = null;
let canDouble = false;
let busy = true;
let info = "";
// ------------------------------------------------------------ Dessin
/** Recalcule ce qui demande le moteur, puis redessine. */
async function refresh(): Promise<void> {
if (game === null) {
return;
}
sides = await game.displayedBoard();
targets = moveTargets(await game.nextHalfMoves());
canDouble = await game.canDouble();
if (selected !== null && !targets.has(selected)) {
selected = null;
}
render();
}
function render(): void {
if (game === null || sides === null) {
return;
}
const state = game.state;
const model = boardModel(sides);
renderBoard(ui.board, model, {
dice: shownDice(game),
cube: state.cube,
movable: new Set(targets.keys()),
selected,
targets: new Set(selected === null ? [] : (targets.get(selected)?.keys() ?? [])),
lastMove: lastComputerMove(game),
});
ui.pips.textContent = TEXTS.pipCounts(model.pipCount.player, model.pipCount.computer);
ui.info.textContent = info;
ui.prompt.textContent = prompt(game, selected !== null, canDouble);
ui.prompt.classList.toggle("thinking", state.turn === "computer" && isPlaying(game));
const player = state.turn === "player";
show(ui.roll, player && state.phase === "rolling");
show(ui.double, player && state.phase === "rolling");
ui.double.disabled = busy || !canDouble;
show(ui.cancel, player && state.phase === "moving");
ui.cancel.disabled = busy || game.pendingHalfMoves.length === 0;
const doubled = state.turn === "computer" && state.phase === "doubled";
show(ui.take, doubled);
show(ui.drop, doubled);
show(ui.again, state.phase === "over");
for (const button of [ui.roll, ui.take, ui.drop, ui.again, ui.newGame]) {
button.disabled = busy;
}
// Pour le style et pour les tests dans un navigateur : l'écran
// attend le moteur.
document.body.classList.toggle("busy", busy);
}
function show(button: HTMLButtonElement, visible: boolean): void {
button.hidden = !visible;
}
// ----------------------------------------------------------- Actions
function save(): void {
if (game !== null) {
options.saveGame(game.journal);
}
}
/** L'ordinateur joue tant que c'est son tour (il s'arrête s'il double). */
async function computerTurns(): Promise<void> {
while (game !== null && game.state.turn === "computer" && isPlaying(game)) {
await refresh();
const start = performance.now();
await game.playComputerTurn();
info = describeComputerTurn(game, performance.now() - start);
save();
}
}
/** Une action du joueur, puis le tour de l'ordinateur. Les touches sont
* ignorées pendant ce temps. */
async function act(action: (game: Game) => Promise<void> | void): Promise<void> {
if (busy || game === null) {
return;
}
busy = true;
selected = null;
targets = new Map();
render();
try {
await action(game);
save();
await computerTurns();
} catch (error) {
info = TEXTS.actionFailed(error instanceof Error ? error.message : String(error));
} finally {
busy = false;
await refresh();
}
}
function startNewGame(): void {
game = options.newGame();
const opening = game.events.findLast((e) => e.type === "DiceRolled");
info =
opening?.type === "DiceRolled" ? TEXTS.opening(opening.roll[0], opening.roll[1]) : "";
}
ui.roll.onclick = () =>
act(async (g) => {
info = "";
await g.roll();
const last = g.events.at(-1);
if (last?.type === "MovePlayed" && last.halfMoves.length === 0) {
info = TEXTS.youCannotMove(rollBefore(g));
}
});
ui.double.onclick = () =>
act(async (g) => {
await g.double();
info = g.state.phase === "over" ? "" : TEXTS.computerTakes(g.state.cube.value);
});
ui.take.onclick = () =>
act((g) => {
g.take();
info = TEXTS.youTook(g.state.cube.value);
});
ui.drop.onclick = () =>
act((g) => {
g.drop();
info = "";
});
ui.cancel.onclick = () => {
if (!busy && game !== null) {
game.cancelHalfMoves();
selected = null;
void refresh();
}
};
const newGame = () => {
if (busy) {
return;
}
const inProgress = game !== null && game.state.phase !== "over";
if (inProgress && !options.confirm(TEXTS.confirmNewGame)) {
return;
}
void act(() => startNewGame());
};
ui.newGame.onclick = newGame;
ui.again.onclick = newGame;
// Toucher le plateau : un pion jouable, puis une de ses cases d'arrivée.
ui.board.addEventListener("click", (event) => {
const target = event.target as Element | null;
const place = target?.closest("[data-place]")?.getAttribute("data-place") as Place | null;
if (busy || place == null) {
return;
}
const halfMove = selected === null ? undefined : targets.get(selected)?.get(place);
if (halfMove !== undefined) {
void act(async (g) => {
info = "";
await g.playHalfMove(halfMove);
});
return;
}
selected = targets.has(place) && place !== selected ? place : null;
render();
});
ui.helpButton.onclick = () => {
ui.game.hidden = true;
ui.help.hidden = false;
window.scrollTo(0, 0);
void showHelp(ui.helpContent);
};
ui.helpBack.onclick = () => {
ui.help.hidden = true;
ui.game.hidden = false;
};
// ---------------------------------------------------------- Démarrage
ui.prompt.textContent = TEXTS.loading;
for (const button of [ui.roll, ui.double, ui.take, ui.drop, ui.cancel, ui.again]) {
button.hidden = true;
}
ui.newGame.disabled = true;
options.engineReady.then(
async () => {
game = options.savedGame();
if (game === null) {
startNewGame();
save();
}
// Comme après une action du joueur : l'ordinateur joue si c'est
// à lui (partie nouvelle ou reprise pendant son tour).
try {
await computerTurns();
} finally {
busy = false;
await refresh();
}
},
(error: unknown) => {
ui.prompt.textContent = TEXTS.engineFailed(
error instanceof Error ? error.message : String(error),
);
},
);
}
// ------------------------------------------------- Lecture de la partie
/** La partie attend-elle encore un coup ou un jet (ni double, ni fin) ? */
function isPlaying(game: Game): boolean {
const phase = game.state.phase;
return phase === "rolling" || phase === "moving";
}
/** Le jet du dernier DiceRolled. */
function rollBefore(game: Game): readonly number[] {
const rolled = game.events.findLast((e) => e.type === "DiceRolled");
return rolled?.type === "DiceRolled" ? rolled.roll : [];
}
/** Les dés à montrer : le dernier jet, de la couleur de qui l'a lancé
* (au jet d'ouverture, un dé chacun). */
function shownDice(game: Game): ShownDie[] {
const rolled = game.events.findLast((e) => e.type === "DiceRolled");
if (rolled?.type !== "DiceRolled") {
return [];
}
const sides: Side[] =
rolled.side === null ? ["player", "computer"] : [rolled.side, rolled.side];
return rolled.roll.map((value, i) => ({ value, side: sides[i] ?? "player" }));
}
/** Les cases de départ et d'arrivée du dernier coup, s'il est de
* l'ordinateur. */
function lastComputerMove(game: Game): Set<Place> {
const played = game.events.findLast((e) => e.type === "MovePlayed");
const places = new Set<Place>();
if (played?.type === "MovePlayed" && played.side === "computer") {
for (const h of played.halfMoves) {
places.add(placeOf("computer", h.from));
places.add(placeOf("computer", h.to));
}
}
return places;
}
/** Ce qu'a fait l'ordinateur pendant son tour. */
function describeComputerTurn(game: Game, ms: number): string {
const last = game.events.at(-1);
if (last?.type === "CubeOffered") {
return "";
}
const played = game.events.findLast((e) => e.type === "MovePlayed");
if (played?.type !== "MovePlayed") {
return "";
}
const dice = rollBefore(game);
if (played.halfMoves.length === 0) {
return TEXTS.computerCannotMove(dice);
}
return TEXTS.computerMoved(dice, formatHalfMoves(played.halfMoves), ms);
}
/** Ce que le joueur doit faire maintenant, ou le résultat. */
function prompt(game: Game, selecting: boolean, canDouble: boolean): string {
const state = game.state;
if (state.phase === "over" && state.result !== null) {
const { winner, kind, points } = state.result;
const dropped = game.events.find((e) => e.type === "CubeDropped");
if (dropped !== undefined) {
return winner === "player" ? TEXTS.computerDropped(points) : TEXTS.youDropped(points);
}
return winner === "player" ? TEXTS.youWin(kind, points) : TEXTS.youLose(kind, points);
}
if (state.turn === "computer") {
return state.phase === "doubled" ? TEXTS.computerDoubles : TEXTS.thinking;
}
if (state.phase === "rolling") {
return canDouble ? TEXTS.yourRoll : TEXTS.yourRollNoDouble;
}
if (state.phase === "moving" && state.roll !== null) {
return selecting ? TEXTS.pickTarget : TEXTS.yourMove(state.roll);
}
return "";
}
+92
View File
@@ -0,0 +1,92 @@
// Ce que dessine le plateau, calculé sans le navigateur (testé sous Node).
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
//
// Le plateau est toujours dessiné du point de vue du joueur : ses cases 1 à 6
// en bas à droite, 7 à 12 en bas à gauche, 13 à 18 en haut à gauche, 19 à 24
// en haut à droite ; ses pions vont de 24 vers 1, puis sortent à droite, en
// bas. La case 1 de l'ordinateur est la case 24 du joueur.
import type { SideBoards } from "../domain/game/game.js";
import { BAR, type HalfMove, OFF } from "../domain/game/position.js";
import type { Side } from "../domain/game/side.js";
/**
* Un endroit du plateau où l'on peut toucher : une case (« p1 » à « p24 »,
* numérotée du point de vue du joueur), la barre ou la sortie d'un camp.
*/
export type Place = `p${number}` | `bar-${Side}` | `off-${Side}`;
/** L'endroit désigné par une case d'un demi-coup de side, dans sa propre
* numérotation (BAR, OFF ou 1 à 24). */
export function placeOf(side: Side, point: number): Place {
if (point === BAR) {
return `bar-${side}`;
}
if (point === OFF) {
return `off-${side}`;
}
return side === "player" ? `p${point}` : `p${25 - point}`;
}
/** Une case du plateau, et les pions de chaque camp qui s'y trouvent. */
export interface PointModel {
readonly place: Place;
/** Numéro de la case pour le joueur, 1 à 24. */
readonly number: number;
/** Le camp qui l'occupe (null : vide) et son nombre de pions. */
readonly owner: Side | null;
readonly count: number;
}
export interface BoardModel {
/** Les cases 1 à 24 du joueur, dans l'ordre. */
readonly points: readonly PointModel[];
readonly bar: Readonly<Record<Side, number>>;
readonly off: Readonly<Record<Side, number>>;
readonly pipCount: Readonly<Record<Side, number>>;
}
/** Le plateau à dessiner, à partir du plateau vu de chaque camp. */
export function boardModel(sides: SideBoards): BoardModel {
const points: PointModel[] = [];
for (let number = 1; number <= 24; number++) {
const player = sides.player.points[number - 1] ?? 0;
const computer = sides.computer.points[24 - number] ?? 0;
// Une case n'a jamais de pions des deux camps à la fois.
const owner = player > 0 ? "player" : computer > 0 ? "computer" : null;
points.push({ place: `p${number}`, number, owner, count: player || computer });
}
return {
points,
bar: { player: sides.player.bar, computer: sides.computer.bar },
off: { player: sides.player.off, computer: sides.computer.off },
pipCount: { player: sides.player.pipCount, computer: sides.computer.pipCount },
};
}
/** Les cases de départ des demi-coups possibles, et pour chacune ses cases
* d'arrivée : ce que l'interface met en évidence. */
export function moveTargets(halfMoves: readonly HalfMove[]): Map<Place, Map<Place, HalfMove>> {
const targets = new Map<Place, Map<Place, HalfMove>>();
for (const halfMove of halfMoves) {
const from = placeOf("player", halfMove.from);
let tos = targets.get(from);
if (tos === undefined) {
tos = new Map();
targets.set(from, tos);
}
tos.set(placeOf("player", halfMove.to), halfMove);
}
return targets;
}
/** Où se trouve une case sur le dessin : rangée du haut ou du bas, et
* colonne de 0 (à gauche) à 11 (à droite), sans compter la barre. */
export function pointSlot(number: number): { top: boolean; column: number } {
if (number <= 12) {
return { top: false, column: 12 - number };
}
return { top: true, column: number - 13 };
}
+358
View File
@@ -0,0 +1,358 @@
// Le dessin du plateau, en SVG.
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
//
// Le plateau est redessiné en entier à chaque changement : c'est rapide, et
// il n'y a aucun état caché dans le dessin. Les couleurs viennent des
// variables CSS (app.css), par des classes : aucun style dans le SVG. Chaque
// endroit qu'on peut toucher est un rectangle transparent, posé par-dessus
// le reste, qui porte son nom dans data-place.
import type { Cube } from "../domain/game/cube.js";
import type { Side } from "../domain/game/side.js";
import { type BoardModel, type Place, pointSlot } from "./board-model.js";
/** Un dé affiché, de la couleur du camp qui l'a lancé. */
export interface ShownDie {
readonly value: number;
readonly side: Side;
}
/** Ce qui s'ajoute aux pions : dés, videau, mises en évidence. */
export interface BoardDecor {
readonly dice: readonly ShownDie[];
readonly cube: Cube;
/** Les pions que le joueur peut déplacer. */
readonly movable: ReadonlySet<Place>;
/** Le pion touché, dont on montre les cases d'arrivée. */
readonly selected: Place | null;
readonly targets: ReadonlySet<Place>;
/** Les cases de départ et d'arrivée du dernier coup de l'ordinateur. */
readonly lastMove: ReadonlySet<Place>;
}
// Géométrie, en unités du viewBox (le SVG s'adapte à la largeur de l'écran).
const WIDTH = 640;
const HEIGHT = 520;
const FRAME = 8;
const POINT_WIDTH = 44;
const HALF_WIDTH = 6 * POINT_WIDTH;
const BAR_LEFT = FRAME + HALF_WIDTH;
const BAR_WIDTH = 40;
const RIGHT_HALF = BAR_LEFT + BAR_WIDTH;
const TRAY_LEFT = RIGHT_HALF + HALF_WIDTH + FRAME;
const TRAY_WIDTH = 48;
const POINT_HEIGHT = 190;
const MIDDLE = HEIGHT / 2;
const RADIUS = 20;
const STEP = 42; // d'un pion au suivant dans une pile
const MAX_STACK = 5; // au-delà, un nombre sur le dernier pion
const OFF_SLAB = 12; // épaisseur d'un pion sorti
const DIE = 36;
const CUBE = 34;
const SVG_NS = "http://www.w3.org/2000/svg";
type Attributes = Record<string, string | number>;
function add(parent: Element, name: string, attributes: Attributes, text?: string): SVGElement {
const element = document.createElementNS(SVG_NS, name);
for (const [key, value] of Object.entries(attributes)) {
element.setAttribute(key, String(value));
}
if (text !== undefined) {
element.textContent = text;
}
parent.append(element);
return element;
}
/** Abscisse du bord gauche d'une colonne (0 à 11). */
function columnLeft(column: number): number {
return column < 6 ? FRAME + column * POINT_WIDTH : RIGHT_HALF + (column - 6) * POINT_WIDTH;
}
/** Centre du i-ième pion d'une pile, depuis le bord du plateau. */
function stackY(top: boolean, index: number): number {
const offset = RADIUS + 1 + index * STEP;
return top ? FRAME + offset : HEIGHT - FRAME - offset;
}
/** Redessine tout le plateau dans svg. */
export function renderBoard(svg: SVGSVGElement, board: BoardModel, decor: BoardDecor): void {
svg.replaceChildren();
svg.setAttribute("viewBox", `0 0 ${WIDTH} ${HEIGHT}`);
drawFrame(svg);
const hits = add(svg, "g", {});
for (const point of board.points) {
drawPoint(svg, hits, board, point.number, decor);
}
drawBar(svg, hits, board, decor);
drawTray(svg, hits, board, decor);
drawDice(svg, decor.dice);
drawCube(svg, decor.cube);
// Les zones à toucher passent devant tout le reste.
svg.append(hits);
}
function drawFrame(svg: SVGSVGElement): void {
add(svg, "rect", { class: "board-frame", x: 0, y: 0, width: WIDTH, height: HEIGHT, rx: 6 });
for (const left of [FRAME, RIGHT_HALF]) {
add(svg, "rect", {
class: "board-bed",
x: left,
y: FRAME,
width: HALF_WIDTH,
height: HEIGHT - 2 * FRAME,
});
}
add(svg, "rect", {
class: "board-tray",
x: TRAY_LEFT,
y: FRAME,
width: TRAY_WIDTH,
height: HEIGHT - 2 * FRAME,
});
}
function drawPoint(
svg: SVGSVGElement,
hits: Element,
board: BoardModel,
number: number,
decor: BoardDecor,
): void {
const point = board.points[number - 1];
if (point === undefined) {
return;
}
const { top, column } = pointSlot(number);
const left = columnLeft(column);
const center = left + POINT_WIDTH / 2;
const base = top ? FRAME : HEIGHT - FRAME;
const tip = top ? FRAME + POINT_HEIGHT : HEIGHT - FRAME - POINT_HEIGHT;
add(svg, "polygon", {
class: column % 2 === 0 ? "point-a" : "point-b",
points: `${left},${base} ${left + POINT_WIDTH},${base} ${center},${tip}`,
});
add(
svg,
"text",
{ class: "point-number", x: center, y: top ? MIDDLE - 26 : MIDDLE + 34 },
String(number),
);
if (decor.targets.has(point.place)) {
add(svg, "rect", {
class: "target-zone",
x: left + 2,
y: top ? FRAME : MIDDLE,
width: POINT_WIDTH - 4,
height: HEIGHT / 2 - FRAME,
rx: 4,
});
}
if (point.owner !== null) {
const y = (i: number) => stackY(top, i);
drawStack(svg, point.owner, point.count, center, y, point.place, decor);
}
if (decor.lastMove.has(point.place)) {
add(svg, "circle", {
class: "last-move",
cx: center,
cy: top ? MIDDLE - 12 : MIDDLE + 12,
r: 5,
});
}
addHit(hits, point.place, decor, {
x: left,
y: top ? 0 : MIDDLE,
width: POINT_WIDTH,
height: MIDDLE,
});
}
/** Une pile de pions, le i-ième centré en (cx, y(i)) ; le dernier porte le
* nombre total s'il y en a trop pour les montrer tous. */
function drawStack(
svg: SVGSVGElement,
side: Side,
count: number,
cx: number,
y: (index: number) => number,
place: Place,
decor: BoardDecor,
): void {
const shown = Math.min(count, MAX_STACK);
for (let i = 0; i < shown; i++) {
const last = i === shown - 1;
const classes = [`checker checker-${side}`];
if (last && decor.selected === place) {
classes.push("selected");
} else if (last && decor.movable.has(place)) {
classes.push("movable");
}
const cy = y(i);
add(svg, "circle", { class: classes.join(" "), cx, cy, r: RADIUS });
if (last && count > MAX_STACK) {
add(
svg,
"text",
{ class: `checker-count count-${side}`, x: cx, y: cy + 6 },
String(count),
);
}
}
}
function drawBar(svg: SVGSVGElement, hits: Element, board: BoardModel, decor: BoardDecor): void {
add(svg, "rect", {
class: "board-bar",
x: BAR_LEFT,
y: 0,
width: BAR_WIDTH,
height: HEIGHT,
});
const cx = BAR_LEFT + BAR_WIDTH / 2;
// Les pions du joueur sur la barre entrent en haut à droite : on les met
// dans la moitié haute, en partant du milieu ; ceux de l'ordinateur en bas.
for (const side of ["player", "computer"] as const) {
const up = side === "player";
const y = (i: number) =>
up ? MIDDLE - RADIUS - 4 - i * STEP : MIDDLE + RADIUS + 4 + i * STEP;
drawStack(svg, side, board.bar[side], cx, y, `bar-${side}`, decor);
}
addHit(hits, "bar-player", decor, { x: BAR_LEFT, y: 0, width: BAR_WIDTH, height: MIDDLE });
}
function drawTray(svg: SVGSVGElement, hits: Element, board: BoardModel, decor: BoardDecor): void {
// Pions sortis, vus de profil : l'ordinateur en haut, le joueur en bas.
for (const side of ["player", "computer"] as const) {
for (let i = 0; i < board.off[side]; i++) {
const y =
side === "computer" ? FRAME + i * OFF_SLAB : HEIGHT - FRAME - (i + 1) * OFF_SLAB;
add(svg, "rect", {
class: `checker checker-${side}`,
x: TRAY_LEFT + 4,
y: y + 1,
width: TRAY_WIDTH - 8,
height: OFF_SLAB - 2,
rx: 3,
});
}
}
if (decor.targets.has("off-player")) {
add(svg, "rect", {
class: "target-zone",
x: TRAY_LEFT + 2,
y: MIDDLE,
width: TRAY_WIDTH - 4,
height: MIDDLE - FRAME,
rx: 4,
});
}
if (decor.lastMove.has("off-computer")) {
add(svg, "circle", {
class: "last-move",
cx: TRAY_LEFT + TRAY_WIDTH / 2,
cy: MIDDLE - 70,
r: 5,
});
}
addHit(hits, "off-player", decor, {
x: TRAY_LEFT,
y: MIDDLE,
width: TRAY_WIDTH,
height: MIDDLE,
});
}
// Position des points d'un dé, en fractions de son côté.
const PIPS: Record<number, [number, number][]> = {
1: [[0.5, 0.5]],
2: [
[0.25, 0.25],
[0.75, 0.75],
],
3: [
[0.25, 0.25],
[0.5, 0.5],
[0.75, 0.75],
],
4: [
[0.25, 0.25],
[0.75, 0.25],
[0.25, 0.75],
[0.75, 0.75],
],
5: [
[0.25, 0.25],
[0.75, 0.25],
[0.5, 0.5],
[0.25, 0.75],
[0.75, 0.75],
],
6: [
[0.25, 0.25],
[0.75, 0.25],
[0.25, 0.5],
[0.75, 0.5],
[0.25, 0.75],
[0.75, 0.75],
],
};
/** Les dés, au milieu de la moitié droite. */
function drawDice(svg: SVGSVGElement, dice: readonly ShownDie[]): void {
const center = RIGHT_HALF + HALF_WIDTH / 2;
const gap = 8;
const total = dice.length * DIE + (dice.length - 1) * gap;
dice.forEach((die, i) => {
const x = center - total / 2 + i * (DIE + gap);
const y = MIDDLE - DIE / 2;
const group = add(svg, "g", { class: "die", "data-value": die.value });
add(group, "rect", { class: `die-${die.side}`, x, y, width: DIE, height: DIE, rx: 6 });
for (const [px, py] of PIPS[die.value] ?? []) {
add(group, "circle", {
class: `pip-${die.side}`,
cx: x + px * DIE,
cy: y + py * DIE,
r: 3.5,
});
}
});
}
/** Le videau, dans le plateau de sortie : au milieu s'il est au centre,
* sinon du côté de son propriétaire. Au centre, il montre 64. */
function drawCube(svg: SVGSVGElement, cube: Cube): void {
const shift = cube.owner === "computer" ? -48 : cube.owner === "player" ? 48 : 0;
const x = TRAY_LEFT + (TRAY_WIDTH - CUBE) / 2;
const y = MIDDLE + shift - CUBE / 2;
const group = add(svg, "g", { class: "cube", "data-owner": cube.owner ?? "center" });
add(group, "rect", { class: "cube-face", x, y, width: CUBE, height: CUBE, rx: 4 });
add(
group,
"text",
{ class: "cube-value", x: x + CUBE / 2, y: y + CUBE / 2 + 6 },
String(cube.owner === null ? 64 : cube.value),
);
}
/** Une zone à toucher. Elle porte son état (can-move, can-land) pour les
* tests dans un navigateur. */
function addHit(
hits: Element,
place: Place,
decor: BoardDecor,
box: { x: number; y: number; width: number; height: number },
): void {
const classes = ["hit"];
if (decor.movable.has(place)) {
classes.push("can-move");
}
if (decor.targets.has(place)) {
classes.push("can-land");
}
add(hits, "rect", { class: classes.join(" "), "data-place": place, ...box });
}
+91
View File
@@ -0,0 +1,91 @@
// Lecture des textes de l'aide : les scénarios (docs/scenarios/) et les
// fiches du glossaire (docs/glossaire/), copiés tels quels dans le site par
// make site (ADR 0008 : une seule source). Fonctions pures, testées sous Node.
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
/** Un scénario, mis en forme comme une page d'aide. */
export interface HelpFeature {
readonly title: string;
/** Paragraphes d'introduction. */
readonly description: readonly string[];
readonly scenarios: readonly HelpScenario[];
}
export interface HelpScenario {
readonly title: string;
readonly description: readonly string[];
/** Les étapes, mot-clé compris : « Quand vous prenez ». */
readonly steps: readonly string[];
}
const STEP = /^(Étant donnée?s?|Etant donnée?s?|Soit|Quand|Lorsque|Alors|Donc|Et|Mais)\s/;
const SCENARIO = /^(Scénario|Exemple|Contexte)\s*:\s*(.*)$/;
const FEATURE = /^Fonctionnalité\s*:\s*(.*)$/;
/**
* Lit un fichier Gherkin en français (fichiers .feature). Les lignes de texte
* libre consécutives forment un paragraphe ; les commentaires (#) sont
* ignorés.
*/
export function parseFeature(text: string): HelpFeature {
let title = "";
const description: string[] = [];
const scenarios: { title: string; description: string[]; steps: string[] }[] = [];
let paragraph: string[] = [];
// Le paragraphe en cours rejoint la description de la fonctionnalité ou
// du dernier scénario.
const endParagraph = () => {
if (paragraph.length > 0) {
(scenarios.at(-1)?.description ?? description).push(paragraph.join(" "));
paragraph = [];
}
};
for (const raw of text.split("\n")) {
const line = raw.trim();
const feature = FEATURE.exec(line);
const scenario = SCENARIO.exec(line);
if (line === "" || line.startsWith("#")) {
endParagraph();
} else if (feature) {
endParagraph();
title = feature[1] ?? "";
} else if (scenario) {
endParagraph();
scenarios.push({ title: scenario[2] ?? "", description: [], steps: [] });
} else if (STEP.test(line) && scenarios.length > 0) {
endParagraph();
scenarios.at(-1)?.steps.push(line);
} else {
paragraph.push(line);
}
}
endParagraph();
return { title, description, scenarios };
}
/** Les fiches citées par l'index du glossaire (README.md), dans l'ordre :
* les liens « [Terme](fichier.md) » de la section « Termes ». */
export function parseGlossaryIndex(readme: string): string[] {
const section = readme.split(/^## Termes$/m)[1] ?? "";
return [...section.matchAll(/\]\(([a-z0-9-]+\.md)\)/g)].map((match) => match[1] ?? "");
}
/** Une fiche du glossaire : le terme et sa phrase « En bref ». */
export interface GlossaryEntry {
readonly term: string;
readonly brief: string;
}
/** Lit une fiche du glossaire ; null si elle n'a pas la forme attendue. */
export function parseGlossaryEntry(text: string): GlossaryEntry | null {
const term = /^term: (.+)$/m.exec(text)?.[1]?.trim();
const brief = /^\*\*En bref\*\* : (.+)$/m.exec(text)?.[1]?.trim();
if (term === undefined || brief === undefined) {
return null;
}
return { term, brief: brief.replaceAll("**", "") };
}
+102
View File
@@ -0,0 +1,102 @@
// La page d'aide : le scénario « Jouer une partie contre l'ordinateur » et le
// glossaire, lus à l'exécution dans les copies de docs/ faites par make site
// (ADR 0008). Rien n'est recopié dans le code.
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
import {
type GlossaryEntry,
type HelpFeature,
parseFeature,
parseGlossaryEntry,
parseGlossaryIndex,
} from "./help-text.js";
import { TEXTS } from "./texts.js";
/** Les scénarios montrés dans l'aide, dans l'ordre. */
const FEATURES = ["docs/scenarios/jouer-une-partie.feature"];
const GLOSSARY = "docs/glossaire/";
async function fetchText(url: string): Promise<string> {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`${url} : ${response.status}`);
}
return response.text();
}
async function loadGlossary(): Promise<GlossaryEntry[]> {
const files = parseGlossaryIndex(await fetchText(`${GLOSSARY}README.md`));
const texts = await Promise.all(files.map((file) => fetchText(GLOSSARY + file)));
return texts.map(parseGlossaryEntry).filter((entry) => entry !== null);
}
function element<K extends keyof HTMLElementTagNameMap>(
parent: HTMLElement,
tag: K,
text?: string,
): HTMLElementTagNameMap[K] {
const child = document.createElement(tag);
if (text !== undefined) {
child.textContent = text;
}
parent.append(child);
return child;
}
function renderFeature(parent: HTMLElement, feature: HelpFeature): void {
element(parent, "h2", feature.title);
for (const paragraph of feature.description) {
element(parent, "p", paragraph);
}
for (const scenario of feature.scenarios) {
element(parent, "h3", scenario.title);
for (const paragraph of scenario.description) {
element(parent, "p", paragraph);
}
const steps = element(parent, "ul");
steps.className = "steps";
for (const step of scenario.steps) {
element(steps, "li", step);
}
}
}
function renderGlossary(parent: HTMLElement, entries: readonly GlossaryEntry[]): void {
element(parent, "h2", TEXTS.glossaryTitle);
const list = element(parent, "dl");
list.className = "glossary";
for (const entry of entries) {
element(list, "dt", entry.term);
element(list, "dd", entry.brief);
}
}
/** Remplit container avec l'aide (une seule fois ; les appels suivants ne
* font rien). */
export async function showHelp(container: HTMLElement): Promise<void> {
if (container.dataset.loaded === "true") {
return;
}
container.replaceChildren();
const status = element(container, "p", TEXTS.helpLoading);
try {
const [features, glossary] = await Promise.all([
Promise.all(FEATURES.map(async (url) => parseFeature(await fetchText(url)))),
loadGlossary(),
]);
status.remove();
for (const feature of features) {
renderFeature(container, feature);
}
renderGlossary(container, glossary);
const about = element(container, "p", `${TEXTS.about} `);
about.className = "about";
const link = element(about, "a", TEXTS.engineTestLink);
link.href = "moteur/";
container.dataset.loaded = "true";
} catch {
status.textContent = TEXTS.helpFailed;
}
}
+91
View File
@@ -0,0 +1,91 @@
// Les textes de l'interface (ADR 0007, point 5), en français, avec les mots
// du glossaire (docs/glossaire/). Le code n'écrit aucun texte lui-même : il
// prend tout ici.
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
import type { ResultKind } from "../domain/game/engine.js";
/** Un jet écrit « 6-4 ». */
function roll(dice: readonly number[]): string {
return dice.join("-");
}
const RESULT_KIND: Record<ResultKind, string> = {
single: "la partie simple",
gammon: "un gammon",
backgammon: "un backgammon",
};
function points(n: number): string {
return n > 1 ? `${n} points` : `${n} point`;
}
/** Une durée en secondes, avec une décimale : « 0,3 s ». */
function seconds(ms: number): string {
return `${(ms / 1000).toFixed(1).replace(".", ",")} s`;
}
export const TEXTS = {
title: "Ustagammon",
boardLabel: "Plateau de backgammon",
// Boutons
roll: "Lancer les dés",
double: "Doubler",
take: "Prendre",
drop: "Passer",
cancel: "Annuler",
newGame: "Nouvelle partie",
help: "Aide",
back: "Retour à la partie",
// Chargement
loading: "Chargement du moteur…",
engineFailed: (message: string) => `Le moteur n'a pas pu se charger : ${message}`,
actionFailed: (message: string) => `Erreur : ${message}`,
confirmNewGame: "Abandonner la partie en cours et en commencer une nouvelle ?",
// Déroulement
opening: (player: number, computer: number) =>
`Jet d'ouverture : vous ${player}, l'ordinateur ${computer}. ${
player > computer ? "Vous commencez." : "L'ordinateur commence."
}`,
yourRoll: "À vous : doublez ou lancez les dés.",
yourRollNoDouble: "À vous : lancez les dés.",
yourMove: (dice: readonly number[]) =>
`Jouez ${roll(dice)} : touchez un pion, puis sa case d'arrivée.`,
pickTarget: "Touchez une case d'arrivée en surbrillance, ou un autre pion.",
youCannotMove: (dice: readonly number[]) =>
`Vous ne pouvez pas jouer ${roll(dice)} : votre tour passe.`,
thinking: "L'ordinateur réfléchit…",
computerMoved: (dice: readonly number[], move: string, ms: number) =>
`L'ordinateur a joué ${roll(dice)} : ${move} (${seconds(ms)}).`,
computerCannotMove: (dice: readonly number[]) =>
`L'ordinateur ne peut pas jouer ${roll(dice)}.`,
computerDoubles: "L'ordinateur vous propose de doubler : prenez ou passez.",
computerTakes: (value: number) =>
`L'ordinateur prend : la partie vaut maintenant ${points(value)}.`,
youTook: (value: number) =>
`Vous prenez : la partie vaut maintenant ${points(value)}, et le videau est chez vous.`,
// Fin de partie
youWin: (kind: ResultKind, n: number) => `Vous gagnez ${RESULT_KIND[kind]} : ${points(n)}.`,
youLose: (kind: ResultKind, n: number) =>
`L'ordinateur gagne ${RESULT_KIND[kind]} : ${points(n)} pour lui.`,
computerDropped: (n: number) => `L'ordinateur passe : vous gagnez ${points(n)}.`,
youDropped: (n: number) => `Vous passez : l'ordinateur gagne ${points(n)}.`,
// Sous le plateau
pipCounts: (player: number, computer: number) =>
`Compte de points : vous ${player}, l'ordinateur ${computer}`,
// Aide
helpTitle: "Aide",
glossaryTitle: "Glossaire",
helpLoading: "Chargement de l'aide…",
helpFailed: "L'aide n'a pas pu se charger.",
about: "Ustagammon est un logiciel libre (GPLv3). L'ordinateur joue avec GNU Backgammon.",
engineTestLink: "Test du moteur",
} as const;
+37
View File
@@ -0,0 +1,37 @@
// Le moteur WebAssembly répond indéfiniment : une partie en navigateur
// envoie des centaines de commandes au même moteur chargé une fois.
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
//
// Régression : callMain (Emscripten) laisse ses arguments sur la pile du
// WebAssembly (64 Ko). Sans remise en place après chaque commande
// (web/engine-wasm.mjs), la pile débordait vers la 500e commande : erreur
// « memory access out of bounds » sous Node, moteur muet dans Firefox,
// onglet planté dans Chromium.
import assert from "node:assert/strict";
import { test } from "node:test";
import { fileURLToPath } from "node:url";
// Compilé dans build/web/tests/ : la racine du dépôt est trois dossiers plus
// haut.
const root = new URL("../../../", import.meta.url);
interface RawEngine {
runText(args: string[]): { text: string; exitCode: number };
}
test("le moteur répond à 3000 commandes à la suite", async () => {
const module = await import(new URL("build/wasm/engine.mjs", root).href);
const wrapper = await import(new URL("web/engine-wasm.mjs", root).href);
const engine: RawEngine = await wrapper.loadEngine(
module.default,
fileURLToPath(new URL("build/wasm/", root)),
);
for (let i = 0; i < 3000; i++) {
const { text, exitCode } = engine.runText(["position", "4HPwATDgc/ABMA", "AQEAAAAAAAAA"]);
assert.equal(exitCode, 0, `commande ${i}`);
assert.match(text, /"pip_count": 167/, `commande ${i}`);
}
});
+54
View File
@@ -218,3 +218,57 @@ test("lecture et écriture des demi-coups", () => {
assert.throws(() => parseHalfMoves("8-5"), /mal formé/);
assert.throws(() => parseHalfMoves("30/5"), /case invalide/);
});
test("une partie se reprend depuis son journal ; un journal d'une autre version est refusé", async () => {
const game = Game.start(fakeEngine(), { dice: fixedDice([3, 1]) });
await game.playHalfMove({ from: 8, to: 5 });
await game.playHalfMove({ from: 6, to: 5 });
const saved = JSON.parse(JSON.stringify(game.journal));
const resumed = Game.resume(fakeEngine(), saved);
assert.deepEqual(resumed.state, game.state);
assert.deepEqual(resumed.events, game.events);
const other = { ...saved, events: saved.events.map((e: object) => ({ ...e, version: 99 })) };
assert.throws(() => Game.resume(fakeEngine(), other), /version/);
assert.throws(() => Game.resume(fakeEngine(), EMPTY_JOURNAL), /mal formé/);
assert.throws(() => Game.resume(fakeEngine(), { ...saved, head: "inconnu" }), /introuvable/);
});
test("le droit de doubler vient du moteur, seulement avant de lancer les dés", async () => {
let available = true;
const engine = fakeEngine({
cubeDecision: async () => ({ available, double: false, take: true }),
});
const game = Game.start(engine, {
dice: fixedDice([3, 1]),
setup: { position: "X", firstTurn: "player" },
});
assert.equal(await game.canDouble(), true);
available = false;
assert.equal(await game.canDouble(), false);
available = true;
await game.roll();
assert.equal(game.state.phase, "moving");
assert.equal(await game.canDouble(), false);
});
test("le plateau affiché est donné du point de vue de chaque camp", async () => {
const side = (pipCount: number) => ({ points: [], bar: 0, off: 0, pipCount });
const engine = fakeEngine({
board: async () => ({
onRoll: side(1),
opponent: side(2),
swappedPosition: "SWAPPED",
result: null,
}),
});
const playerTurn = Game.start(engine, { setup: { position: "X", firstTurn: "player" } });
const sides = await playerTurn.displayedBoard();
assert.equal(sides.player.pipCount, 1);
assert.equal(sides.computer.pipCount, 2);
const computerTurn = Game.start(engine, { setup: { position: "X", firstTurn: "computer" } });
const swapped = await computerTurn.displayedBoard();
assert.equal(swapped.player.pipCount, 2);
assert.equal(swapped.computer.pipCount, 1);
});
+65
View File
@@ -0,0 +1,65 @@
// Ce que l'interface propose au joueur, sur le vrai moteur WebAssembly : les
// demi-coups possibles (mis en évidence quand il touche un pion) et le
// plateau affiché pendant la saisie d'un coup.
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
import assert from "node:assert/strict";
import { test } from "node:test";
import { Game } from "../src/domain/game/game.js";
import { formatHalfMoves, type HalfMove } from "../src/domain/game/position.js";
import { fixedDice } from "../src/domain/game/roll.js";
import { loadNodeEngine } from "./support/node-engine.js";
// Le joueur, au trait, n'a plus que deux pions, sur ses cases 6 et 5
// (même position que dans les étapes du scénario).
const PLAYER_ALMOST_DONE = "/38AAABQAAAAAA";
/** Les demi-coups en notation, triés : « 13/10 13/12… ». */
function text(halfMoves: readonly HalfMove[]): string {
return halfMoves
.map((h) => formatHalfMoves([h]))
.sort()
.join(" ");
}
test("au départ avec 3-1 : chaque pion jouable, avec chaque dé", async () => {
const engine = await loadNodeEngine();
const game = Game.start(engine, { dice: fixedDice([3, 1]) });
assert.equal(text(await game.nextHalfMoves()), "13/10 24/21 24/23 6/3 6/5 8/5 8/7");
// 13/12 manque : votre case 12 est tenue par l'ordinateur. Après 8/5,
// il ne reste que le 1.
assert.equal(await game.playHalfMove({ from: 8, to: 5 }), true);
assert.equal(text(await game.nextHalfMoves()), "24/23 5/4 6/5 8/7");
});
test("en fin de partie, les demi-coups vers la sortie viennent du moteur", async () => {
const engine = await loadNodeEngine();
const game = Game.start(engine, {
dice: fixedDice([6, 5]),
setup: { position: PLAYER_ALMOST_DONE, firstTurn: "player" },
});
await game.roll();
assert.equal(text(await game.nextHalfMoves()), "5/off 6/1 6/off");
});
test("hors du tour de jeu du joueur, rien n'est proposé", async () => {
const engine = await loadNodeEngine();
const game = Game.start(engine, { dice: fixedDice([1, 3]) });
assert.equal(game.state.turn, "computer");
assert.deepEqual(await game.nextHalfMoves(), []);
});
test("le plateau affiché suit les demi-coups saisis", async () => {
const engine = await loadNodeEngine();
const game = Game.start(engine, { dice: fixedDice([3, 1]) });
const before = await game.displayedBoard();
assert.equal(before.player.points[7], 3);
assert.equal(before.computer.points[23], 2);
await game.playHalfMove({ from: 8, to: 5 });
const after = await game.displayedBoard();
assert.equal(after.player.points[7], 2);
assert.equal(after.player.points[4], 1);
assert.equal(after.player.pipCount, before.player.pipCount - 3);
});
+145
View File
@@ -0,0 +1,145 @@
// Tests de ce qui, dans l'interface et le stockage, se vérifie sans
// navigateur : le plateau à dessiner, les mises en évidence, la lecture de
// l'aide, l'enregistrement de la partie.
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { test } from "node:test";
import type { BoardSide } from "../src/domain/game/engine.js";
import { appendEvent, EMPTY_JOURNAL } from "../src/domain/game/events.js";
import { START_POSITION } from "../src/domain/game/position.js";
import { createGameStore } from "../src/storage/game-store.js";
import { boardModel, moveTargets, placeOf, pointSlot } from "../src/ui/board-model.js";
import { parseFeature, parseGlossaryEntry, parseGlossaryIndex } from "../src/ui/help-text.js";
import { TEXTS } from "../src/ui/texts.js";
// Compilé dans build/web/tests/ : la racine du dépôt est trois dossiers plus haut.
const root = new URL("../../../", import.meta.url);
const read = (path: string) => readFileSync(new URL(path, root), "utf8");
function side(points: Record<number, number>, extra: Partial<BoardSide> = {}): BoardSide {
const all = Array.from({ length: 24 }, (_, i) => points[i + 1] ?? 0);
return { points: all, bar: 0, off: 0, pipCount: 0, ...extra };
}
test("le plateau est vu du joueur : la case q de l'ordinateur est sa case 25 - q", () => {
const model = boardModel({
player: side({ 6: 5, 8: 3 }, { bar: 1, pipCount: 100 }),
computer: side({ 6: 5, 24: 2 }, { off: 3, pipCount: 90 }),
});
assert.equal(model.points.length, 24);
assert.deepEqual(model.points[5], { place: "p6", number: 6, owner: "player", count: 5 });
assert.deepEqual(model.points[18], { place: "p19", number: 19, owner: "computer", count: 5 });
assert.deepEqual(model.points[0], { place: "p1", number: 1, owner: "computer", count: 2 });
assert.deepEqual(model.points[1], { place: "p2", number: 2, owner: null, count: 0 });
assert.deepEqual(model.bar, { player: 1, computer: 0 });
assert.deepEqual(model.off, { player: 0, computer: 3 });
assert.deepEqual(model.pipCount, { player: 100, computer: 90 });
});
test("cases, barre et sortie de chaque camp", () => {
assert.equal(placeOf("player", 8), "p8");
assert.equal(placeOf("computer", 8), "p17");
assert.equal(placeOf("player", 25), "bar-player");
assert.equal(placeOf("computer", 0), "off-computer");
});
test("disposition : 1 à 12 en bas de droite à gauche, 13 à 24 en haut de gauche à droite", () => {
assert.deepEqual(pointSlot(1), { top: false, column: 11 });
assert.deepEqual(pointSlot(7), { top: false, column: 5 });
assert.deepEqual(pointSlot(12), { top: false, column: 0 });
assert.deepEqual(pointSlot(13), { top: true, column: 0 });
assert.deepEqual(pointSlot(24), { top: true, column: 11 });
});
test("les demi-coups possibles donnent les pions jouables et leurs cases d'arrivée", () => {
const targets = moveTargets([
{ from: 8, to: 5 },
{ from: 8, to: 7 },
{ from: 25, to: 22 },
{ from: 3, to: 0 },
]);
assert.deepEqual([...targets.keys()], ["p8", "bar-player", "p3"]);
assert.deepEqual([...(targets.get("p8")?.keys() ?? [])], ["p5", "p7"]);
assert.deepEqual(targets.get("p3")?.get("off-player"), { from: 3, to: 0 });
});
test("l'aide lit le scénario tel qu'il est écrit dans docs/scenarios/", () => {
const feature = parseFeature(read("docs/scenarios/jouer-une-partie.feature"));
assert.equal(feature.title, "Jouer une partie contre l'ordinateur");
assert.match(feature.description[0] ?? "", /^Vous jouez une partie complète/);
assert.equal(feature.scenarios[0]?.title, "Commencer une partie");
assert.deepEqual(feature.scenarios[0]?.steps, [
"Quand vous commencez une nouvelle partie",
"Alors les pions sont en position de départ",
"Et le premier jet de dés désigne qui commence",
]);
const refused = feature.scenarios.find((s) => s.title === "Un coup interdit est refusé");
assert.match(refused?.description[0] ?? "", /^La case 12 est tenue/);
assert.equal(refused?.steps[0], "Étant donné que c'est à vous de jouer avec le jet 3-1");
// Chaque scénario a des étapes : aucune n'a été prise pour du texte libre.
for (const scenario of feature.scenarios) {
assert.ok(scenario.steps.length >= 2, scenario.title);
}
});
test("l'aide lit chaque fiche de l'index du glossaire", () => {
const files = parseGlossaryIndex(read("docs/glossaire/README.md"));
assert.ok(files.includes("videau.md"));
assert.ok(!files.includes("README.md"));
for (const file of files) {
const entry = parseGlossaryEntry(read(`docs/glossaire/${file}`));
assert.ok(entry !== null, file);
assert.ok(entry.brief.length > 10, file);
}
assert.deepEqual(parseGlossaryEntry(read("docs/glossaire/videau.md")), {
term: "Videau",
brief: "le dé à doubler, marqué 2, 4, 8, 16, 32 et 64 : il indique la valeur actuelle de la partie.",
});
assert.equal(parseGlossaryEntry("pas une fiche"), null);
});
test("la partie enregistrée se relit ; un enregistrement illisible est ignoré", () => {
const data = new Map<string, string>();
const storage = {
getItem: (key: string) => data.get(key) ?? null,
setItem: (key: string, value: string) => void data.set(key, value),
};
const store = createGameStore(storage);
assert.equal(store.load(), null);
const journal = appendEvent(EMPTY_JOURNAL, {
type: "GameStarted",
position: START_POSITION,
cube: { value: 1, owner: null },
firstTurn: null,
});
store.save(journal);
assert.deepEqual(store.load(), journal);
for (const key of data.keys()) {
data.set(key, "{pas du JSON");
}
assert.equal(store.load(), null);
// Un stockage plein ou interdit ne casse pas la partie.
const full = createGameStore({
getItem: () => null,
setItem: () => {
throw new Error("plein");
},
});
assert.doesNotThrow(() => full.save(journal));
});
test("les textes de fin de partie emploient les mots du glossaire", () => {
assert.equal(TEXTS.youWin("gammon", 2), "Vous gagnez un gammon : 2 points.");
assert.equal(
TEXTS.youLose("single", 1),
"L'ordinateur gagne la partie simple : 1 point pour lui.",
);
assert.equal(
TEXTS.computerMoved([6, 4], "24/18 13/9", 312),
"L'ordinateur a joué 6-4 : 24/18 13/9 (0,3 s).",
);
});
+1 -1
View File
@@ -1,7 +1,7 @@
{
"compilerOptions": {
"target": "ES2023",
"lib": ["ES2023"],
"lib": ["ES2023", "DOM", "DOM.Iterable"],
"module": "NodeNext",
"moduleResolution": "NodeNext",
"types": ["node"],
+12
View File
@@ -30,10 +30,22 @@
header X-Content-Type-Options "nosniff"
header Referrer-Policy "no-referrer"
# Politique de sécurité du contenu : tout vient du site lui-même, sans
# script ni style en ligne. 'wasm-unsafe-eval' autorise la compilation du
# moteur WebAssembly (et rien d'autre : ni eval ni new Function). L'en-tête
# s'applique aussi au Web Worker du moteur et au service worker.
header Content-Security-Policy "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self'; img-src 'self'; connect-src 'self'; worker-src 'self'; manifest-src 'self'; object-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'"
# Données du moteur (Emscripten) : extension inconnue de Caddy, qui
# devinerait text/xml d'après le début du fichier.
@engine_data path *.data
header @engine_data Content-Type "application/octet-stream"
# Textes de l'aide (copies de docs/), lus par l'application.
@markdown path *.md
header @markdown Content-Type "text/markdown; charset=utf-8"
@gherkin path *.feature
header @gherkin Content-Type "text/plain; charset=utf-8"
file_server
}
+54 -11
View File
@@ -1,7 +1,9 @@
# Déploiement
Ustagammon est un site statique (PWA, ADR 0009) : des fichiers HTML,
JavaScript, le moteur en WebAssembly et ses données. Il est publié sur
JavaScript, le moteur en WebAssembly et ses données. L'application est à la
racine ; les pages de test du moteur de l'étape 0b restent publiées sous
`/moteur/` (test du moteur) et `/moteur/bench.html` (mesure). Il est publié sur
CapRover, machine `sans-pub` (https://captain.cloud.sans.pub), app
`ustagammon`, sur https://ustagammon.sans.pub.
@@ -46,31 +48,72 @@ make deploy-prod`.
## Ce qui est publié
- `make site` assemble `build/site/` : le contenu de `web/` à la racine, le
moteur (`engine.mjs`, `engine.wasm`, `engine.data`, `positions.json`) sous
`wasm/`, et `version.json` avec le commit publié.
- `make site` assemble `build/site/` (9 Mo) :
- le contenu de `web/` à la racine : la page de l'application
(`index.html`, `app.css`), le manifeste (`manifest.webmanifest`), les
icônes (`icons/`), le service worker (`sw.js`), le Web Worker du moteur
(`engine-worker.mjs`, `engine-wasm.mjs`) et les pages de test sous
`moteur/` ;
- l'application TypeScript compilée (`build/web/src/`) sous `app/` ;
- le moteur (`engine.mjs`, `engine.wasm`, `engine.data`, `positions.json`)
sous `wasm/` ;
- des copies de `docs/glossaire/` et `docs/scenarios/` sous `docs/`, lues
par l'aide de l'application (ADR 0008) ;
- `version.json` avec le commit publié ;
- enfin, `scripts/service-worker.sh` écrit en tête de `sw.js` la liste de
tous ces fichiers et une empreinte de leur contenu.
- `make package` ajoute `deploy/` et produit `build/ustagammon.tar` (environ
9 Mo).
9 Mo, 7,3 Mo transférés grâce à la compression).
- `tests/test_site.sh` (dans `make test`) construit l'image de production en
local, la lance et vérifie ce que reçoit un navigateur : pages, types MIME
(`application/wasm` pour le moteur), compression gzip des données, en-têtes,
et commit de `version.json`.
local, la lance et vérifie ce que reçoit un navigateur : application, pages
de test, aide, manifeste et icônes, liste du service worker, types MIME
(`application/wasm` pour le moteur), compression gzip des données,
en-têtes dont la politique de sécurité, et commit de `version.json`.
- `make test-browser` (hors de `make test`, plusieurs minutes) joue une
partie complète par l'interface dans Chromium et Firefox sans écran, sur
la même image, puis vérifie la reprise, l'aide, le hors ligne (Chromium
seulement : Firefox sous Playwright refuse toute navigation « hors
ligne », on y vérifie que le cache est complet et que la page rechargée
est servie par le service worker) et la politique de sécurité. Captures
d'écran dans `build/browser-shots/`.
**Hors ligne et mises à jour.** Le service worker met tous les fichiers du
site en cache à la première visite (environ 9 Mo), puis sert tout depuis ce
cache, avec ou sans réseau. À chaque publication, l'empreinte écrite dans
`sw.js` change dès qu'un fichier a changé : le navigateur, qui revérifie
`sw.js` à chaque ouverture, installe la nouvelle version (tout le site est
retéléchargé), qui prend la main et efface l'ancien cache. La page déjà
ouverte continue sur l'ancienne version ; le lancement suivant utilise la
nouvelle. Une publication sans changement de fichier garde le même `sw.js` :
rien n'est retéléchargé. Le service worker exige https (ou `localhost`) :
avec `make deploy`, en http sur le réseau local, l'application fonctionne,
mais pas hors ligne.
Réglages du serveur (`deploy/Caddyfile`) : compression zstd ou gzip, y compris
pour les données du moteur ; `Cache-Control: no-cache` (les noms de fichiers ne
changent pas d'une version à l'autre, le navigateur revalide chaque fichier) ;
`X-Content-Type-Options: nosniff` ; `Referrer-Policy: no-referrer`.
`X-Content-Type-Options: nosniff` ; `Referrer-Policy: no-referrer` ; et une
politique de sécurité du contenu (`Content-Security-Policy`) : tout vient du
site lui-même, aucun script ni style en ligne, aucun `eval`, seule la
compilation du WebAssembly est permise (`'wasm-unsafe-eval'`). Elle est
vérifiée par `tests/test_site.sh` (en-tête présent) et par
`make test-browser` : aucune violation signalée par Chromium ni Firefox
pendant toute une partie (moteur, Web Worker, service worker, aide), et un
script en ligne ajouté exprès à la page est bien bloqué.
## Vérifier
```bash
curl -s https://ustagammon.sans.pub/version.json # commit publié
curl -sI https://ustagammon.sans.pub/wasm/engine.wasm # application/wasm
curl -sI https://ustagammon.sans.pub/ | grep -i content-security # CSP
curl -s https://ustagammon.sans.pub/sw.js | head -2 # version du cache
ssh sans.pub 'sudo docker service ps srv-captain--ustagammon --format "{{.Image}} {{.CurrentState}}" | head -2'
```
Puis ouvrir le site sur le téléphone : la page de test doit afficher « Moteur
prêt ».
Puis ouvrir le site sur le téléphone : le plateau s'affiche et une consigne
remplace « Chargement du moteur… ». La page de test du moteur
(https://ustagammon.sans.pub/moteur/) doit afficher « Moteur prêt ».
## Retour arrière
+1 -1
View File
@@ -20,7 +20,7 @@ garde-fous d'architecture (0d.1), déploiement sur https://ustagammon.sans.pub.
| Sous-étape | Contenu | État | Plan |
| --- | --- | --- | --- |
| 0d.2 | Plateau minimal jouable (PWA hors ligne) ; vérifié avec Pierre en suivant `docs/scenarios/jouer-une-partie.feature` | ⏳ | |
| 0d.2 | Plateau minimal jouable (PWA hors ligne), publié ; reste la vérification avec Pierre sur le Fairphone 4 (`docs/guide-test-etape-0.md`, section « Étape 0d ») | 🔄 | |
**Critère de fin** : une partie complète jouée sur le Fairphone 4, moins de 2 s
par coup de l'IA en niveau standard, tous les tests au vert.
+100 -14
View File
@@ -1,38 +1,124 @@
# Guide de test : le moteur sur le Fairphone 4 (étape 0b)
# Guide de test : Ustagammon sur le Fairphone 4 (étape 0)
Document temporaire de l'étape 0 : il sera supprimé avec `docs/etape-0.md`.
Deux tests sur le téléphone :
- **Étape 0d : jouer une partie** (section suivante), sur le site publié ;
- **Étape 0b : mesurer le moteur** (fait le 2026-11-02), sur une page
servie par le PC.
## Étape 0d : jouer une partie sur le téléphone
Le but : installer l'application sur le Fairphone 4, jouer une partie
complète contre l'ordinateur, vérifier qu'elle marche sans réseau, et
rapporter ce que vous avez constaté. Comptez une demi-heure.
### Ouvrir et installer l'application
1. Sur le Fairphone, ouvrez Chrome et allez sur https://ustagammon.sans.pub.
2. Attendez que le plateau s'affiche et que le message « Chargement du
moteur… » laisse place à une consigne (par exemple « À vous : lancez les
dés. » ou « L'ordinateur réfléchit… »). La première fois, le moteur se
télécharge (environ 7 Mo) : quelques secondes en Wi-Fi.
3. Installez l'application : menu de Chrome (les trois points), puis
« Installer l'application » ou « Ajouter à l'écran d'accueil ». Une icône
(deux pions sur du bois) apparaît sur l'écran d'accueil.
4. Fermez Chrome et ouvrez Ustagammon depuis cette icône : l'application
s'ouvre en plein écran, sans barre d'adresse.
### Jouer une partie complète
Suivez le scénario « Jouer une partie contre l'ordinateur »
(`docs/scenarios/jouer-une-partie.feature`), qui est aussi l'aide de
l'application : bouton « Aide », en haut.
1. Une partie commence d'elle-même au premier lancement, par le jet
d'ouverture (un dé chacun) : le message dit qui commence. « Nouvelle
partie », en haut, en recommence une.
2. Quand c'est à vous : touchez « Lancer les dés » (ou « Doubler », pour
proposer de doubler avant de lancer).
3. Pour jouer : touchez un pion entouré de vert, puis l'une des cases
d'arrivée en surbrillance ; recommencez pour chaque dé. Pour sortir un
pion, touchez le bac à droite, en bas. « Annuler » remet les pions du coup
en cours à leur place.
4. Le coup est envoyé dès qu'il est complet ; l'ordinateur joue ensuite, et
le message dit son coup et son temps de calcul, par exemple
« L'ordinateur a joué 6-4 : 24/18 13/9 (0,3 s) ». Les petits points
orange marquent ses cases de départ et d'arrivée (dans sa numérotation :
sa case 24 est votre case 1).
5. Si l'ordinateur vous propose de doubler : « Prendre » ou « Passer ».
Essayez au moins une fois de doubler vous-même.
6. En fin de partie, le message donne le gagnant, la sorte de victoire
(simple, gammon ou backgammon) et les points.
En cours de partie, fermez l'application (balayez-la dans la liste des
applications ouvertes), puis rouvrez-la : la partie doit reprendre là où vous
en étiez.
### Vérifier le hors ligne
1. Après avoir ouvert l'application au moins une fois (attendez une
vingtaine de secondes après le premier chargement, le temps que tout soit
mis de côté), fermez-la.
2. Passez le téléphone en mode avion.
3. Rouvrez Ustagammon depuis son icône : le plateau, l'ordinateur et l'aide
doivent fonctionner comme avant.
4. Repassez en mode normal.
### Ce qu'il faut rapporter
Dans la conversation avec Claude Code :
- les **temps des coups de l'ordinateur** : ceux affichés dans les messages
(le plus long que vous avez vu), et votre ressenti (attente gênante ou
non) ;
- si la partie est allée **jusqu'au bout**, et son résultat ;
- si la **reprise** après fermeture et le **mode avion** ont marché ;
- tout ce qui gêne : plateau trop petit, case difficile à toucher, texte
incompréhensible, bouton qui ne répond pas, couleurs peu lisibles (thème
clair ou sombre du téléphone) ;
- une **capture d'écran** (bouton marche + volume bas) de tout problème.
Si vous le pouvez, refaites l'essai dans Firefox sur le téléphone (sans
l'installer) et dites si les temps de l'ordinateur sont nettement plus longs.
## Étape 0b : mesurer le moteur
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. 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
### 2. Lancer la page
1. Tapez `make deploy-web` puis Entrée.
1. Tapez `make deploy` 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
Application : http://192.168.1.21:8080/
Test du moteur : http://192.168.1.21:8080/moteur/
Page de mesure : http://192.168.1.21:8080/moteur/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
### 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`).
2. Tapez l'adresse du **test du moteur** dans la barre d'adresse, exactement
comme affichée (avec `http://` et `:8080`). Les pages de test sont aussi
publiées : https://ustagammon.sans.pub/moteur/ et
https://ustagammon.sans.pub/moteur/bench.html.
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) ».
@@ -40,7 +126,7 @@ Comptez un quart d'heure.
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
### 4. Lancer la mesure
1. En bas de la page, touchez « Page de mesure » (ou tapez l'adresse de la
page de mesure).
@@ -50,7 +136,7 @@ Comptez un quart d'heure.
4. Un tableau résume les résultats : « OK » si le seuil est respecté,
« dépassé » sinon.
## 5. Envoyer le rapport
### 5. Envoyer le rapport
1. Touchez « Copier les résultats ».
2. Si la page dit « Rapport copié », c'est bon.
@@ -65,7 +151,7 @@ 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
### Si la page ne s'ouvre pas
- **Vérifiez l'adresse** : `http://` (et non `https://`), les chiffres exacts,
et `:8080` à la fin.
@@ -73,7 +159,7 @@ lent, et le rapport indique le navigateur utilisé.
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.
plus, relancez `make deploy` 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
@@ -82,4 +168,4 @@ lent, et le rapport indique le navigateur utilisé.
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.
`make deploy WEB_PORT=8081` et utilisez `:8081` dans l'adresse.
+25
View File
@@ -64,3 +64,28 @@ Une entrée datée par session : ce qui a été fait, décidé, et la suite.
- É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.
## 2026-11-02 (soir) et 2026-11-03
**Fait**
- Mesures sur le Fairphone 4 (Chrome 156) : 2 plis en 348 ms au pire, seuil
3 s ; ADR 0009 accepté (socle PWA en TypeScript), 0c abandonnée.
- Déploiement CapRover (MR !10) : app `ustagammon` créée, site publié sur
https://ustagammon.sans.pub (HTTPS forcé).
- Étape 0d.1 (MR !11) : domaine « partie » en journal d'événements, scénario
exécuté par Cucumber sur le vrai moteur, garde-fous d'architecture.
- Étape 0d.2 : plateau jouable au toucher en PWA hors ligne, aide intégrée
(glossaire et scénarios lus depuis leurs sources), CSP, `make test-browser`
(parties complètes dans Chromium et Firefox). Deux bugs trouvés et corrigés :
la pile du moteur WebAssembly débordait après quelques centaines de commandes
(plantage en cours de partie), et le service worker échouait dans Chromium en
téléchargeant les données en même temps que le moteur.
- Interruptions de quota : reprise programmée à 23h50 ; l'agent de 0d.2 arrêté a
été remplacé par un agent de reprise sur le même espace de travail.
**Reste à faire**
- Vérification finale de l'étape 0 avec Pierre : une partie complète sur le
Fairphone 4, en suivant le guide de test (section « Étape 0d »).
- Sous Firefox sur PC, un tour de l'ordinateur a pris jusqu'à 5,1 s
(Chromium : 0,4 s) ; Firefox sur le téléphone reste à mesurer.
- Validation par Pierre de Playwright (Apache-2.0), outil de `make test-browser`.
+5
View File
@@ -5,6 +5,11 @@ joueur, écrit pour lui. Ces fichiers sont la seule source (ADR 0008) :
l'application les met en forme comme pages d'aide, et ils sont exécutés comme
tests d'intégration sur le domaine et le moteur réel.
`make site` copie ce dossier dans le site publié (`docs/scenarios/`) ; l'aide
de l'application (bouton « Aide ») lit ces copies à l'exécution
(`app/src/ui/help.ts`). Les fichiers affichés dans l'aide sont ceux de la
liste `FEATURES` de `help.ts`.
`make test` les exécute avec Cucumber : les étapes, définies dans
`app/tests/steps/`, pilotent le domaine branché sur le vrai moteur
WebAssembly, avec des dés imposés pour que chaque exécution soit identique. Une
+1 -1
View File
@@ -10,7 +10,7 @@ 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 { DOWNLOADED_FILES, formatReport, measureTimes } from "../web/moteur/benchmark.mjs";
import { commands, loadEngine } from "../web/engine-wasm.mjs";
const root = new URL("../", import.meta.url);
+6 -2
View File
@@ -33,14 +33,18 @@ ENGINE_INCLUDES := -I$(COMPAT) -I$(COMPAT)/glib-shim -I$(GNUBG) -I$(GNUBG)/lib -
# - 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.
# mégaoctets ;
# - stackSave et stackRestore : callMain laisse ses arguments sur la pile
# (64 Ko) ; sans restauration après chaque commande, elle déborde au bout
# de quelques centaines de commandes et corrompt la mémoire du moteur
# (voir web/engine-wasm.mjs).
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 \
-sEXPORTED_RUNTIME_METHODS=callMain,ccall,HEAPU8,stackSave,stackRestore \
--preload-file $(BUILD)/data@/data
DATA := $(BUILD)/data/gnubg.wd $(BUILD)/data/gnubg_os0.bd $(BUILD)/data/gnubg_ts0.bd \
+73
View File
@@ -0,0 +1,73 @@
#!/usr/bin/env python3
# Produit les icônes PNG de l'application (web/icons/icon-192.png et
# icon-512.png) à partir des formes de web/icons/icon.svg : un fond de bois
# et deux pions. Icônes provisoires, en attendant l'identité définitive.
#
# Python seul, sans bibliothèque : chaque pixel est échantillonné 4 × 4 fois
# pour adoucir les bords. À relancer seulement si les formes changent, dans
# la chaîne C (Python 3) :
# docker run --rm --user "$(id -u):$(id -g)" -v "$PWD:/src" -w /src \
# ustagammon/build-c:2 python3 scripts/make-icons.py
#
# Copyright (C) 2026 les auteurs d'Ustagammon.
# Licence : GPLv3 ou ultérieure (voir LICENSE).
import struct
import zlib
BACKGROUND = (0x6B, 0x44, 0x26)
# Pions dessinés dans l'ordre : (centre x, centre y, rayon, remplissage,
# bord), en unités d'une icône de 512 pixels ; bord de 10 unités.
CHECKERS = [
(300, 300, 120, (0x2B, 0x22, 0x1C), (0x0D, 0x0A, 0x08)),
(212, 212, 120, (0xFB, 0xF6, 0xEA), (0x7D, 0x6A, 0x52)),
]
EDGE = 10
SAMPLES = 4
def color_at(x, y):
"""Couleur d'un point de l'icône, en unités de 512."""
color = BACKGROUND
for cx, cy, r, fill, edge in CHECKERS:
d2 = (x - cx) ** 2 + (y - cy) ** 2
if d2 <= (r + EDGE / 2) ** 2:
color = fill if d2 <= (r - EDGE / 2) ** 2 else edge
return color
def render(size):
scale = 512 / size
rows = []
for py in range(size):
row = bytearray([0]) # filtre PNG « aucun »
for px in range(size):
total = [0, 0, 0]
for sy in range(SAMPLES):
for sx in range(SAMPLES):
x = (px + (sx + 0.5) / SAMPLES) * scale
y = (py + (sy + 0.5) / SAMPLES) * scale
for i, c in enumerate(color_at(x, y)):
total[i] += c
row += bytes(round(t / SAMPLES**2) for t in total)
rows.append(bytes(row))
return b"".join(rows)
def png(size, pixels):
def chunk(kind, data):
body = kind + data
return struct.pack(">I", len(data)) + body + struct.pack(">I", zlib.crc32(body))
header = struct.pack(">IIBBBBB", size, size, 8, 2, 0, 0, 0) # RVB, 8 bits
return (
b"\x89PNG\r\n\x1a\n"
+ chunk(b"IHDR", header)
+ chunk(b"IDAT", zlib.compress(pixels, 9))
+ chunk(b"IEND", b"")
)
for size in (192, 512):
with open(f"web/icons/icon-{size}.png", "wb") as f:
f.write(png(size, render(size)))
+40
View File
@@ -0,0 +1,40 @@
#!/usr/bin/env bash
# Complète le service worker d'un site assemblé (make site) : en tête de
# <site>/sw.js (copie de web/sw.js), écrit la liste de tous les fichiers du
# site, à mettre en cache pour le hors ligne, et la version du site, une
# empreinte de leur contenu.
#
# Le navigateur compare sw.js octet par octet à chaque visite : le moindre
# fichier modifié change l'empreinte, donc sw.js, ce qui déclenche la mise à
# jour du cache. Un site identique garde le même sw.js : rien à
# retélécharger.
#
# Usage : scripts/service-worker.sh build/site
set -euo pipefail
site="${1:?usage : scripts/service-worker.sh <dossier du site>}"
cd "$site"
[ -f sw.js ] || { echo "$site/sw.js absent" >&2; exit 1; }
# Tous les fichiers sauf sw.js, triés pour que l'empreinte soit stable.
mapfile -t files < <(find . -type f ! -path ./sw.js | sed 's|^\./||' | LC_ALL=C sort)
version="$(sha256sum "${files[@]}" | sha256sum | cut -c1-16)"
{
echo "// Généré par scripts/service-worker.sh (make site) : ne pas modifier."
echo "const VERSION = \"$version\";"
echo "const FILES = ["
for f in "${files[@]}"; do
# Une page index.html est aussi demandée sous le nom de son dossier.
case "$f" in
index.html) echo ' "./",' ;;
*/index.html) echo " \"${f%index.html}\"," ;;
esac
echo " \"$f\","
done
echo "];"
echo
cat sw.js
} >sw.js.tmp
mv sw.js.tmp sw.js
+48
View File
@@ -0,0 +1,48 @@
#!/usr/bin/env bash
# Joue une partie complète par l'interface dans Chromium et Firefox sans
# écran (tests/browser/play.mjs), sur le site servi par l'image de production
# construite en local, comme tests/test_site.sh. Les captures d'écran vont
# dans build/browser-shots/.
#
# Outils : l'image de Playwright (navigateurs) et le paquet playwright,
# installé une fois par npm dans build/browser-tools/ (réseau nécessaire la
# première fois). Lent (plusieurs minutes) : lancé par make test-browser,
# pas par make test.
set -euo pipefail
cd "$(dirname "$0")/.."
image="${PLAYWRIGHT_IMAGE:?lancer via make test-browser}"
browsers="${BROWSERS:-chromium firefox}"
unset MAKEFLAGS MFLAGS MAKELEVEL
make -s package >/dev/null
site_image="ustagammon/site-test:local"
docker build -q -t "$site_image" build/deploy >/dev/null
container="$(docker run -d --rm -p 127.0.0.1::80 "$site_image")"
trap 'docker stop "$container" >/dev/null' EXIT
port="$(docker port "$container" 80/tcp | head -n1 | sed 's/.*://')"
# Le paquet playwright, à la version des navigateurs de l'image.
tools=build/browser-tools
version="${image##*:v}"
version="${version%%-*}"
if [ ! -f "$tools/node_modules/playwright/package.json" ]; then
mkdir -p "$tools"
docker run --rm --user "$(id -u):$(id -g)" -e HOME=/tmp -v "$PWD/$tools:/tools" -w /tools \
"$image" npm install --no-audit --no-fund --save-exact "playwright@$version" >/dev/null
fi
rm -rf build/browser-shots
mkdir -p build/browser-shots
cp tests/browser/play.mjs "$tools/"
status=0
for browser in $browsers; do
# Réseau de l'hôte : le site est sur 127.0.0.1. Le service worker
# demande un contexte sûr, ce qu'est localhost.
docker run --rm --init --ipc=host --network host --user "$(id -u):$(id -g)" -e HOME=/tmp \
-e TRACE -v "$PWD/$tools:/tools" -v "$PWD/build/browser-shots:/shots" -w /tools "$image" \
node play.mjs "http://localhost:$port/" "$browser" /shots || status=1
done
exit "$status"
+328
View File
@@ -0,0 +1,328 @@
// play.mjs -- joue une partie complète par l'interface, dans un vrai
// navigateur sans écran (Playwright), sur le site servi par l'image de
// production : dés, coups au toucher, coups de l'ordinateur, videau, reprise
// après rechargement, aide, puis hors ligne (service worker).
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
//
// Usage : node play.mjs <adresse du site> <chromium|firefox> <dossier des captures>
// Lancé par tests/browser.sh, dans l'image de Playwright. Sort en erreur au
// premier problème, ou si la page signale une erreur (dont une violation de
// la politique de sécurité du contenu).
import { chromium, firefox } from "playwright";
const [base, browserName, shots] = process.argv.slice(2);
const browserType = { chromium, firefox }[browserName];
if (!base || !browserType || !shots) {
console.error("usage : node play.mjs <adresse> <chromium|firefox> <dossier des captures>");
process.exit(2);
}
const browser = await browserType.launch();
const context = await browser.newContext({
viewport: { width: 360, height: 740 },
deviceScaleFactor: 2,
locale: "fr-FR",
});
// Pour le diagnostic d'un blocage : la dernière commande envoyée au moteur
// (Web Worker), et si elle a reçu sa réponse ; et, pour vérifier la
// politique de sécurité du contenu, chaque violation signalée par la page.
await context.addInitScript(() => {
window.__engine = { sent: 0, received: 0, last: null };
const post = Worker.prototype.postMessage;
Worker.prototype.postMessage = function (message, ...rest) {
window.__engine.sent++;
window.__engine.last = message?.args ?? null;
if (!this.__counted) {
this.__counted = true;
this.addEventListener("message", () => window.__engine.received++);
}
return post.call(this, message, ...rest);
};
window.__csp = [];
document.addEventListener("securitypolicyviolation", (e) =>
window.__csp.push(`${e.effectiveDirective} ${e.blockedURI}`),
);
});
const page = await context.newPage();
const problems = [];
page.on("pageerror", (error) => problems.push(`erreur de la page : ${error.message}`));
page.on("crash", () => {
console.error(`[${browserName}] la page a planté`);
process.exit(1);
});
// Vrai pendant le contrôle final de la politique de sécurité, qui provoque
// exprès une violation.
let cspProbe = false;
page.on("console", (message) => {
if (message.type() === "error" && !(cspProbe && /Content.Security.Policy/i.test(message.text()))) {
problems.push(`console : ${message.text()}`);
}
});
const log = (text) => console.log(`[${browserName}] ${text}`);
const shot = (name) => page.screenshot({ path: `${shots}/${browserName}-${name}.png` });
/** Relève les violations de la CSP signalées par la page (avant chaque
* rechargement, qui les efface). */
const violations = [];
async function collectViolations() {
violations.push(...(await page.evaluate(() => window.__csp)));
}
function check(condition, message) {
if (!condition) {
throw new Error(message);
}
}
/** Les coups joués de chaque camp, d'après le journal enregistré. */
async function savedJournal() {
return page.evaluate(() => JSON.parse(localStorage.getItem("ustagammon.current-game")));
}
function countMoves(journal, side) {
return journal.events.filter((e) => e.type === "MovePlayed" && e.side === side).length;
}
/**
* Attend que le joueur ait quelque chose à faire, et dit quoi. (Pas de
* page.waitForFunction : Playwright y passe par eval, que la politique de
* sécurité du site interdit, à raison.)
*/
async function waitForPlayer() {
for (let i = 0; i < 600; i++) {
const todo = await page.evaluate(() => {
if (document.body.classList.contains("busy")) {
return null;
}
const usable = (id) => {
const b = document.getElementById(id);
return !b.hidden && !b.disabled;
};
for (const id of ["again", "take", "roll"]) {
if (usable(id)) {
return id;
}
}
return document.querySelector(".hit.can-move") ? "move" : null;
});
if (todo !== null) {
return todo;
}
await new Promise((resolve) => setTimeout(resolve, 100));
}
await shot("bloque").catch(() => {});
const screen = await page
.evaluate(() => {
const text = (id) => document.getElementById(id)?.textContent;
const busy = document.body.classList.contains("busy") ? "occupé" : "libre";
const engine = window.__engine;
return (
`${busy} ; « ${text("info")} » ; « ${text("prompt")} » ; moteur : ` +
`${engine.sent} commandes, ${engine.received} réponses, dernière ` +
`${JSON.stringify(engine.last)} ; journal ` +
localStorage.getItem("ustagammon.current-game")
);
})
.catch((error) => `page illisible (${error.message})`);
throw new Error(`rien à faire pour le joueur depuis 60 s : ${screen}`);
}
// Garde-fou : une étape bloquée plus de deux minutes arrête tout, avec une
// capture et l'état de l'écran.
let lastStep = Date.now();
let stepName = "chargement";
function step(name) {
lastStep = Date.now();
stepName = name;
}
const watchdog = setInterval(async () => {
if (Date.now() - lastStep > 120_000) {
clearInterval(watchdog);
console.error(`[${browserName}] bloqué depuis 2 minutes (étape : ${stepName})`);
await shot("bloque").catch(() => {});
process.exit(1);
}
}, 5_000);
const start = Date.now();
await page.goto(base);
const first = await waitForPlayer();
log(`moteur chargé, partie prête en ${Date.now() - start} ms (${first})`);
await shot("1-debut");
let doubled = false;
let selectionShot = false;
let reloaded = false;
let computerMs = [];
for (let actions = 0; ; actions++) {
check(actions < 800, "la partie ne finit pas");
const todo = await waitForPlayer();
step(`partie, action ${actions}`);
if (process.env.TRACE) {
log(`${todo} : ${await page.textContent("#info")} / ${await page.textContent("#prompt")}`);
}
const info = await page.textContent("#info");
const timing = /\((\d+),(\d) s\)/.exec(info ?? "");
if (timing) {
computerMs.push(Number(timing[1]) * 1000 + Number(timing[2]) * 100);
}
if (todo === "again") {
break;
}
if (todo === "take") {
log("l'ordinateur double : vous prenez");
await shot("videau-propose");
await page.click("#take");
} else if (todo === "roll") {
if (!doubled && (await page.isEnabled("#double"))) {
doubled = true;
log("vous doublez");
await page.click("#double");
await waitForPlayer();
const answer = (await page.textContent("#info")) || (await page.textContent("#prompt"));
log(`réponse : ${answer}`);
continue;
}
await page.click("#roll");
} else {
await page.click(".hit.can-move");
await page.waitForSelector(".hit.can-land");
if (!selectionShot) {
selectionShot = true;
await shot("2-pion-touche");
}
await page.click(".hit.can-land");
}
// Une fois, au milieu de la partie : fermer et rouvrir la page.
const journal = await savedJournal();
if (!reloaded && countMoves(journal, "computer") >= 3 && countMoves(journal, "player") >= 3) {
reloaded = true;
await waitForPlayer();
await shot("3-milieu-de-partie");
const before = await savedJournal();
const pips = await page.textContent("#pips");
await collectViolations();
await page.reload();
await waitForPlayer();
const after = await savedJournal();
check(after.head === before.head, "la partie n'a pas été reprise après rechargement");
check((await page.textContent("#pips")) === pips, "plateau différent après rechargement");
log(`partie reprise après rechargement (événement ${after.head})`);
}
}
const journal = await savedJournal();
const result = await page.textContent("#prompt");
log(
`partie finie : ${result} (${countMoves(journal, "player")} coups du joueur, ` +
`${countMoves(journal, "computer")} de l'ordinateur, ${journal.events.length} événements)`,
);
check(countMoves(journal, "player") >= 3 && countMoves(journal, "computer") >= 3, "trop peu de coups");
check(reloaded, "la reprise après rechargement n'a pas été essayée");
if (computerMs.length > 0) {
log(`temps affichés des tours de l'ordinateur : max ${Math.max(...computerMs)} ms, ` +
`${computerMs.length} tours`);
}
await shot("4-fin-de-partie");
step("aide");
// Aide : scénario et glossaire lus dans les copies de docs/.
await page.click("#help-button");
await page.waitForSelector(".glossary dt");
const terms = await page.locator(".glossary dt").count();
const scenarios = await page.locator("#help-content h3").count();
check(terms >= 20 && scenarios >= 5, `aide incomplète : ${terms} termes, ${scenarios} scénarios`);
log(`aide : ${scenarios} scénarios, ${terms} termes du glossaire`);
await shot("5-aide");
await page.click("#help-back");
step("thème sombre");
// Thème sombre.
await page.emulateMedia({ colorScheme: "dark" });
await page.click("#again");
await waitForPlayer();
await shot("6-sombre");
await page.emulateMedia({ colorScheme: "light" });
step("mise en cache");
// Hors ligne : une fois le cache du service worker rempli, la page se
// recharge sans réseau, et le moteur répond.
const cached = await page.evaluate(async () => {
// Le service worker est enregistré une fois le moteur chargé ; s'il ne
// s'installe pas, l'attente s'arrête au bout d'une minute.
for (let i = 0; i < 600; i++) {
const names = await caches.keys();
if (names.length === 1) {
const cache = await caches.open(names[0]);
const keys = await cache.keys();
const active = (await navigator.serviceWorker.getRegistration())?.active;
if (active && keys.some((r) => r.url.endsWith("/wasm/engine.data"))) {
return keys.length;
}
}
await new Promise((resolve) => setTimeout(resolve, 100));
}
return 0;
});
check(cached > 0, "le service worker n'a pas mis le site en cache");
// Firefox sous Playwright refuse toute navigation une fois le contexte
// « hors ligne », même servie par le service worker (NS_ERROR_OFFLINE) : on
// y vérifie seulement que la page rechargée est contrôlée par le service
// worker, dont le cache est complet.
const offlineMode = browserName === "chromium";
if (offlineMode) {
await context.setOffline(true);
}
step("rechargement hors ligne");
await collectViolations();
await page.reload();
const controlled = await page.evaluate(() => navigator.serviceWorker.controller !== null);
check(controlled, "la page rechargée n'est pas contrôlée par le service worker");
const offline = await waitForPlayer();
if (offline === "roll") {
await page.click("#roll");
await waitForPlayer();
}
await page.click("#help-button");
await page.waitForSelector(".glossary dt");
await page.click("#help-back");
log(
offlineMode
? `hors ligne : page rechargée sans réseau depuis le cache (${cached} adresses), moteur et aide disponibles`
: `cache du service worker rempli (${cached} adresses), page rechargée sous son contrôle ` +
"(le hors ligne lui-même n'est pas simulable dans Firefox sous Playwright)",
);
await shot("7-hors-ligne");
step("CSP");
// Politique de sécurité du contenu : aucune violation pendant toute la
// partie (moteur WebAssembly, Web Worker, service worker, aide compris) ; et
// elle est bien appliquée : un script en ligne ajouté à la page est bloqué.
await collectViolations();
check(violations.length === 0, `violations de la CSP : ${violations.join(", ")}`);
cspProbe = true;
const probe = await page.evaluate(async () => {
const script = document.createElement("script");
script.textContent = "window.__inline = true;";
document.head.append(script);
await new Promise((resolve) => setTimeout(resolve, 300));
return { ran: window.__inline === true, violations: window.__csp };
});
check(!probe.ran, "CSP non appliquée : un script en ligne a été exécuté");
check(
probe.violations.some((v) => v.startsWith("script-src")),
"CSP : le script en ligne bloqué n'a pas été signalé",
);
log("CSP : aucune violation pendant la partie, script en ligne bloqué");
clearInterval(watchdog);
await browser.close();
if (problems.length > 0) {
console.error(problems.join("\n"));
process.exit(1);
}
log("aucune erreur dans la page");
+51 -2
View File
@@ -1,7 +1,8 @@
#!/usr/bin/env bash
# Vérifie l'image de production (deploy/) avec le site assemblé (make package) :
# construit l'image en local, la lance, et contrôle ce que reçoit un
# navigateur (pages, types MIME du moteur, compression, version).
# navigateur (application, pages de test du moteur, aide, manifeste, service
# worker, types MIME, compression, politique de sécurité, version).
set -euo pipefail
cd "$(dirname "$0")/.."
@@ -37,12 +38,60 @@ check() {
}
check "" "text/html"
check "bench.html" "text/html"
check "app.css" "text/css"
check "app/main.js" "text/javascript"
check "app/ui/app.js" "text/javascript"
check "app/domain/game/game.js" "text/javascript"
check "manifest.webmanifest" "application/manifest\+json"
check "icons/icon-192.png" "image/png"
check "icons/icon-512.png" "image/png"
check "sw.js" "text/javascript"
check "engine-worker.mjs" "text/javascript"
check "wasm/engine.mjs" "text/javascript"
check "wasm/engine.wasm" "application/wasm"
check "wasm/engine.data" "application/octet-stream"
check "version.json" "application/json"
# Pages de test du moteur (étape 0b).
check "moteur/" "text/html"
check "moteur/bench.html" "text/html"
check "moteur/engine-client.mjs" "text/javascript"
# Aide : copies du glossaire et des scénarios (ADR 0008).
check "docs/scenarios/jouer-une-partie.feature" "text/plain"
check "docs/glossaire/README.md" "text/markdown"
check "docs/glossaire/videau.md" "text/markdown"
cmp -s <(curl -s "$base/docs/glossaire/videau.md") docs/glossaire/videau.md \
|| report "/docs/glossaire/videau.md diffère de la source"
# Le manifeste désigne des icônes servies, et l'application à la racine.
manifest="$(curl -s "$base/manifest.webmanifest")"
grep -q '"start_url": "./"' <<<"$manifest" || report "manifeste : start_url absent"
while read -r icon; do
curl -sf -o /dev/null "$base/$icon" || report "manifeste : icône $icon introuvable"
done < <(grep -o '"src": "[^"]*"' <<<"$manifest" | sed 's/"src": "\(.*\)"/\1/')
# Le service worker met en cache chaque fichier du site, avec une version.
sw="$(curl -s "$base/sw.js")"
grep -qE '^const VERSION = "[0-9a-f]{16}";' <<<"$sw" || report "sw.js : version absente"
for file in ./ index.html app/main.js wasm/engine.data wasm/engine.wasm moteur/ \
docs/glossaire/videau.md version.json; do
grep -qF " \"$file\"," <<<"$sw" || report "sw.js : $file absent de la liste à mettre en cache"
done
listed="$(grep -c '^ "' <<<"$sw")"
present="$(find build/site -type f ! -name sw.js | wc -l)"
# Chaque fichier, plus « ./ » et « moteur/ » pour les deux index.html.
[ "$listed" -eq "$((present + 2))" ] \
|| report "sw.js liste $listed adresses pour $present fichiers (plus 2 dossiers)"
grep -o '^ "[^"]*"' <<<"$sw" | tr -d ' "' | while read -r file; do
curl -sf -o /dev/null "$base/$file" || echo "sw.js : $file introuvable sur le site"
done | grep . && report "sw.js liste des fichiers introuvables"
# Politique de sécurité du contenu : WebAssembly permis, rien en ligne.
csp="$(curl -s -D - -o /dev/null "$base/" | grep -i '^Content-Security-Policy:')"
grep -q "script-src 'self' 'wasm-unsafe-eval';" <<<"$csp" \
|| report "CSP absente ou sans 'wasm-unsafe-eval'"
grep -q "unsafe-inline" <<<"$csp" && report "CSP : unsafe-inline ne doit pas être permis"
curl -s -D - -o /dev/null "$base/engine-worker.mjs" | grep -qi '^Content-Security-Policy:' \
|| report "CSP absente du Web Worker du moteur"
curl -s -H "Accept-Encoding: gzip" -D - -o /dev/null "$base/wasm/engine.data" \
| grep -qi "^Content-Encoding: gzip" || report "/wasm/engine.data : pas de compression gzip"
+11 -2
View File
@@ -46,8 +46,13 @@ web app depcruise --config .dependency-cruiser.cjs src tests \
# doit être signalée par dependency-cruiser.
fixture=build/web-violations
rm -rf "$fixture"
mkdir -p "$fixture/src/domain" "$fixture/src/engine" "$fixture/src/ui"
mkdir -p "$fixture/src/domain" "$fixture/src/engine" "$fixture/src/ui" "$fixture/src/storage"
cp app/tsconfig.json "$fixture/"
echo 'export const main = 1;' >"$fixture/src/main.ts"
echo 'export const store = 1;' >"$fixture/src/storage/store.ts"
echo 'import { store } from "../storage/store.js"; export const f = store;' >"$fixture/src/ui/uses-storage.ts"
echo 'import { e } from "../ui/loop-b.js"; export const g = e;' >"$fixture/src/storage/uses-ui.ts"
echo 'import { main } from "../main.js"; export const h = main;' >"$fixture/src/ui/uses-main.ts"
echo 'import { engine } from "../engine/adapter.js"; export const a = engine;' >"$fixture/src/domain/uses-engine.ts"
echo 'import { readFileSync } from "node:fs"; export const b = readFileSync;' >"$fixture/src/domain/uses-node.ts"
echo 'export const engine = 1;' >"$fixture/src/engine/adapter.ts"
@@ -56,10 +61,14 @@ echo 'import { e } from "./loop-b.js"; export const d = e;' >"$fixture/src/ui/lo
echo 'import { d } from "./loop-a.js"; export const e = d;' >"$fixture/src/ui/loop-b.ts"
output="$(web "$fixture" depcruise --config /src/app/.dependency-cruiser.cjs src 2>&1)" \
&& report "dependency-cruiser accepte des violations volontaires"
for rule in domain-is-pure ui-does-not-call-engine no-circular; do
for rule in domain-is-pure ui-does-not-call-engine only-main-assembles nothing-imports-main no-circular; do
grep -q "$rule" <<<"$output" || report "violation volontaire de $rule non détectée"
done
grep -q 'uses-node' <<<"$output" || report "import de Node dans le domaine non détecté"
for file in uses-storage uses-ui; do
grep -q "only-main-assembles: src/[a-z]*/$file" <<<"$output" \
|| report "assemblage hors de main.ts non détecté ($file)"
done
rm -rf "$fixture"
[ "$errors" -eq 0 ]
+283
View File
@@ -0,0 +1,283 @@
/* Style de l'application. Palette provisoire et sobre (bois, ivoire, un
* accent vert), en attendant l'identité définitive : toutes les couleurs et
* tailles sont des variables, redéfinies pour le thème sombre. */
:root {
--text: #2b211a;
--muted: #6b5d50;
--background: #f7f2e8;
--accent: #2e6b4f;
--on-accent: #ffffff;
--border: #cbbfae;
--error: #a3261b;
--frame: #6b4426;
--bed: #e6cfa6;
--bar: #5c3a20;
--tray: #57371e;
--point-a: #9a6439;
--point-b: #c79b69;
--point-number: #f3e7d0;
--checker-player: #fbf6ea;
--checker-player-edge: #7d6a52;
--checker-computer: #2b221c;
--checker-computer-edge: #0d0a08;
--count-player: #2b211a;
--count-computer: #fbf6ea;
--die-player: #fbf6ea;
--pip-player: #2b211a;
--die-computer: #2b221c;
--pip-computer: #fbf6ea;
--cube: #fbf6ea;
--cube-text: #2b211a;
--highlight: #2e6b4f;
--target: rgba(46, 107, 79, 0.35);
--last-move: #d9822b;
--font-size: 17px;
--button-height: 48px;
--radius: 8px;
--page-width: 40rem;
}
@media (prefers-color-scheme: dark) {
:root {
--text: #ece4d6;
--muted: #b3a796;
--background: #1b1714;
--accent: #6fc3a0;
--on-accent: #10221a;
--border: #4d443b;
--error: #f08a7e;
--frame: #3a2616;
--bed: #6d5136;
--bar: #2f1f12;
--tray: #2c1d11;
--point-a: #4a3120;
--point-b: #8b6943;
--point-number: #d9cbb4;
--checker-player: #efe6d2;
--checker-player-edge: #8e7c64;
--checker-computer: #17110d;
--checker-computer-edge: #a8957d;
--highlight: #7fd6b0;
--target: rgba(127, 214, 176, 0.35);
--last-move: #f0a050;
}
}
* {
box-sizing: border-box;
}
body {
margin: 0 auto;
max-width: var(--page-width);
padding: 0 0.5rem 1rem;
font: var(--font-size) / 1.45 system-ui, sans-serif;
color: var(--text);
background: var(--background);
}
[hidden] {
display: none !important;
}
.topbar {
display: flex;
gap: 0.5rem;
align-items: center;
padding: 0.5rem 0;
}
.topbar h1 {
flex: 1;
margin: 0;
font-size: 1.25rem;
}
button {
min-height: var(--button-height);
padding: 0 1rem;
font: inherit;
font-weight: 600;
color: var(--on-accent);
background: var(--accent);
border: 1px solid var(--accent);
border-radius: var(--radius);
}
button.secondary {
color: var(--text);
background: transparent;
border-color: var(--border);
}
.topbar button {
min-height: 40px;
padding: 0 0.75rem;
font-size: 0.9rem;
}
button:disabled {
opacity: 0.45;
}
.actions {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.actions button {
flex: 1;
}
.pips {
margin: 0.25rem 0 0;
font-size: 0.85rem;
color: var(--muted);
text-align: center;
}
.info,
.prompt {
min-height: 1.45em;
margin: 0.4rem 0;
}
.info {
color: var(--muted);
}
.prompt {
font-weight: 600;
}
/* L'ordinateur réfléchit : trois points qui apparaissent l'un après l'autre. */
.prompt.thinking::after {
content: "";
animation: thinking 1.2s steps(4, end) infinite;
}
@keyframes thinking {
0% { content: ""; }
25% { content: "."; }
50% { content: ".."; }
75% { content: "..."; }
}
/* ------------------------------------------------------------ Plateau */
.board {
display: block;
width: 100%;
height: auto;
aspect-ratio: 640 / 520;
user-select: none;
-webkit-tap-highlight-color: transparent;
}
.board-frame { fill: var(--frame); }
.board-bed { fill: var(--bed); }
.board-bar { fill: var(--bar); }
.board-tray { fill: var(--tray); }
.point-a { fill: var(--point-a); }
.point-b { fill: var(--point-b); }
.point-number {
fill: var(--muted);
font-size: 13px;
text-anchor: middle;
}
.checker {
stroke-width: 2;
}
.checker-player {
fill: var(--checker-player);
stroke: var(--checker-player-edge);
}
.checker-computer {
fill: var(--checker-computer);
stroke: var(--checker-computer-edge);
}
.checker.movable {
stroke: var(--highlight);
stroke-width: 4;
}
.checker.selected {
stroke: var(--highlight);
stroke-width: 7;
}
.checker-count {
font-size: 18px;
font-weight: 700;
text-anchor: middle;
}
.count-player { fill: var(--count-player); }
.count-computer { fill: var(--count-computer); }
.target-zone { fill: var(--target); }
.last-move { fill: var(--last-move); }
.die-player { fill: var(--die-player); stroke: var(--checker-player-edge); }
.die-computer { fill: var(--die-computer); stroke: var(--checker-computer-edge); }
.pip-player { fill: var(--pip-player); }
.pip-computer { fill: var(--pip-computer); }
.cube-face { fill: var(--cube); stroke: var(--checker-player-edge); stroke-width: 2; }
.cube-value {
fill: var(--cube-text);
font-size: 17px;
font-weight: 700;
text-anchor: middle;
}
.hit {
fill: transparent;
cursor: pointer;
}
/* --------------------------------------------------------------- Aide */
.help h2 {
margin-top: 1.5rem;
font-size: 1.2rem;
}
.help h3 {
margin-bottom: 0.25rem;
font-size: 1rem;
}
.steps {
padding-left: 1.25rem;
}
.glossary dt {
margin-top: 0.6rem;
font-weight: 600;
}
.glossary dd {
margin: 0;
}
.about {
margin-top: 2rem;
font-size: 0.85rem;
color: var(--muted);
}
.about a {
color: var(--accent);
}
+11 -2
View File
@@ -36,10 +36,19 @@ export async function loadEngine(createEngineModule, wasmDir) {
throw new Error("le moteur n'a pas pu charger ses données (code " + status + ")");
}
// callMain copie les arguments sur la pile du WebAssembly sans jamais
// les retirer : on remet la pile en place après chaque commande, sinon
// elle déborde au bout de quelques centaines de commandes (environ 540
// pendant une partie) et le moteur plante ou ne répond plus.
function runText(args) {
output = [];
const exitCode = module.callMain(args.map(String));
return { text: output.join("\n"), exitCode };
const stack = module.stackSave();
try {
const exitCode = module.callMain(args.map(String));
return { text: output.join("\n"), exitCode };
} finally {
module.stackRestore(stack);
}
}
function run(args) {
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.1 KiB

+7
View File
@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<!-- Icône provisoire (pas un logo) : deux pions sur du bois. Les PNG sont
produits à partir des mêmes formes par scripts/make-icons.py. -->
<rect width="512" height="512" fill="#6b4426"/>
<circle cx="300" cy="300" r="120" fill="#2b221c" stroke="#0d0a08" stroke-width="10"/>
<circle cx="212" cy="212" r="120" fill="#fbf6ea" stroke="#7d6a52" stroke-width="10"/>
</svg>

After

Width:  |  Height:  |  Size: 449 B

+32 -37
View File
@@ -3,46 +3,41 @@
<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>
<meta name="color-scheme" content="light dark">
<meta name="theme-color" content="#6b4426">
<title>Ustagammon</title>
<link rel="manifest" href="manifest.webmanifest">
<link rel="icon" href="icons/icon.svg" type="image/svg+xml">
<link rel="stylesheet" href="app.css">
<script type="module" src="app/main.js"></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>
<!-- Les textes des boutons et des messages viennent de app/src/ui/texts.ts. -->
<header class="topbar">
<h1>Ustagammon</h1>
<button id="help-button" type="button" class="secondary"></button>
<button id="new-game" type="button" class="secondary"></button>
</header>
<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 id="game">
<svg id="board" class="board" role="img" xmlns="http://www.w3.org/2000/svg"></svg>
<p id="pips" class="pips"></p>
<p id="info" class="info" aria-live="polite"></p>
<p id="prompt" class="prompt" aria-live="polite"></p>
<div class="actions">
<button id="roll" type="button" hidden></button>
<button id="double" type="button" class="secondary" hidden></button>
<button id="take" type="button" hidden></button>
<button id="drop" type="button" class="secondary" hidden></button>
<button id="cancel" type="button" class="secondary" hidden></button>
<button id="again" type="button" hidden></button>
</div>
<noscript><p>Ustagammon a besoin de JavaScript.</p></noscript>
</main>
<section id="help" class="help" hidden>
<button id="help-back" type="button" class="secondary"></button>
<div id="help-content"></div>
</section>
</body>
</html>
+18
View File
@@ -0,0 +1,18 @@
{
"name": "Ustagammon",
"short_name": "Ustagammon",
"description": "Jouer au backgammon contre l'ordinateur, hors ligne.",
"lang": "fr",
"start_url": "./",
"scope": "./",
"display": "standalone",
"orientation": "portrait",
"background_color": "#f7f2e8",
"theme_color": "#6b4426",
"icons": [
{ "src": "icons/icon-192.png", "sizes": "192x192", "type": "image/png" },
{ "src": "icons/icon-512.png", "sizes": "512x512", "type": "image/png" },
{ "src": "icons/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" },
{ "src": "icons/icon.svg", "sizes": "any", "type": "image/svg+xml" }
]
}
@@ -12,8 +12,9 @@ const $ = (id) => document.getElementById(id);
// Taille d'un fichier du site, brute et compressée gzip (comme le ferait un
// serveur qui compresse ses réponses).
// name est relatif à la racine du site, un dossier au-dessus de cette page.
async function fileSize(name) {
const blob = await (await fetch(name, { cache: "no-store" })).blob();
const 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 };
@@ -37,7 +38,7 @@ async function measure() {
await engine.ready;
const loadMs = performance.now() - start;
const { positions } = await (await fetch("wasm/positions.json")).json();
const { positions } = await (await fetch("../wasm/positions.json")).json();
let memoryBytes = 0;
const times = await measureTimes({
bestMove: (...args) => engine.bestMove(...args).then(keepMemory),
@@ -20,13 +20,13 @@ export const TIME_THRESHOLDS = [
// 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/).
// Fichiers téléchargés pour faire tourner le moteur dans la page de test,
// chemins relatifs à la racine du site (web/ ; wasm/ est build/wasm/).
export const DOWNLOADED_FILES = [
"index.html",
"style.css",
"page.mjs",
"engine-client.mjs",
"moteur/index.html",
"moteur/style.css",
"moteur/page.mjs",
"moteur/engine-client.mjs",
"engine-worker.mjs",
"engine-wasm.mjs",
"wasm/engine.mjs",
@@ -1,5 +1,6 @@
// engine-client.mjs -- le moteur vu depuis la page : démarre le Web Worker
// (engine-worker.mjs) et lui envoie les commandes.
// engine-client.mjs -- le moteur vu depuis les pages de test : démarre le
// Web Worker (../engine-worker.mjs, partagé avec l'application) et lui envoie
// les commandes.
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
@@ -8,10 +9,10 @@
// 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";
import { commands } from "../engine-wasm.mjs";
export function startEngine() {
const worker = new Worker(new URL("engine-worker.mjs", import.meta.url), { type: "module" });
const worker = new Worker(new URL("../engine-worker.mjs", import.meta.url), { type: "module" });
const pending = new Map();
let nextId = 1;
let resolveReady, rejectReady;
+48
View File
@@ -0,0 +1,48 @@
<!doctype html>
<html lang="fr">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Ustagammon : test du moteur</title>
<link rel="stylesheet" href="style.css">
<script type="module" src="page.mjs"></script>
</head>
<body>
<main>
<h1>Test du moteur</h1>
<p>Le moteur GNU Backgammon, compilé en WebAssembly, calcule dans le navigateur.</p>
<p id="status" class="status">Chargement du moteur…</p>
<form id="form">
<label>Exemple
<select id="example"><option value="">(choisir une position de référence)</option></select>
</label>
<label>Position ID
<input id="position-id" required autocomplete="off" spellcheck="false" value="4HPwATDgc/ABMA">
</label>
<label>Match ID
<input id="match-id" required autocomplete="off" spellcheck="false" value="cIkFAAAAAAAA">
</label>
<div class="row">
<label>Dé 1 <input id="die1" type="number" min="1" max="6" required value="3"></label>
<label>Dé 2 <input id="die2" type="number" min="1" max="6" required value="1"></label>
<label>Niveau
<select id="ply">
<option value="0">rapide (0 pli)</option>
<option value="2">fort (2 plis)</option>
</select>
</label>
</div>
<button id="best-move" type="submit" disabled>Meilleur coup</button>
</form>
<section id="result" class="result" hidden>
<p>Meilleur coup : <strong id="move"></strong></p>
<p id="details"></p>
<p>Temps de calcul : <strong id="time"></strong></p>
</section>
<p><a href="bench.html">Page de mesure</a> · <a href="../">L'application</a></p>
</main>
</body>
</html>
+1 -1
View File
@@ -21,7 +21,7 @@ engine.ready.then(
);
// Exemples : les positions de référence avec des dés.
fetch("wasm/positions.json")
fetch("../wasm/positions.json")
.then((response) => response.json())
.then(({ positions }) => {
for (const p of positions.filter((p) => p.type === "move")) {
+61
View File
@@ -0,0 +1,61 @@
// sw.js -- service worker d'Ustagammon : l'application hors ligne.
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
//
// À l'installation, tous les fichiers du site (dont les 8,7 Mo de données du
// moteur) sont mis en cache ; ensuite, chaque fichier est servi depuis ce
// cache, sans réseau. VERSION et FILES sont écrits en tête de ce fichier par
// scripts/service-worker.sh au moment de make site.
//
// Mise à jour : à chaque publication, VERSION change, donc ce fichier. Le
// navigateur installe alors la nouvelle version, qui télécharge tout dans un
// nouveau cache, prend la main tout de suite et efface l'ancien cache. La
// page déjà ouverte a chargé tous ses fichiers au démarrage : elle continue
// sans rien remarquer, et le prochain lancement utilise la nouvelle version.
/* global VERSION, FILES */
const CACHE = `ustagammon-${VERSION}`;
self.addEventListener("install", (event) => {
event.waitUntil(
(async () => {
const cache = await caches.open(CACHE);
// no-cache : revalider auprès du serveur, jamais une copie
// périmée du cache HTTP du navigateur.
await cache.addAll(FILES.map((file) => new Request(file, { cache: "no-cache" })));
await self.skipWaiting();
})(),
);
});
self.addEventListener("activate", (event) => {
event.waitUntil(
(async () => {
for (const name of await caches.keys()) {
if (name !== CACHE) {
await caches.delete(name);
}
}
await self.clients.claim();
})(),
);
});
self.addEventListener("fetch", (event) => {
const request = event.request;
if (request.method !== "GET" || new URL(request.url).origin !== self.location.origin) {
return;
}
event.respondWith(
(async () => {
const cached = await caches.match(request, {
cacheName: CACHE,
ignoreSearch: true,
ignoreVary: true,
});
return cached ?? fetch(request);
})(),
);
});