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