Plateau jouable en PWA : interface, Web Worker, stockage, service worker
L'application est à la racine du site : plateau en SVG, saisie au toucher (pion puis case d'arrivée, proposées à partir du moteur), boutons du jet, du videau et de nouvelle partie, coup de l'ordinateur affiché avec son temps, fin de partie, reprise depuis localStorage, aide lue dans les copies de docs/. Le moteur tourne dans le Web Worker existant ; main.ts est le seul assembleur (nouvelles règles dependency-cruiser). Le service worker met tout le site en cache, avec une empreinte du contenu comme version. Les pages de test du moteur passent sous /moteur/. Politique de sécurité du contenu dans le Caddyfile ; make test-browser joue une partie dans Chromium et Firefox sans écran (Playwright, hors de make test). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
613ff38254
commit
a8636b3f83
+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.",
|
||||
|
||||
@@ -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,42 @@
|
||||
// Point d'entrée de l'application dans le navigateur (index.html) : le seul
|
||||
// endroit qui assemble le domaine, le moteur, le stockage et l'interface
|
||||
// (ADR 0007, vérifié par dependency-cruiser).
|
||||
//
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
|
||||
import { Game } from "./domain/game/game.js";
|
||||
import { createWasmEngine } from "./engine/wasm-engine.js";
|
||||
import { startEngineWorker } from "./engine/worker-run.js";
|
||||
import { createGameStore } from "./storage/game-store.js";
|
||||
import { startApp } from "./ui/app.js";
|
||||
|
||||
// Ce fichier est servi sous app/ ; le Worker et le service worker sont à la
|
||||
// racine du site.
|
||||
const worker = startEngineWorker(new URL("../engine-worker.mjs", import.meta.url));
|
||||
const engine = createWasmEngine(worker.run);
|
||||
const store = createGameStore(window.localStorage);
|
||||
|
||||
startApp({
|
||||
engineReady: worker.ready,
|
||||
newGame: () => Game.start(engine),
|
||||
savedGame: () => {
|
||||
const journal = store.load();
|
||||
if (journal === null) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return Game.resume(engine, journal);
|
||||
} catch {
|
||||
return null; // partie illisible : on en commence une nouvelle
|
||||
}
|
||||
},
|
||||
saveGame: (journal) => store.save(journal),
|
||||
confirm: (question) => window.confirm(question),
|
||||
});
|
||||
|
||||
// Hors ligne : le service worker met tout le site en cache. Le navigateur le
|
||||
// refuse hors https (sauf localhost) : l'application marche alors sans.
|
||||
if ("serviceWorker" in navigator) {
|
||||
navigator.serviceWorker.register(new URL("../sw.js", import.meta.url)).catch(() => {});
|
||||
}
|
||||
@@ -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,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).",
|
||||
);
|
||||
});
|
||||
@@ -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
|
||||
}
|
||||
|
||||
+49
-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,67 @@ make deploy-prod`.
|
||||
|
||||
## Ce qui est publié
|
||||
|
||||
- `make site` assemble `build/site/` : le contenu de `web/` à la racine, le
|
||||
moteur (`engine.mjs`, `engine.wasm`, `engine.data`, `positions.json`) sous
|
||||
`wasm/`, et `version.json` avec le commit publié.
|
||||
- `make site` assemble `build/site/` (9 Mo) :
|
||||
- le contenu de `web/` à la racine : la page de l'application
|
||||
(`index.html`, `app.css`), le manifeste (`manifest.webmanifest`), les
|
||||
icônes (`icons/`), le service worker (`sw.js`), le Web Worker du moteur
|
||||
(`engine-worker.mjs`, `engine-wasm.mjs`) et les pages de test sous
|
||||
`moteur/` ;
|
||||
- l'application TypeScript compilée (`build/web/src/`) sous `app/` ;
|
||||
- le moteur (`engine.mjs`, `engine.wasm`, `engine.data`, `positions.json`)
|
||||
sous `wasm/` ;
|
||||
- des copies de `docs/glossaire/` et `docs/scenarios/` sous `docs/`, lues
|
||||
par l'aide de l'application (ADR 0008) ;
|
||||
- `version.json` avec le commit publié ;
|
||||
- enfin, `scripts/service-worker.sh` écrit en tête de `sw.js` la liste de
|
||||
tous ces fichiers et une empreinte de leur contenu.
|
||||
- `make package` ajoute `deploy/` et produit `build/ustagammon.tar` (environ
|
||||
9 Mo).
|
||||
9 Mo, 7,3 Mo transférés grâce à la compression).
|
||||
- `tests/test_site.sh` (dans `make test`) construit l'image de production en
|
||||
local, la lance et vérifie ce que reçoit un navigateur : pages, types MIME
|
||||
(`application/wasm` pour le moteur), compression gzip des données, en-têtes,
|
||||
et commit de `version.json`.
|
||||
local, la lance et vérifie ce que reçoit un navigateur : application, pages
|
||||
de test, aide, manifeste et icônes, liste du service worker, types MIME
|
||||
(`application/wasm` pour le moteur), compression gzip des données,
|
||||
en-têtes dont la politique de sécurité, et commit de `version.json`.
|
||||
- `make test-browser` (hors de `make test`, plusieurs minutes) joue une
|
||||
partie complète par l'interface dans Chromium et Firefox sans écran, sur
|
||||
la même image, puis vérifie la reprise, l'aide et le hors ligne.
|
||||
|
||||
**Hors ligne et mises à jour.** Le service worker met tous les fichiers du
|
||||
site en cache à la première visite (environ 9 Mo), puis sert tout depuis ce
|
||||
cache, avec ou sans réseau. À chaque publication, l'empreinte écrite dans
|
||||
`sw.js` change dès qu'un fichier a changé : le navigateur, qui revérifie
|
||||
`sw.js` à chaque ouverture, installe la nouvelle version (tout le site est
|
||||
retéléchargé), qui prend la main et efface l'ancien cache. La page déjà
|
||||
ouverte continue sur l'ancienne version ; le lancement suivant utilise la
|
||||
nouvelle. Une publication sans changement de fichier garde le même `sw.js` :
|
||||
rien n'est retéléchargé. Le service worker exige https (ou `localhost`) :
|
||||
avec `make deploy`, en http sur le réseau local, l'application fonctionne,
|
||||
mais pas hors ligne.
|
||||
|
||||
Réglages du serveur (`deploy/Caddyfile`) : compression zstd ou gzip, y compris
|
||||
pour les données du moteur ; `Cache-Control: no-cache` (les noms de fichiers ne
|
||||
changent pas d'une version à l'autre, le navigateur revalide chaque fichier) ;
|
||||
`X-Content-Type-Options: nosniff` ; `Referrer-Policy: no-referrer`.
|
||||
`X-Content-Type-Options: nosniff` ; `Referrer-Policy: no-referrer` ; et une
|
||||
politique de sécurité du contenu (`Content-Security-Policy`) : tout vient du
|
||||
site lui-même, aucun script ni style en ligne, aucun `eval`, seule la
|
||||
compilation du WebAssembly est permise (`'wasm-unsafe-eval'`). Elle est
|
||||
vérifiée par `tests/test_site.sh` (en-tête présent) et par
|
||||
`make test-browser` (aucune violation signalée par Chromium ni Firefox
|
||||
pendant une partie).
|
||||
|
||||
## Vérifier
|
||||
|
||||
```bash
|
||||
curl -s https://ustagammon.sans.pub/version.json # commit publié
|
||||
curl -sI https://ustagammon.sans.pub/wasm/engine.wasm # application/wasm
|
||||
curl -sI https://ustagammon.sans.pub/ | grep -i content-security # CSP
|
||||
curl -s https://ustagammon.sans.pub/sw.js | head -2 # version du cache
|
||||
ssh sans.pub 'sudo docker service ps srv-captain--ustagammon --format "{{.Image}} {{.CurrentState}}" | head -2'
|
||||
```
|
||||
|
||||
Puis ouvrir le site sur le téléphone : la page de test doit afficher « Moteur
|
||||
prêt ».
|
||||
Puis ouvrir le site sur le téléphone : le plateau s'affiche et une consigne
|
||||
remplace « Chargement du moteur… ». La page de test du moteur
|
||||
(https://ustagammon.sans.pub/moteur/) doit afficher « Moteur prêt ».
|
||||
|
||||
## Retour arrière
|
||||
|
||||
|
||||
+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** (déjà fait le 2026-11-02), sur une page servie par
|
||||
le PC.
|
||||
|
||||
## Étape 0d : jouer une partie sur le téléphone
|
||||
|
||||
Le but : installer l'application sur le Fairphone 4, jouer une partie
|
||||
complète contre l'ordinateur, vérifier qu'elle marche sans réseau, et
|
||||
rapporter ce que vous avez constaté. Comptez une demi-heure.
|
||||
|
||||
### Ouvrir et installer l'application
|
||||
|
||||
1. Sur le Fairphone, ouvrez Chrome et allez sur https://ustagammon.sans.pub.
|
||||
2. Attendez que le plateau s'affiche et que le message « Chargement du
|
||||
moteur… » laisse place à une consigne (par exemple « À vous : lancez les
|
||||
dés. » ou « L'ordinateur réfléchit… »). La première fois, le moteur se
|
||||
télécharge (environ 7 Mo) : quelques secondes en Wi-Fi.
|
||||
3. Installez l'application : menu de Chrome (les trois points), puis
|
||||
« Installer l'application » ou « Ajouter à l'écran d'accueil ». Une icône
|
||||
(deux pions sur du bois) apparaît sur l'écran d'accueil.
|
||||
4. Fermez Chrome et ouvrez Ustagammon depuis cette icône : l'application
|
||||
s'ouvre en plein écran, sans barre d'adresse.
|
||||
|
||||
### Jouer une partie complète
|
||||
|
||||
Suivez le scénario « Jouer une partie contre l'ordinateur »
|
||||
(`docs/scenarios/jouer-une-partie.feature`), qui est aussi l'aide de
|
||||
l'application : bouton « Aide », en haut.
|
||||
|
||||
1. Une partie commence d'elle-même au premier lancement, par le jet
|
||||
d'ouverture (un dé chacun) : le message dit qui commence. « Nouvelle
|
||||
partie », en haut, en recommence une.
|
||||
2. Quand c'est à vous : touchez « Lancer les dés » (ou « Doubler », pour
|
||||
proposer de doubler avant de lancer).
|
||||
3. Pour jouer : touchez un pion entouré de vert, puis l'une des cases
|
||||
d'arrivée en surbrillance ; recommencez pour chaque dé. Pour sortir un
|
||||
pion, touchez le bac à droite, en bas. « Annuler » remet les pions du coup
|
||||
en cours à leur place.
|
||||
4. Le coup est envoyé dès qu'il est complet ; l'ordinateur joue ensuite, et
|
||||
le message dit son coup et son temps de calcul, par exemple
|
||||
« L'ordinateur a joué 6-4 : 24/18 13/9 (0,3 s) ». Les petits points
|
||||
orange marquent ses cases de départ et d'arrivée (dans sa numérotation :
|
||||
sa case 24 est votre case 1).
|
||||
5. Si l'ordinateur vous propose de doubler : « Prendre » ou « Passer ».
|
||||
Essayez au moins une fois de doubler vous-même.
|
||||
6. En fin de partie, le message donne le gagnant, la sorte de victoire
|
||||
(simple, gammon ou backgammon) et les points.
|
||||
|
||||
En cours de partie, fermez l'application (balayez-la dans la liste des
|
||||
applications ouvertes), puis rouvrez-la : la partie doit reprendre là où vous
|
||||
en étiez.
|
||||
|
||||
### Vérifier le hors ligne
|
||||
|
||||
1. Après avoir ouvert l'application au moins une fois (attendez une
|
||||
vingtaine de secondes après le premier chargement, le temps que tout soit
|
||||
mis de côté), fermez-la.
|
||||
2. Passez le téléphone en mode avion.
|
||||
3. Rouvrez Ustagammon depuis son icône : le plateau, l'ordinateur et l'aide
|
||||
doivent fonctionner comme avant.
|
||||
4. Repassez en mode normal.
|
||||
|
||||
### Ce qu'il faut rapporter
|
||||
|
||||
Dans la conversation avec Claude Code :
|
||||
|
||||
- les **temps des coups de l'ordinateur** : ceux affichés dans les messages
|
||||
(le plus long que vous avez vu), et votre ressenti (attente gênante ou
|
||||
non) ;
|
||||
- si la partie est allée **jusqu'au bout**, et son résultat ;
|
||||
- si la **reprise** après fermeture et le **mode avion** ont marché ;
|
||||
- tout ce qui gêne : plateau trop petit, case difficile à toucher, texte
|
||||
incompréhensible, bouton qui ne répond pas, couleurs peu lisibles (thème
|
||||
clair ou sombre du téléphone) ;
|
||||
- une **capture d'écran** (bouton marche + volume bas) de tout problème.
|
||||
|
||||
Si vous le pouvez, refaites l'essai dans Firefox sur le téléphone (sans
|
||||
l'installer) et dites si les temps de l'ordinateur sont nettement plus longs.
|
||||
|
||||
## Étape 0b : mesurer le moteur
|
||||
|
||||
Le but : ouvrir sur le téléphone une page web où le moteur de backgammon
|
||||
calcule, puis lancer une mesure et coller son rapport dans la conversation.
|
||||
Comptez un quart d'heure.
|
||||
|
||||
## 1. Préparer le PC
|
||||
### 1. Préparer le PC
|
||||
|
||||
1. Ouvrez un terminal dans le dossier du projet (`~/Code/ustagammon`).
|
||||
2. Si ce n'est pas déjà fait, tapez `make setup` puis Entrée. Cela prépare les
|
||||
outils. La première fois, c'est long ; ensuite, c'est immédiat.
|
||||
3. Le PC et le téléphone doivent être sur **le même Wi-Fi** (la même box).
|
||||
|
||||
## 2. Lancer la page
|
||||
### 2. Lancer la page
|
||||
|
||||
1. Tapez `make deploy-web` puis Entrée.
|
||||
1. Tapez `make deploy` puis Entrée.
|
||||
2. La première fois, le moteur se compile : quelques minutes.
|
||||
3. Le terminal affiche deux adresses, par exemple :
|
||||
|
||||
```
|
||||
Page de test : http://192.168.1.21:8080/
|
||||
Page de mesure : http://192.168.1.21:8080/bench.html
|
||||
Application : http://192.168.1.21:8080/
|
||||
Test du moteur : http://192.168.1.21:8080/moteur/
|
||||
Page de mesure : http://192.168.1.21:8080/moteur/bench.html
|
||||
```
|
||||
|
||||
Vos chiffres peuvent être différents : notez ceux de votre écran.
|
||||
4. Laissez ce terminal ouvert pendant tout le test. Pour arrêter la page à la
|
||||
fin : Ctrl+C dans le terminal.
|
||||
|
||||
## 3. Essayer un meilleur coup
|
||||
### 3. Essayer un meilleur coup
|
||||
|
||||
1. Sur le Fairphone, ouvrez le navigateur (Chrome ou Firefox).
|
||||
2. Tapez l'adresse de la **page de test** dans la barre d'adresse, exactement
|
||||
comme affichée (avec `http://` et `:8080`).
|
||||
2. Tapez l'adresse du **test du moteur** dans la barre d'adresse, exactement
|
||||
comme affichée (avec `http://` et `:8080`). Les pages de test sont aussi
|
||||
publiées : https://ustagammon.sans.pub/moteur/ et
|
||||
https://ustagammon.sans.pub/moteur/bench.html.
|
||||
3. Attendez le message « Moteur prêt, chargé en … ms ».
|
||||
4. Dans « Exemple », choisissez une position, par exemple « middlegame-1 ».
|
||||
5. Dans « Niveau », choisissez « fort (2 plis) ».
|
||||
@@ -40,7 +126,7 @@ Comptez un quart d'heure.
|
||||
7. La page affiche le coup conseillé et le **temps de calcul**. Notez ce temps
|
||||
si vous voulez, il sera aussi dans le rapport.
|
||||
|
||||
## 4. Lancer la mesure
|
||||
### 4. Lancer la mesure
|
||||
|
||||
1. En bas de la page, touchez « Page de mesure » (ou tapez l'adresse de la
|
||||
page de mesure).
|
||||
@@ -50,7 +136,7 @@ Comptez un quart d'heure.
|
||||
4. Un tableau résume les résultats : « OK » si le seuil est respecté,
|
||||
« dépassé » sinon.
|
||||
|
||||
## 5. Envoyer le rapport
|
||||
### 5. Envoyer le rapport
|
||||
|
||||
1. Touchez « Copier les résultats ».
|
||||
2. Si la page dit « Rapport copié », c'est bon.
|
||||
@@ -65,7 +151,7 @@ sont stables. Si les deux navigateurs sont installés, faites la mesure dans
|
||||
Chrome **et** dans Firefox : sur le PC, Firefox s'est montré nettement plus
|
||||
lent, et le rapport indique le navigateur utilisé.
|
||||
|
||||
## Si la page ne s'ouvre pas
|
||||
### Si la page ne s'ouvre pas
|
||||
|
||||
- **Vérifiez l'adresse** : `http://` (et non `https://`), les chiffres exacts,
|
||||
et `:8080` à la fin.
|
||||
@@ -73,7 +159,7 @@ lent, et le rapport indique le navigateur utilisé.
|
||||
pas sur la 4G ni sur un Wi-Fi invité.
|
||||
- **Vérifiez que la page tourne** : sur le PC, ouvrez
|
||||
`http://localhost:8080/` dans un navigateur. Si elle ne s'ouvre pas non
|
||||
plus, relancez `make deploy-web` et lisez le message d'erreur.
|
||||
plus, relancez `make deploy` et lisez le message d'erreur.
|
||||
- **Le pare-feu du PC** peut bloquer le téléphone. S'il est actif
|
||||
(`sudo ufw status` affiche « Status: active »), autorisez le port le temps
|
||||
du test : `sudo ufw allow 8080/tcp`, puis à la fin `sudo ufw delete allow
|
||||
@@ -82,4 +168,4 @@ lent, et le rapport indique le navigateur utilisé.
|
||||
collez-le dans la conversation. Essayez aussi l'autre navigateur (Chrome ou
|
||||
Firefox), à jour.
|
||||
- **Le port 8080 est déjà pris** : un autre programme l'utilise. Lancez
|
||||
`make deploy-web WEB_PORT=8081` et utilisez `:8081` dans l'adresse.
|
||||
`make deploy WEB_PORT=8081` et utilisez `:8081` dans l'adresse.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 \
|
||||
-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,215 @@
|
||||
// play.mjs -- joue une partie complète par l'interface, dans un vrai
|
||||
// navigateur sans écran (Playwright), sur le site servi par l'image de
|
||||
// production : dés, coups au toucher, coups de l'ordinateur, videau, reprise
|
||||
// après rechargement, aide, puis hors ligne (service worker).
|
||||
//
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
//
|
||||
// Usage : node play.mjs <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",
|
||||
});
|
||||
const page = await context.newPage();
|
||||
const problems = [];
|
||||
page.on("pageerror", (error) => problems.push(`erreur de la page : ${error.message}`));
|
||||
page.on("console", (message) => {
|
||||
if (message.type() === "error") {
|
||||
problems.push(`console : ${message.text()}`);
|
||||
}
|
||||
});
|
||||
const log = (text) => console.log(`[${browserName}] ${text}`);
|
||||
const shot = (name) => page.screenshot({ path: `${shots}/${browserName}-${name}.png` });
|
||||
|
||||
function check(condition, message) {
|
||||
if (!condition) {
|
||||
throw new Error(message);
|
||||
}
|
||||
}
|
||||
|
||||
/** Les coups joués de chaque camp, d'après le journal enregistré. */
|
||||
async function savedJournal() {
|
||||
return page.evaluate(() => JSON.parse(localStorage.getItem("ustagammon.current-game")));
|
||||
}
|
||||
|
||||
function countMoves(journal, side) {
|
||||
return journal.events.filter((e) => e.type === "MovePlayed" && e.side === side).length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Attend que le joueur ait quelque chose à faire, et dit quoi. (Pas de
|
||||
* page.waitForFunction : Playwright y passe par eval, que la politique de
|
||||
* sécurité du site interdit, à raison.)
|
||||
*/
|
||||
async function waitForPlayer() {
|
||||
for (let i = 0; i < 600; i++) {
|
||||
const todo = await page.evaluate(() => {
|
||||
if (document.body.classList.contains("busy")) {
|
||||
return null;
|
||||
}
|
||||
const usable = (id) => {
|
||||
const b = document.getElementById(id);
|
||||
return !b.hidden && !b.disabled;
|
||||
};
|
||||
for (const id of ["again", "take", "roll"]) {
|
||||
if (usable(id)) {
|
||||
return id;
|
||||
}
|
||||
}
|
||||
return document.querySelector(".hit.can-move") ? "move" : null;
|
||||
});
|
||||
if (todo !== null) {
|
||||
return todo;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
}
|
||||
throw new Error("rien à faire pour le joueur depuis 60 s");
|
||||
}
|
||||
|
||||
const start = Date.now();
|
||||
await page.goto(base);
|
||||
const first = await waitForPlayer();
|
||||
log(`moteur chargé, partie prête en ${Date.now() - start} ms (${first})`);
|
||||
await shot("1-debut");
|
||||
|
||||
let doubled = false;
|
||||
let selectionShot = false;
|
||||
let reloaded = false;
|
||||
let computerMs = [];
|
||||
for (let actions = 0; ; actions++) {
|
||||
check(actions < 800, "la partie ne finit pas");
|
||||
const todo = await waitForPlayer();
|
||||
const info = await page.textContent("#info");
|
||||
const timing = /\((\d+),(\d) s\)/.exec(info ?? "");
|
||||
if (timing) {
|
||||
computerMs.push(Number(timing[1]) * 1000 + Number(timing[2]) * 100);
|
||||
}
|
||||
if (todo === "again") {
|
||||
break;
|
||||
}
|
||||
if (todo === "take") {
|
||||
log("l'ordinateur double : vous prenez");
|
||||
await shot("videau-propose");
|
||||
await page.click("#take");
|
||||
} else if (todo === "roll") {
|
||||
if (!doubled && (await page.isEnabled("#double"))) {
|
||||
doubled = true;
|
||||
log("vous doublez");
|
||||
await page.click("#double");
|
||||
await waitForPlayer();
|
||||
const answer = (await page.textContent("#info")) || (await page.textContent("#prompt"));
|
||||
log(`réponse : ${answer}`);
|
||||
continue;
|
||||
}
|
||||
await page.click("#roll");
|
||||
} else {
|
||||
await page.click(".hit.can-move");
|
||||
await page.waitForSelector(".hit.can-land");
|
||||
if (!selectionShot) {
|
||||
selectionShot = true;
|
||||
await shot("2-pion-touche");
|
||||
}
|
||||
await page.click(".hit.can-land");
|
||||
}
|
||||
|
||||
// Une fois, au milieu de la partie : fermer et rouvrir la page.
|
||||
const journal = await savedJournal();
|
||||
if (!reloaded && countMoves(journal, "computer") >= 3 && countMoves(journal, "player") >= 3) {
|
||||
reloaded = true;
|
||||
await waitForPlayer();
|
||||
await shot("3-milieu-de-partie");
|
||||
const before = await savedJournal();
|
||||
const pips = await page.textContent("#pips");
|
||||
await page.reload();
|
||||
await waitForPlayer();
|
||||
const after = await savedJournal();
|
||||
check(after.head === before.head, "la partie n'a pas été reprise après rechargement");
|
||||
check((await page.textContent("#pips")) === pips, "plateau différent après rechargement");
|
||||
log(`partie reprise après rechargement (événement ${after.head})`);
|
||||
}
|
||||
}
|
||||
|
||||
const journal = await savedJournal();
|
||||
const result = await page.textContent("#prompt");
|
||||
log(
|
||||
`partie finie : ${result} (${countMoves(journal, "player")} coups du joueur, ` +
|
||||
`${countMoves(journal, "computer")} de l'ordinateur, ${journal.events.length} événements)`,
|
||||
);
|
||||
check(countMoves(journal, "player") >= 3 && countMoves(journal, "computer") >= 3, "trop peu de coups");
|
||||
check(reloaded, "la reprise après rechargement n'a pas été essayée");
|
||||
if (computerMs.length > 0) {
|
||||
log(`temps affichés des tours de l'ordinateur : max ${Math.max(...computerMs)} ms, ` +
|
||||
`${computerMs.length} tours`);
|
||||
}
|
||||
await shot("4-fin-de-partie");
|
||||
|
||||
// Aide : scénario et glossaire lus dans les copies de docs/.
|
||||
await page.click("#help-button");
|
||||
await page.waitForSelector(".glossary dt");
|
||||
const terms = await page.locator(".glossary dt").count();
|
||||
const scenarios = await page.locator("#help-content h3").count();
|
||||
check(terms >= 20 && scenarios >= 5, `aide incomplète : ${terms} termes, ${scenarios} scénarios`);
|
||||
log(`aide : ${scenarios} scénarios, ${terms} termes du glossaire`);
|
||||
await shot("5-aide");
|
||||
await page.click("#help-back");
|
||||
|
||||
// Thème sombre.
|
||||
await page.emulateMedia({ colorScheme: "dark" });
|
||||
await page.click("#again");
|
||||
await waitForPlayer();
|
||||
await shot("6-sombre");
|
||||
await page.emulateMedia({ colorScheme: "light" });
|
||||
|
||||
// Hors ligne : une fois le cache du service worker rempli, la page se
|
||||
// recharge sans réseau, et le moteur répond.
|
||||
const cached = await page.evaluate(async () => {
|
||||
await navigator.serviceWorker.ready;
|
||||
for (let i = 0; i < 600; i++) {
|
||||
const names = await caches.keys();
|
||||
if (names.length === 1) {
|
||||
const cache = await caches.open(names[0]);
|
||||
const keys = await cache.keys();
|
||||
if (keys.some((r) => r.url.endsWith("/wasm/engine.data"))) {
|
||||
return keys.length;
|
||||
}
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
check(cached > 0, "le service worker n'a pas mis le site en cache");
|
||||
await context.setOffline(true);
|
||||
await page.reload();
|
||||
const offline = await waitForPlayer();
|
||||
if (offline === "roll") {
|
||||
await page.click("#roll");
|
||||
await waitForPlayer();
|
||||
}
|
||||
await page.click("#help-button");
|
||||
await page.waitForSelector(".glossary dt");
|
||||
await page.click("#help-back");
|
||||
log(`hors ligne : page rechargée depuis le cache (${cached} adresses), moteur et aide disponibles`);
|
||||
await shot("7-hors-ligne");
|
||||
|
||||
await browser.close();
|
||||
if (problems.length > 0) {
|
||||
console.error(problems.join("\n"));
|
||||
process.exit(1);
|
||||
}
|
||||
log("aucune erreur dans la page");
|
||||
+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);
|
||||
}
|
||||
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