diff --git a/CREDITS.md b/CREDITS.md index 745588e..12f8660 100644 --- a/CREDITS.md +++ b/CREDITS.md @@ -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 diff --git a/Makefile b/Makefile index 1c9e5b3..7746b09 100644 --- a/Makefile +++ b/Makefile @@ -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. diff --git a/app/.dependency-cruiser.cjs b/app/.dependency-cruiser.cjs index 3b59596..504316e 100644 --- a/app/.dependency-cruiser.cjs +++ b/app/.dependency-cruiser.cjs @@ -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.", diff --git a/app/src/engine/worker-run.ts b/app/src/engine/worker-run.ts new file mode 100644 index 0000000..3f8193c --- /dev/null +++ b/app/src/engine/worker-run.ts @@ -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; + 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(); + let nextId = 1; + let failure: Error | null = null; + let markReady: () => void = () => {}; + let markFailed: (error: Error) => void = () => {}; + const ready = new Promise((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 { + 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 }; +} diff --git a/app/src/main.ts b/app/src/main.ts new file mode 100644 index 0000000..4963c2a --- /dev/null +++ b/app/src/main.ts @@ -0,0 +1,42 @@ +// 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. +if ("serviceWorker" in navigator) { + navigator.serviceWorker.register(new URL("../sw.js", import.meta.url)).catch(() => {}); +} diff --git a/app/src/storage/game-store.ts b/app/src/storage/game-store.ts new file mode 100644 index 0000000..d46c5c9 --- /dev/null +++ b/app/src/storage/game-store.ts @@ -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. + } + }, + }; +} diff --git a/app/src/ui/app.ts b/app/src/ui/app.ts new file mode 100644 index 0000000..9cac0d4 --- /dev/null +++ b/app/src/ui/app.ts @@ -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; + /** 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(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("roll"), + double: byId("double"), + take: byId("take"), + drop: byId("drop"), + cancel: byId("cancel"), + again: byId("again"), + newGame: byId("new-game"), + helpButton: byId("help-button"), + helpBack: byId("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>(); + 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 { + 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 { + 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): Promise { + 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 { + const played = game.events.findLast((e) => e.type === "MovePlayed"); + const places = new Set(); + 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 ""; +} diff --git a/app/src/ui/board-model.ts b/app/src/ui/board-model.ts new file mode 100644 index 0000000..dbd19f6 --- /dev/null +++ b/app/src/ui/board-model.ts @@ -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>; + readonly off: Readonly>; + readonly pipCount: Readonly>; +} + +/** 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> { + const targets = new Map>(); + 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 }; +} diff --git a/app/src/ui/board-view.ts b/app/src/ui/board-view.ts new file mode 100644 index 0000000..ce28bac --- /dev/null +++ b/app/src/ui/board-view.ts @@ -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; + /** Le pion touché, dont on montre les cases d'arrivée. */ + readonly selected: Place | null; + readonly targets: ReadonlySet; + /** Les cases de départ et d'arrivée du dernier coup de l'ordinateur. */ + readonly lastMove: ReadonlySet; +} + +// 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; + +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 = { + 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 }); +} diff --git a/app/src/ui/help-text.ts b/app/src/ui/help-text.ts new file mode 100644 index 0000000..4b4ccc7 --- /dev/null +++ b/app/src/ui/help-text.ts @@ -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("**", "") }; +} diff --git a/app/src/ui/help.ts b/app/src/ui/help.ts new file mode 100644 index 0000000..e064764 --- /dev/null +++ b/app/src/ui/help.ts @@ -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 { + const response = await fetch(url); + if (!response.ok) { + throw new Error(`${url} : ${response.status}`); + } + return response.text(); +} + +async function loadGlossary(): Promise { + 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( + 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 { + 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; + } +} diff --git a/app/src/ui/texts.ts b/app/src/ui/texts.ts new file mode 100644 index 0000000..5a177df --- /dev/null +++ b/app/src/ui/texts.ts @@ -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 = { + 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; diff --git a/app/tests/ui.test.ts b/app/tests/ui.test.ts new file mode 100644 index 0000000..cfc1218 --- /dev/null +++ b/app/tests/ui.test.ts @@ -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, extra: Partial = {}): 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(); + 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).", + ); +}); diff --git a/deploy/Caddyfile b/deploy/Caddyfile index 4d4e338..d533f28 100644 --- a/deploy/Caddyfile +++ b/deploy/Caddyfile @@ -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 } diff --git a/docs/deploiement.md b/docs/deploiement.md index 35bb54f..51f3e6e 100644 --- a/docs/deploiement.md +++ b/docs/deploiement.md @@ -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,67 @@ 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 et le hors ligne. + +**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 une partie). ## 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 diff --git a/docs/guide-test-etape-0.md b/docs/guide-test-etape-0.md index 08ea57c..48caa7a 100644 --- a/docs/guide-test-etape-0.md +++ b/docs/guide-test-etape-0.md @@ -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** (déjà 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. diff --git a/engine-wasm/bench.mjs b/engine-wasm/bench.mjs index dc2b53c..f45b59c 100644 --- a/engine-wasm/bench.mjs +++ b/engine-wasm/bench.mjs @@ -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); diff --git a/scripts/make-icons.py b/scripts/make-icons.py new file mode 100644 index 0000000..bd42c57 --- /dev/null +++ b/scripts/make-icons.py @@ -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))) diff --git a/scripts/service-worker.sh b/scripts/service-worker.sh new file mode 100755 index 0000000..10d77c5 --- /dev/null +++ b/scripts/service-worker.sh @@ -0,0 +1,40 @@ +#!/usr/bin/env bash +# Complète le service worker d'un site assemblé (make site) : en tête de +# /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 }" +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 diff --git a/tests/browser.sh b/tests/browser.sh new file mode 100755 index 0000000..35b926f --- /dev/null +++ b/tests/browser.sh @@ -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 \ + -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" diff --git a/tests/browser/play.mjs b/tests/browser/play.mjs new file mode 100644 index 0000000..34f7f71 --- /dev/null +++ b/tests/browser/play.mjs @@ -0,0 +1,215 @@ +// 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 +// 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 "); + process.exit(2); +} + +const browser = await browserType.launch(); +const context = await browser.newContext({ + viewport: { width: 360, height: 740 }, + deviceScaleFactor: 2, + locale: "fr-FR", +}); +const page = await context.newPage(); +const problems = []; +page.on("pageerror", (error) => problems.push(`erreur de la page : ${error.message}`)); +page.on("console", (message) => { + if (message.type() === "error") { + problems.push(`console : ${message.text()}`); + } +}); +const log = (text) => console.log(`[${browserName}] ${text}`); +const shot = (name) => page.screenshot({ path: `${shots}/${browserName}-${name}.png` }); + +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)); + } + throw new Error("rien à faire pour le joueur depuis 60 s"); +} + +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(); + 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 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"); + +// 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"); + +// Thème sombre. +await page.emulateMedia({ colorScheme: "dark" }); +await page.click("#again"); +await waitForPlayer(); +await shot("6-sombre"); +await page.emulateMedia({ colorScheme: "light" }); + +// 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 () => { + await navigator.serviceWorker.ready; + 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(); + if (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"); +await context.setOffline(true); +await page.reload(); +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(`hors ligne : page rechargée depuis le cache (${cached} adresses), moteur et aide disponibles`); +await shot("7-hors-ligne"); + +await browser.close(); +if (problems.length > 0) { + console.error(problems.join("\n")); + process.exit(1); +} +log("aucune erreur dans la page"); diff --git a/tests/test_site.sh b/tests/test_site.sh index 42565cb..e394931 100755 --- a/tests/test_site.sh +++ b/tests/test_site.sh @@ -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" diff --git a/tests/test_web.sh b/tests/test_web.sh index 64baee1..023d9cc 100644 --- a/tests/test_web.sh +++ b/tests/test_web.sh @@ -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 ] diff --git a/web/app.css b/web/app.css new file mode 100644 index 0000000..da3f52d --- /dev/null +++ b/web/app.css @@ -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); +} diff --git a/web/icons/icon-192.png b/web/icons/icon-192.png new file mode 100644 index 0000000..82f3f1b Binary files /dev/null and b/web/icons/icon-192.png differ diff --git a/web/icons/icon-512.png b/web/icons/icon-512.png new file mode 100644 index 0000000..febd9a4 Binary files /dev/null and b/web/icons/icon-512.png differ diff --git a/web/icons/icon.svg b/web/icons/icon.svg new file mode 100644 index 0000000..d4902fc --- /dev/null +++ b/web/icons/icon.svg @@ -0,0 +1,7 @@ + + + + + + diff --git a/web/index.html b/web/index.html index 26aaad9..63ad803 100644 --- a/web/index.html +++ b/web/index.html @@ -3,46 +3,41 @@ -Ustagammon : test du moteur - - + + +Ustagammon + + + + -
-

Test du moteur

-

Le moteur GNU Backgammon, compilé en WebAssembly, calcule dans le navigateur.

-

Chargement du moteur…

+ +
+

Ustagammon

+ + +
-
- - - -
- - - -
- -
- - - -

Page de mesure

+
+ +

+

+

+
+ + + + + + +
+
+ + diff --git a/web/manifest.webmanifest b/web/manifest.webmanifest new file mode 100644 index 0000000..3c45106 --- /dev/null +++ b/web/manifest.webmanifest @@ -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" } + ] +} diff --git a/web/bench-page.mjs b/web/moteur/bench-page.mjs similarity index 93% rename from web/bench-page.mjs rename to web/moteur/bench-page.mjs index eab399b..f6a47c1 100644 --- a/web/bench-page.mjs +++ b/web/moteur/bench-page.mjs @@ -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), diff --git a/web/bench.html b/web/moteur/bench.html similarity index 100% rename from web/bench.html rename to web/moteur/bench.html diff --git a/web/benchmark.mjs b/web/moteur/benchmark.mjs similarity index 96% rename from web/benchmark.mjs rename to web/moteur/benchmark.mjs index ee0aa86..b9c3348 100644 --- a/web/benchmark.mjs +++ b/web/moteur/benchmark.mjs @@ -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", diff --git a/web/engine-client.mjs b/web/moteur/engine-client.mjs similarity index 87% rename from web/engine-client.mjs rename to web/moteur/engine-client.mjs index d25459c..f8ddfc5 100644 --- a/web/engine-client.mjs +++ b/web/moteur/engine-client.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; diff --git a/web/moteur/index.html b/web/moteur/index.html new file mode 100644 index 0000000..614357b --- /dev/null +++ b/web/moteur/index.html @@ -0,0 +1,48 @@ + + + + + +Ustagammon : test du moteur + + + + +
+

Test du moteur

+

Le moteur GNU Backgammon, compilé en WebAssembly, calcule dans le navigateur.

+

Chargement du moteur…

+ +
+ + + +
+ + + +
+ +
+ + + +

Page de mesure · L'application

+
+ + diff --git a/web/page.mjs b/web/moteur/page.mjs similarity index 98% rename from web/page.mjs rename to web/moteur/page.mjs index a4a325c..a51c175 100644 --- a/web/page.mjs +++ b/web/moteur/page.mjs @@ -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")) { diff --git a/web/style.css b/web/moteur/style.css similarity index 100% rename from web/style.css rename to web/moteur/style.css diff --git a/web/sw.js b/web/sw.js new file mode 100644 index 0000000..823d22f --- /dev/null +++ b/web/sw.js @@ -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); + })(), + ); +});